From 4ab426c52a33b29ec13d152b7cd76dfceef17d6a Mon Sep 17 00:00:00 2001 From: bosiraphael <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 19 Mar 2024 18:34:00 +0100 Subject: [PATCH] 4485 create a custom resolver for calendar events (#4568) * create timeline calendar event resolver * working on getCalendarEventsFromPersonIds * add count query * add calendarEventVisibility and add typing * update calendarEvent dto * modify calendarEvent dto * compute calendar event visibility * fix types * add FieldMetadata in timeline calendar dtos and create queries and fragments * remove fieldMatadata * fix naming * update resolver * add getCalendarEventsFromCompanyId * fix queries * refactor queries * fix visibility * fix calendar event attendees bug * visibility is working * remove @IDField * update gql queries * update dto * add error * add enum * throw http exception * modify error * Refactor calendar event visibility check * use enum --- .../twenty-front/src/generated/graphql.tsx | 216 +++++++++++++- .../queries/fragments/attendeeFragment.ts | 13 + .../fragments/calendarEventFragment.ts | 19 ++ .../calendarEventFragmentWithTotalFragment.ts | 13 + .../queries/getCalendarEventsFromCompanyId.ts | 20 ++ .../queries/getCalendarEventsFromPersonId.ts | 20 ++ .../calendar/constants/calendar.constants.ts | 2 + .../timeline-calendar-event-attendee.dto.ts | 25 ++ .../dtos/timeline-calendar-event.dto.ts | 52 ++++ ...timeline-calendar-events-with-total.dto.ts | 12 + .../timeline-calendar-event.module.ts | 12 + .../timeline-calendar-event.resolver.ts | 108 +++++++ .../timeline-calendar-event.service.ts | 271 ++++++++++++++++++ .../engine/modules/engine-modules.module.ts | 3 + .../messaging/dtos/timeline-thread.dto.ts | 4 +- 15 files changed, 785 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-front/src/modules/activities/calendar/queries/fragments/attendeeFragment.ts create mode 100644 packages/twenty-front/src/modules/activities/calendar/queries/fragments/calendarEventFragment.ts create mode 100644 packages/twenty-front/src/modules/activities/calendar/queries/fragments/calendarEventFragmentWithTotalFragment.ts create mode 100644 packages/twenty-front/src/modules/activities/calendar/queries/getCalendarEventsFromCompanyId.ts create mode 100644 packages/twenty-front/src/modules/activities/calendar/queries/getCalendarEventsFromPersonId.ts create mode 100644 packages/twenty-server/src/engine/modules/calendar/constants/calendar.constants.ts create mode 100644 packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-event-attendee.dto.ts create mode 100644 packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-event.dto.ts create mode 100644 packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-events-with-total.dto.ts create mode 100644 packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.module.ts create mode 100644 packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.resolver.ts create mode 100644 packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.service.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index c96373fe9..973de1bd5 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -61,7 +61,7 @@ export type AuthTokens = { export type Billing = { __typename?: 'Billing'; billingFreeTrialDurationInDays?: Maybe; - billingUrl: Scalars['String']; + billingUrl?: Maybe; isBillingEnabled: Scalars['Boolean']; }; @@ -417,6 +417,8 @@ export type Query = { currentWorkspace: Workspace; findWorkspaceFromInviteHash: Workspace; getProductPrices: ProductPricesEntity; + getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; + getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal; getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; object: Object; @@ -450,6 +452,20 @@ export type QueryGetProductPricesArgs = { }; +export type QueryGetTimelineCalendarEventsFromCompanyIdArgs = { + companyId: Scalars['ID']; + page: Scalars['Int']; + pageSize: Scalars['Int']; +}; + + +export type QueryGetTimelineCalendarEventsFromPersonIdArgs = { + page: Scalars['Int']; + pageSize: Scalars['Int']; + personId: Scalars['ID']; +}; + + export type QueryGetTimelineThreadsFromCompanyIdArgs = { companyId: Scalars['ID']; page: Scalars['Int']; @@ -492,6 +508,23 @@ export type RelationConnection = { pageInfo: PageInfo; }; +export type RelationDefinition = { + __typename?: 'RelationDefinition'; + direction: RelationDefinitionType; + sourceFieldMetadata: Field; + sourceObjectMetadata: Object; + targetFieldMetadata: Field; + targetObjectMetadata: Object; +}; + +/** Relation definition type */ +export enum RelationDefinitionType { + ManyToMany = 'MANY_TO_MANY', + ManyToOne = 'MANY_TO_ONE', + OneToMany = 'ONE_TO_MANY', + OneToOne = 'ONE_TO_ONE' +} + export type RelationDeleteResponse = { __typename?: 'RelationDeleteResponse'; createdAt?: Maybe; @@ -545,6 +578,45 @@ export type Telemetry = { enabled: Scalars['Boolean']; }; +export type TimelineCalendarEvent = { + __typename?: 'TimelineCalendarEvent'; + attendees: Array; + conferenceSolution: Scalars['String']; + conferenceUri: Scalars['String']; + description: Scalars['String']; + endsAt: Scalars['DateTime']; + id: Scalars['ID']; + isCanceled: Scalars['Boolean']; + isFullDay: Scalars['Boolean']; + location: Scalars['String']; + startsAt: Scalars['DateTime']; + title: Scalars['String']; + visibility: TimelineCalendarEventVisibility; +}; + +export type TimelineCalendarEventAttendee = { + __typename?: 'TimelineCalendarEventAttendee'; + avatarUrl: Scalars['String']; + displayName: Scalars['String']; + firstName: Scalars['String']; + handle: Scalars['String']; + lastName: Scalars['String']; + personId?: Maybe; + workspaceMemberId?: Maybe; +}; + +/** Visibility of the calendar event */ +export enum TimelineCalendarEventVisibility { + Metadata = 'METADATA', + ShareEverything = 'SHARE_EVERYTHING' +} + +export type TimelineCalendarEventsWithTotal = { + __typename?: 'TimelineCalendarEventsWithTotal'; + timelineCalendarEvents: Array; + totalNumberOfCalendarEvents: Scalars['Int']; +}; + export type TimelineThread = { __typename?: 'TimelineThread'; firstParticipant: TimelineThreadParticipant; @@ -716,6 +788,7 @@ export type Field = { label: Scalars['String']; name: Scalars['String']; options?: Maybe; + relationDefinition?: Maybe; toRelationMetadata?: Maybe; type: FieldMetadataType; updatedAt: Scalars['DateTime']; @@ -794,6 +867,30 @@ export type RelationEdge = { node: Relation; }; +export type AttendeeFragmentFragment = { __typename?: 'TimelineCalendarEventAttendee', personId?: string | null, workspaceMemberId?: string | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }; + +export type CalendarEventFragmentFragment = { __typename?: 'TimelineCalendarEvent', id: string, title: string, description: string, location: string, startsAt: string, endsAt: string, isFullDay: boolean, attendees: Array<{ __typename?: 'TimelineCalendarEventAttendee', personId?: string | null, workspaceMemberId?: string | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }; + +export type TimelineCalendarEventsWithTotalFragmentFragment = { __typename?: 'TimelineCalendarEventsWithTotal', totalNumberOfCalendarEvents: number, timelineCalendarEvents: Array<{ __typename?: 'TimelineCalendarEvent', id: string, title: string, description: string, location: string, startsAt: string, endsAt: string, isFullDay: boolean, attendees: Array<{ __typename?: 'TimelineCalendarEventAttendee', personId?: string | null, workspaceMemberId?: string | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> }; + +export type GetTimelineCalendarEventsFromCompanyIdQueryVariables = Exact<{ + companyId: Scalars['ID']; + page: Scalars['Int']; + pageSize: Scalars['Int']; +}>; + + +export type GetTimelineCalendarEventsFromCompanyIdQuery = { __typename?: 'Query', getTimelineCalendarEventsFromCompanyId: { __typename?: 'TimelineCalendarEventsWithTotal', totalNumberOfCalendarEvents: number, timelineCalendarEvents: Array<{ __typename?: 'TimelineCalendarEvent', id: string, title: string, description: string, location: string, startsAt: string, endsAt: string, isFullDay: boolean, attendees: Array<{ __typename?: 'TimelineCalendarEventAttendee', personId?: string | null, workspaceMemberId?: string | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> } }; + +export type GetTimelineCalendarEventsFromPersonIdQueryVariables = Exact<{ + personId: Scalars['ID']; + page: Scalars['Int']; + pageSize: Scalars['Int']; +}>; + + +export type GetTimelineCalendarEventsFromPersonIdQuery = { __typename?: 'Query', getTimelineCalendarEventsFromPersonId: { __typename?: 'TimelineCalendarEventsWithTotal', totalNumberOfCalendarEvents: number, timelineCalendarEvents: Array<{ __typename?: 'TimelineCalendarEvent', id: string, title: string, description: string, location: string, startsAt: string, endsAt: string, isFullDay: boolean, attendees: Array<{ __typename?: 'TimelineCalendarEventAttendee', personId?: string | null, workspaceMemberId?: string | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> } }; + export type ParticipantFragmentFragment = { __typename?: 'TimelineThreadParticipant', personId?: string | null, workspaceMemberId?: string | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }; export type TimelineThreadFragmentFragment = { __typename?: 'TimelineThread', id: string, read: boolean, visibility: string, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: string | null, workspaceMemberId?: string | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: string | null, workspaceMemberId?: string | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }; @@ -937,7 +1034,7 @@ export type GetProductPricesQuery = { __typename?: 'Query', getProductPrices: { export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl: string, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null } } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null } } }; export type UploadFileMutationVariables = Exact<{ file: Scalars['Upload']; @@ -1007,6 +1104,39 @@ export type GetWorkspaceFromInviteHashQueryVariables = Exact<{ export type GetWorkspaceFromInviteHashQuery = { __typename?: 'Query', findWorkspaceFromInviteHash: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean } }; +export const AttendeeFragmentFragmentDoc = gql` + fragment AttendeeFragment on TimelineCalendarEventAttendee { + personId + workspaceMemberId + firstName + lastName + displayName + avatarUrl + handle +} + `; +export const CalendarEventFragmentFragmentDoc = gql` + fragment CalendarEventFragment on TimelineCalendarEvent { + id + title + description + location + startsAt + endsAt + isFullDay + attendees { + ...AttendeeFragment + } +} + ${AttendeeFragmentFragmentDoc}`; +export const TimelineCalendarEventsWithTotalFragmentFragmentDoc = gql` + fragment TimelineCalendarEventsWithTotalFragment on TimelineCalendarEventsWithTotal { + totalNumberOfCalendarEvents + timelineCalendarEvents { + ...CalendarEventFragment + } +} + ${CalendarEventFragmentFragmentDoc}`; export const ParticipantFragmentFragmentDoc = gql` fragment ParticipantFragment on TimelineThreadParticipant { personId @@ -1103,6 +1233,88 @@ export const UserQueryFragmentFragmentDoc = gql` } } `; +export const GetTimelineCalendarEventsFromCompanyIdDocument = gql` + query GetTimelineCalendarEventsFromCompanyId($companyId: ID!, $page: Int!, $pageSize: Int!) { + getTimelineCalendarEventsFromCompanyId( + companyId: $companyId + page: $page + pageSize: $pageSize + ) { + ...TimelineCalendarEventsWithTotalFragment + } +} + ${TimelineCalendarEventsWithTotalFragmentFragmentDoc}`; + +/** + * __useGetTimelineCalendarEventsFromCompanyIdQuery__ + * + * To run a query within a React component, call `useGetTimelineCalendarEventsFromCompanyIdQuery` and pass it any options that fit your needs. + * When your component renders, `useGetTimelineCalendarEventsFromCompanyIdQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetTimelineCalendarEventsFromCompanyIdQuery({ + * variables: { + * companyId: // value for 'companyId' + * page: // value for 'page' + * pageSize: // value for 'pageSize' + * }, + * }); + */ +export function useGetTimelineCalendarEventsFromCompanyIdQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetTimelineCalendarEventsFromCompanyIdDocument, options); + } +export function useGetTimelineCalendarEventsFromCompanyIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetTimelineCalendarEventsFromCompanyIdDocument, options); + } +export type GetTimelineCalendarEventsFromCompanyIdQueryHookResult = ReturnType; +export type GetTimelineCalendarEventsFromCompanyIdLazyQueryHookResult = ReturnType; +export type GetTimelineCalendarEventsFromCompanyIdQueryResult = Apollo.QueryResult; +export const GetTimelineCalendarEventsFromPersonIdDocument = gql` + query GetTimelineCalendarEventsFromPersonId($personId: ID!, $page: Int!, $pageSize: Int!) { + getTimelineCalendarEventsFromPersonId( + personId: $personId + page: $page + pageSize: $pageSize + ) { + ...TimelineCalendarEventsWithTotalFragment + } +} + ${TimelineCalendarEventsWithTotalFragmentFragmentDoc}`; + +/** + * __useGetTimelineCalendarEventsFromPersonIdQuery__ + * + * To run a query within a React component, call `useGetTimelineCalendarEventsFromPersonIdQuery` and pass it any options that fit your needs. + * When your component renders, `useGetTimelineCalendarEventsFromPersonIdQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetTimelineCalendarEventsFromPersonIdQuery({ + * variables: { + * personId: // value for 'personId' + * page: // value for 'page' + * pageSize: // value for 'pageSize' + * }, + * }); + */ +export function useGetTimelineCalendarEventsFromPersonIdQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetTimelineCalendarEventsFromPersonIdDocument, options); + } +export function useGetTimelineCalendarEventsFromPersonIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetTimelineCalendarEventsFromPersonIdDocument, options); + } +export type GetTimelineCalendarEventsFromPersonIdQueryHookResult = ReturnType; +export type GetTimelineCalendarEventsFromPersonIdLazyQueryHookResult = ReturnType; +export type GetTimelineCalendarEventsFromPersonIdQueryResult = Apollo.QueryResult; export const GetTimelineThreadsFromCompanyIdDocument = gql` query GetTimelineThreadsFromCompanyId($companyId: ID!, $page: Int!, $pageSize: Int!) { getTimelineThreadsFromCompanyId( diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/fragments/attendeeFragment.ts b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/attendeeFragment.ts new file mode 100644 index 000000000..fc6b600dd --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/attendeeFragment.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const attendeeFragment = gql` + fragment AttendeeFragment on TimelineCalendarEventAttendee { + personId + workspaceMemberId + firstName + lastName + displayName + avatarUrl + handle + } +`; diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/fragments/calendarEventFragment.ts b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/calendarEventFragment.ts new file mode 100644 index 000000000..5188d3d77 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/calendarEventFragment.ts @@ -0,0 +1,19 @@ +import { gql } from '@apollo/client'; + +import { attendeeFragment } from '@/activities/calendar/queries/fragments/attendeeFragment'; + +export const calendarEventFragment = gql` + fragment CalendarEventFragment on TimelineCalendarEvent { + id + title + description + location + startsAt + endsAt + isFullDay + attendees { + ...AttendeeFragment + } + } + ${attendeeFragment} +`; diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/fragments/calendarEventFragmentWithTotalFragment.ts b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/calendarEventFragmentWithTotalFragment.ts new file mode 100644 index 000000000..5788bb09c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/calendarEventFragmentWithTotalFragment.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +import { calendarEventFragment } from '@/activities/calendar/queries/fragments/calendarEventFragment'; + +export const timelineCalendarEventWithTotalFragment = gql` + fragment TimelineCalendarEventsWithTotalFragment on TimelineCalendarEventsWithTotal { + totalNumberOfCalendarEvents + timelineCalendarEvents { + ...CalendarEventFragment + } + } + ${calendarEventFragment} +`; diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/getCalendarEventsFromCompanyId.ts b/packages/twenty-front/src/modules/activities/calendar/queries/getCalendarEventsFromCompanyId.ts new file mode 100644 index 000000000..1928fded2 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/queries/getCalendarEventsFromCompanyId.ts @@ -0,0 +1,20 @@ +import { gql } from '@apollo/client'; + +import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/queries/fragments/calendarEventFragmentWithTotalFragment'; + +export const getTimelineCalendarEventsFromCompanyId = gql` + query GetTimelineCalendarEventsFromCompanyId( + $companyId: ID! + $page: Int! + $pageSize: Int! + ) { + getTimelineCalendarEventsFromCompanyId( + companyId: $companyId + page: $page + pageSize: $pageSize + ) { + ...TimelineCalendarEventsWithTotalFragment + } + } + ${timelineCalendarEventWithTotalFragment} +`; diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/getCalendarEventsFromPersonId.ts b/packages/twenty-front/src/modules/activities/calendar/queries/getCalendarEventsFromPersonId.ts new file mode 100644 index 000000000..764282a6b --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/queries/getCalendarEventsFromPersonId.ts @@ -0,0 +1,20 @@ +import { gql } from '@apollo/client'; + +import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/queries/fragments/calendarEventFragmentWithTotalFragment'; + +export const getTimelineCalendarEventsFromPersonId = gql` + query GetTimelineCalendarEventsFromPersonId( + $personId: ID! + $page: Int! + $pageSize: Int! + ) { + getTimelineCalendarEventsFromPersonId( + personId: $personId + page: $page + pageSize: $pageSize + ) { + ...TimelineCalendarEventsWithTotalFragment + } + } + ${timelineCalendarEventWithTotalFragment} +`; diff --git a/packages/twenty-server/src/engine/modules/calendar/constants/calendar.constants.ts b/packages/twenty-server/src/engine/modules/calendar/constants/calendar.constants.ts new file mode 100644 index 000000000..3a0d0be05 --- /dev/null +++ b/packages/twenty-server/src/engine/modules/calendar/constants/calendar.constants.ts @@ -0,0 +1,2 @@ +export const TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE = 20; +export const TIMELINE_CALENDAR_EVENTS_MAX_PAGE_SIZE = 50; diff --git a/packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-event-attendee.dto.ts b/packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-event-attendee.dto.ts new file mode 100644 index 000000000..1b5082b22 --- /dev/null +++ b/packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-event-attendee.dto.ts @@ -0,0 +1,25 @@ +import { ObjectType, Field, ID } from '@nestjs/graphql'; + +@ObjectType('TimelineCalendarEventAttendee') +export class TimelineCalendarEventAttendee { + @Field(() => ID, { nullable: true }) + personId: string; + + @Field(() => ID, { nullable: true }) + workspaceMemberId: string; + + @Field() + firstName: string; + + @Field() + lastName: string; + + @Field() + displayName: string; + + @Field() + avatarUrl: string; + + @Field() + handle: string; +} diff --git a/packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-event.dto.ts b/packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-event.dto.ts new file mode 100644 index 000000000..3d276f72e --- /dev/null +++ b/packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-event.dto.ts @@ -0,0 +1,52 @@ +import { ObjectType, ID, Field, registerEnumType } from '@nestjs/graphql'; + +import { TimelineCalendarEventAttendee } from 'src/engine/modules/calendar/dtos/timeline-calendar-event-attendee.dto'; + +export enum TimelineCalendarEventVisibility { + METADATA = 'METADATA', + SHARE_EVERYTHING = 'SHARE_EVERYTHING', +} + +registerEnumType(TimelineCalendarEventVisibility, { + name: 'TimelineCalendarEventVisibility', + description: 'Visibility of the calendar event', +}); + +@ObjectType('TimelineCalendarEvent') +export class TimelineCalendarEvent { + @Field(() => ID) + id: string; + + @Field() + title: string; + + @Field() + isCanceled: boolean; + + @Field() + isFullDay: boolean; + + @Field() + startsAt: Date; + + @Field() + endsAt: Date; + + @Field() + description: string; + + @Field() + location: string; + + @Field() + conferenceSolution: string; + + @Field() + conferenceUri: string; + + @Field(() => [TimelineCalendarEventAttendee]) + attendees: TimelineCalendarEventAttendee[]; + + @Field(() => TimelineCalendarEventVisibility) + visibility: TimelineCalendarEventVisibility; +} diff --git a/packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-events-with-total.dto.ts b/packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-events-with-total.dto.ts new file mode 100644 index 000000000..4b879c380 --- /dev/null +++ b/packages/twenty-server/src/engine/modules/calendar/dtos/timeline-calendar-events-with-total.dto.ts @@ -0,0 +1,12 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +import { TimelineCalendarEvent } from 'src/engine/modules/calendar/dtos/timeline-calendar-event.dto'; + +@ObjectType('TimelineCalendarEventsWithTotal') +export class TimelineCalendarEventsWithTotal { + @Field(() => Int) + totalNumberOfCalendarEvents: number; + + @Field(() => [TimelineCalendarEvent]) + timelineCalendarEvents: TimelineCalendarEvent[]; +} diff --git a/packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.module.ts b/packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.module.ts new file mode 100644 index 000000000..7b658e172 --- /dev/null +++ b/packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; +import { UserModule } from 'src/engine/modules/user/user.module'; +import { TimelineCalendarEventResolver } from 'src/engine/modules/calendar/timeline-calendar-event.resolver'; +import { TimelineCalendarEventService } from 'src/engine/modules/calendar/timeline-calendar-event.service'; +@Module({ + imports: [WorkspaceDataSourceModule, UserModule], + exports: [], + providers: [TimelineCalendarEventResolver, TimelineCalendarEventService], +}) +export class TimelineCalendarEventModule {} diff --git a/packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.resolver.ts b/packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.resolver.ts new file mode 100644 index 000000000..1e9e176dc --- /dev/null +++ b/packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.resolver.ts @@ -0,0 +1,108 @@ +import { UseGuards } from '@nestjs/common'; +import { + Query, + Args, + ArgsType, + Field, + ID, + Int, + Resolver, +} from '@nestjs/graphql'; + +import { Max } from 'class-validator'; + +import { User } from 'src/engine/modules/user/user.entity'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard'; +import { TIMELINE_CALENDAR_EVENTS_MAX_PAGE_SIZE } from 'src/engine/modules/calendar/constants/calendar.constants'; +import { TimelineCalendarEventsWithTotal } from 'src/engine/modules/calendar/dtos/timeline-calendar-events-with-total.dto'; +import { TimelineCalendarEventService } from 'src/engine/modules/calendar/timeline-calendar-event.service'; +import { UserService } from 'src/engine/modules/user/services/user.service'; +import { Workspace } from 'src/engine/modules/workspace/workspace.entity'; +import { NotFoundError } from 'src/engine/filters/utils/graphql-errors.util'; + +@ArgsType() +class GetTimelineCalendarEventsFromPersonIdArgs { + @Field(() => ID) + personId: string; + + @Field(() => Int) + page: number; + + @Field(() => Int) + @Max(TIMELINE_CALENDAR_EVENTS_MAX_PAGE_SIZE) + pageSize: number; +} + +@ArgsType() +class GetTimelineCalendarEventsFromCompanyIdArgs { + @Field(() => ID) + companyId: string; + + @Field(() => Int) + page: number; + + @Field(() => Int) + @Max(TIMELINE_CALENDAR_EVENTS_MAX_PAGE_SIZE) + pageSize: number; +} + +@UseGuards(JwtAuthGuard) +@Resolver(() => TimelineCalendarEventsWithTotal) +export class TimelineCalendarEventResolver { + constructor( + private readonly timelineCalendarEventService: TimelineCalendarEventService, + private readonly userService: UserService, + ) {} + + @Query(() => TimelineCalendarEventsWithTotal) + async getTimelineCalendarEventsFromPersonId( + @AuthWorkspace() { id: workspaceId }: Workspace, + @AuthUser() user: User, + @Args() + { personId, page, pageSize }: GetTimelineCalendarEventsFromPersonIdArgs, + ) { + const workspaceMember = await this.userService.loadWorkspaceMember(user); + + if (!workspaceMember) { + throw new NotFoundError('Workspace member not found'); + } + + const timelineCalendarEvents = + await this.timelineCalendarEventService.getCalendarEventsFromPersonIds( + workspaceMember.id, + workspaceId, + [personId], + page, + pageSize, + ); + + return timelineCalendarEvents; + } + + @Query(() => TimelineCalendarEventsWithTotal) + async getTimelineCalendarEventsFromCompanyId( + @AuthWorkspace() { id: workspaceId }: Workspace, + @AuthUser() user: User, + @Args() + { companyId, page, pageSize }: GetTimelineCalendarEventsFromCompanyIdArgs, + ) { + const workspaceMember = await this.userService.loadWorkspaceMember(user); + + if (!workspaceMember) { + throw new NotFoundError('Workspace member not found'); + } + + const timelineCalendarEvents = + await this.timelineCalendarEventService.getCalendarEventsFromCompanyId( + workspaceMember.id, + workspaceId, + companyId, + page, + pageSize, + ); + + return timelineCalendarEvents; + } +} diff --git a/packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.service.ts b/packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.service.ts new file mode 100644 index 000000000..37429daf7 --- /dev/null +++ b/packages/twenty-server/src/engine/modules/calendar/timeline-calendar-event.service.ts @@ -0,0 +1,271 @@ +import { Injectable } from '@nestjs/common'; + +import groupBy from 'lodash.groupBy'; + +import { TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE } from 'src/engine/modules/calendar/constants/calendar.constants'; +import { TimelineCalendarEventAttendee } from 'src/engine/modules/calendar/dtos/timeline-calendar-event-attendee.dto'; +import { + TimelineCalendarEvent, + TimelineCalendarEventVisibility, +} from 'src/engine/modules/calendar/dtos/timeline-calendar-event.dto'; +import { TimelineCalendarEventsWithTotal } from 'src/engine/modules/calendar/dtos/timeline-calendar-events-with-total.dto'; +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { ObjectRecord } from 'src/engine/workspace-manager/workspace-sync-metadata/types/object-record'; +import { CalendarEventAttendeeObjectMetadata } from 'src/modules/calendar/standard-objects/calendar-event-attendee.object-metadata'; + +type TimelineCalendarEventAttendeeWithPersonInformation = + ObjectRecord & { + personFirstName: string; + personLastName: string; + personAvatarUrl: string; + workspaceMemberFirstName: string; + workspaceMemberLastName: string; + workspaceMemberAvatarUrl: string; + }; +@Injectable() +export class TimelineCalendarEventService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + async getCalendarEventsFromPersonIds( + workspaceMemberId: string, + workspaceId: string, + personIds: string[], + page: number = 1, + pageSize: number = TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE, + ): Promise { + const offset = (page - 1) * pageSize; + + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + const calendarEvents: Omit[] = + await this.workspaceDataSourceService.executeRawQuery( + `SELECT + "calendarEvent".* + FROM + ${dataSourceSchema}."calendarEvent" "calendarEvent" + LEFT JOIN + ${dataSourceSchema}."calendarEventAttendee" "calendarEventAttendee" ON "calendarEvent".id = "calendarEventAttendee"."calendarEventId" + LEFT JOIN + ${dataSourceSchema}."person" "person" ON "calendarEventAttendee"."personId" = "person".id + WHERE + "calendarEventAttendee"."personId" = ANY($1) + GROUP BY + "calendarEvent".id + ORDER BY + "calendarEvent"."startsAt" DESC + LIMIT $2 + OFFSET $3`, + [personIds, pageSize, offset], + workspaceId, + ); + + if (!calendarEvents) { + return { + totalNumberOfCalendarEvents: 0, + timelineCalendarEvents: [], + }; + } + + const calendarEventAttendees: TimelineCalendarEventAttendeeWithPersonInformation[] = + await this.workspaceDataSourceService.executeRawQuery( + `SELECT + "calendarEventAttendee".*, + "person"."nameFirstName" as "personFirstName", + "person"."nameLastName" as "personLastName", + "person"."avatarUrl" as "personAvatarUrl", + "workspaceMember"."nameFirstName" as "workspaceMemberFirstName", + "workspaceMember"."nameLastName" as "workspaceMemberLastName", + "workspaceMember"."avatarUrl" as "workspaceMemberAvatarUrl" + FROM + ${dataSourceSchema}."calendarEventAttendee" "calendarEventAttendee" + LEFT JOIN + ${dataSourceSchema}."person" "person" ON "calendarEventAttendee"."personId" = "person".id + LEFT JOIN + ${dataSourceSchema}."workspaceMember" "workspaceMember" ON "calendarEventAttendee"."workspaceMemberId" = "workspaceMember".id + WHERE + "calendarEventAttendee"."calendarEventId" = ANY($1)`, + [calendarEvents.map((event) => event.id)], + workspaceId, + ); + + const formattedCalendarEventAttendees: TimelineCalendarEventAttendee[] = + calendarEventAttendees.map((attendee) => { + const firstName = + attendee.personFirstName || attendee.workspaceMemberFirstName || ''; + + const lastName = + attendee.personLastName || attendee.workspaceMemberLastName || ''; + + const displayName = + firstName || attendee.displayName || attendee.handle; + + const avatarUrl = + attendee.personAvatarUrl || attendee.workspaceMemberAvatarUrl || ''; + + return { + calendarEventId: attendee.calendarEventId, + personId: attendee.personId, + workspaceMemberId: attendee.workspaceMemberId, + firstName, + lastName, + displayName, + avatarUrl, + handle: attendee.handle, + }; + }); + + const calendarEventAttendeesByEventId: { + [calendarEventId: string]: TimelineCalendarEventAttendee[]; + } = groupBy(formattedCalendarEventAttendees, 'calendarEventId'); + + const totalNumberOfCalendarEvents: { count: number }[] = + await this.workspaceDataSourceService.executeRawQuery( + ` + SELECT + COUNT(DISTINCT "calendarEventId") + FROM + ${dataSourceSchema}."calendarEventAttendee" "calendarEventAttendee" + WHERE + "calendarEventAttendee"."personId" = ANY($1) + `, + [personIds], + workspaceId, + ); + + const timelineCalendarEvents = calendarEvents.map((event) => { + const attendees = calendarEventAttendeesByEventId[event.id] || []; + + return { + ...event, + attendees, + }; + }); + + const calendarEventIdsWithWorkspaceMemberInAttendees = + await this.workspaceDataSourceService.executeRawQuery( + ` + SELECT + "calendarEventId" + FROM + ${dataSourceSchema}."calendarEventAttendee" "calendarEventAttendee" + WHERE + "calendarEventAttendee"."workspaceMemberId" = $1 + `, + [workspaceMemberId], + workspaceId, + ); + + const calendarEventIdsWithWorkspaceMemberInAttendeesFormatted = + calendarEventIdsWithWorkspaceMemberInAttendees.map( + (event: { calendarEventId: string }) => event.calendarEventId, + ); + + const calendarEventIdsToFetchVisibilityFor = timelineCalendarEvents + .filter( + (event) => + !calendarEventIdsWithWorkspaceMemberInAttendeesFormatted.includes( + event.id, + ), + ) + .map((event) => event.id); + + const calendarEventIdsForWhichVisibilityIsMetadata: + | { + id: string; + }[] + | undefined = await this.workspaceDataSourceService.executeRawQuery( + ` + SELECT + "calendarChannelEventAssociation"."calendarEventId" AS "id" + FROM + ${dataSourceSchema}."calendarChannel" "calendarChannel" + LEFT JOIN + ${dataSourceSchema}."calendarChannelEventAssociation" "calendarChannelEventAssociation" ON "calendarChannel".id = "calendarChannelEventAssociation"."calendarChannelId" + WHERE + "calendarChannelEventAssociation"."calendarEventId" = ANY($1) + AND + "calendarChannel"."visibility" = 'METADATA' + `, + [calendarEventIdsToFetchVisibilityFor], + workspaceId, + ); + + if (!calendarEventIdsForWhichVisibilityIsMetadata) { + throw new Error('Failed to fetch calendar event visibility'); + } + + const calendarEventIdsForWhichVisibilityIsMetadataMap = new Map( + calendarEventIdsForWhichVisibilityIsMetadata.map((event) => [ + event.id, + TimelineCalendarEventVisibility.METADATA, + ]), + ); + + timelineCalendarEvents.forEach((event) => { + event.visibility = + calendarEventIdsForWhichVisibilityIsMetadataMap.get(event.id) ?? + TimelineCalendarEventVisibility.SHARE_EVERYTHING; + + if (event.visibility === TimelineCalendarEventVisibility.METADATA) { + event.title = ''; + event.description = ''; + event.location = ''; + event.conferenceSolution = ''; + event.conferenceUri = ''; + } + }); + + return { + totalNumberOfCalendarEvents: totalNumberOfCalendarEvents[0].count, + timelineCalendarEvents, + }; + } + + async getCalendarEventsFromCompanyId( + workspaceMemberId: string, + workspaceId: string, + companyId: string, + page: number = 1, + pageSize: number = TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE, + ): Promise { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + const personIds = await this.workspaceDataSourceService.executeRawQuery( + ` + SELECT + p."id" + FROM + ${dataSourceSchema}."person" p + WHERE + p."companyId" = $1 + `, + [companyId], + workspaceId, + ); + + if (!personIds) { + return { + totalNumberOfCalendarEvents: 0, + timelineCalendarEvents: [], + }; + } + + const formattedPersonIds = personIds.map( + (personId: { id: string }) => personId.id, + ); + + const messageThreads = await this.getCalendarEventsFromPersonIds( + workspaceMemberId, + workspaceId, + formattedPersonIds, + page, + pageSize, + ); + + return messageThreads; + } +} diff --git a/packages/twenty-server/src/engine/modules/engine-modules.module.ts b/packages/twenty-server/src/engine/modules/engine-modules.module.ts index aa23a0f52..d0dd30973 100644 --- a/packages/twenty-server/src/engine/modules/engine-modules.module.ts +++ b/packages/twenty-server/src/engine/modules/engine-modules.module.ts @@ -9,6 +9,7 @@ import { OpenApiModule } from 'src/engine/modules/open-api/open-api.module'; import { TimelineMessagingModule } from 'src/engine/modules/messaging/timeline-messaging.module'; import { BillingModule } from 'src/engine/modules/billing/billing.module'; import { HealthModule } from 'src/engine/modules/health/health.module'; +import { TimelineCalendarEventModule } from 'src/engine/modules/calendar/timeline-calendar-event.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { FileModule } from './file/file.module'; @@ -26,6 +27,7 @@ import { ClientConfigModule } from './client-config/client-config.module'; OpenApiModule, RefreshTokenModule, TimelineMessagingModule, + TimelineCalendarEventModule, UserModule, WorkspaceModule, ], @@ -34,6 +36,7 @@ import { ClientConfigModule } from './client-config/client-config.module'; AuthModule, FeatureFlagModule, TimelineMessagingModule, + TimelineCalendarEventModule, UserModule, WorkspaceModule, ], diff --git a/packages/twenty-server/src/engine/modules/messaging/dtos/timeline-thread.dto.ts b/packages/twenty-server/src/engine/modules/messaging/dtos/timeline-thread.dto.ts index 86d540bd8..811ea2b96 100644 --- a/packages/twenty-server/src/engine/modules/messaging/dtos/timeline-thread.dto.ts +++ b/packages/twenty-server/src/engine/modules/messaging/dtos/timeline-thread.dto.ts @@ -1,12 +1,10 @@ import { ObjectType, Field, ID } from '@nestjs/graphql'; -import { IDField } from '@ptc-org/nestjs-query-graphql'; - import { TimelineThreadParticipant } from 'src/engine/modules/messaging/dtos/timeline-thread-participant.dto'; @ObjectType('TimelineThread') export class TimelineThread { - @IDField(() => ID) + @Field(() => ID) id: string; @Field()