diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index d126a0f9e..0bc809390 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -443,6 +443,17 @@ export type Telemetry = { enabled: Scalars['Boolean']['output']; }; +export type TimelineThreadParticipant = { + __typename?: 'TimelineThreadParticipant'; + avatarUrl: Scalars['String']['output']; + displayName: Scalars['String']['output']; + firstName: Scalars['String']['output']; + handle: Scalars['String']['output']; + lastName: Scalars['String']['output']; + personId?: Maybe; + workspaceMemberId?: Maybe; +}; + export type UpdateFieldInput = { defaultValue?: InputMaybe; description?: InputMaybe; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 6d49e69e5..db114055c 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -402,12 +402,16 @@ export type QueryFindWorkspaceFromInviteHashArgs = { export type QueryGetTimelineThreadsFromCompanyIdArgs = { - companyId: Scalars['String']; + companyId: Scalars['ID']; + page: Scalars['Int']; + pageSize: Scalars['Int']; }; export type QueryGetTimelineThreadsFromPersonIdArgs = { - personId: Scalars['String']; + page: Scalars['Int']; + pageSize: Scalars['Int']; + personId: Scalars['ID']; }; @@ -491,15 +495,28 @@ export type Telemetry = { export type TimelineThread = { __typename?: 'TimelineThread'; - body: Scalars['String']; + firstParticipant: TimelineThreadParticipant; + id: Scalars['ID']; + lastMessageBody: Scalars['String']; + lastMessageReceivedAt: Scalars['DateTime']; + lastTwoParticipants: Array; numberOfMessagesInThread: Scalars['Float']; + participantCount: Scalars['Float']; read: Scalars['Boolean']; - receivedAt: Scalars['DateTime']; - senderName: Scalars['String']; - senderPictureUrl: Scalars['String']; subject: Scalars['String']; }; +export type TimelineThreadParticipant = { + __typename?: 'TimelineThreadParticipant'; + avatarUrl: Scalars['String']; + displayName: Scalars['String']; + firstName: Scalars['String']; + handle: Scalars['String']; + lastName: Scalars['String']; + personId?: Maybe; + workspaceMemberId?: Maybe; +}; + export type TransientToken = { __typename?: 'TransientToken'; transientToken: AuthToken; @@ -694,19 +711,27 @@ export type RelationEdge = { node: Relation; }; +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, 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 }> }; + export type GetTimelineThreadsFromCompanyIdQueryVariables = Exact<{ - companyId: Scalars['String']; + companyId: Scalars['ID']; + page: Scalars['Int']; + pageSize: Scalars['Int']; }>; -export type GetTimelineThreadsFromCompanyIdQuery = { __typename?: 'Query', getTimelineThreadsFromCompanyId: Array<{ __typename?: 'TimelineThread', body: string, numberOfMessagesInThread: number, read: boolean, receivedAt: string, senderName: string, senderPictureUrl: string, subject: string }> }; +export type GetTimelineThreadsFromCompanyIdQuery = { __typename?: 'Query', getTimelineThreadsFromCompanyId: Array<{ __typename?: 'TimelineThread', id: string, read: boolean, 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 }> }> }; export type GetTimelineThreadsFromPersonIdQueryVariables = Exact<{ - personId: Scalars['String']; + personId: Scalars['ID']; + page: Scalars['Int']; + pageSize: Scalars['Int']; }>; -export type GetTimelineThreadsFromPersonIdQuery = { __typename?: 'Query', getTimelineThreadsFromPersonId: Array<{ __typename?: 'TimelineThread', body: string, numberOfMessagesInThread: number, read: boolean, receivedAt: string, senderName: string, senderPictureUrl: string, subject: string }> }; +export type GetTimelineThreadsFromPersonIdQuery = { __typename?: 'Query', getTimelineThreadsFromPersonId: Array<{ __typename?: 'TimelineThread', id: string, read: boolean, 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 }> }> }; export type CreateEventMutationVariables = Exact<{ type: Scalars['String']; @@ -859,6 +884,34 @@ export type GetWorkspaceFromInviteHashQueryVariables = Exact<{ export type GetWorkspaceFromInviteHashQuery = { __typename?: 'Query', findWorkspaceFromInviteHash: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean } }; +export const ParticipantFragmentFragmentDoc = gql` + fragment ParticipantFragment on TimelineThreadParticipant { + personId + workspaceMemberId + firstName + lastName + displayName + avatarUrl + handle +} + `; +export const TimelineThreadFragmentFragmentDoc = gql` + fragment TimelineThreadFragment on TimelineThread { + id + read + firstParticipant { + ...ParticipantFragment + } + lastTwoParticipants { + ...ParticipantFragment + } + lastMessageReceivedAt + lastMessageBody + subject + numberOfMessagesInThread + participantCount +} + ${ParticipantFragmentFragmentDoc}`; export const AuthTokenFragmentFragmentDoc = gql` fragment AuthTokenFragment on AuthToken { token @@ -911,18 +964,16 @@ export const UserQueryFragmentFragmentDoc = gql` } `; export const GetTimelineThreadsFromCompanyIdDocument = gql` - query GetTimelineThreadsFromCompanyId($companyId: String!) { - getTimelineThreadsFromCompanyId(companyId: $companyId) { - body - numberOfMessagesInThread - read - receivedAt - senderName - senderPictureUrl - subject + query GetTimelineThreadsFromCompanyId($companyId: ID!, $page: Int!, $pageSize: Int!) { + getTimelineThreadsFromCompanyId( + companyId: $companyId + page: $page + pageSize: $pageSize + ) { + ...TimelineThreadFragment } } - `; + ${TimelineThreadFragmentFragmentDoc}`; /** * __useGetTimelineThreadsFromCompanyIdQuery__ @@ -937,6 +988,8 @@ export const GetTimelineThreadsFromCompanyIdDocument = gql` * const { data, loading, error } = useGetTimelineThreadsFromCompanyIdQuery({ * variables: { * companyId: // value for 'companyId' + * page: // value for 'page' + * pageSize: // value for 'pageSize' * }, * }); */ @@ -952,18 +1005,16 @@ export type GetTimelineThreadsFromCompanyIdQueryHookResult = ReturnType; export type GetTimelineThreadsFromCompanyIdQueryResult = Apollo.QueryResult; export const GetTimelineThreadsFromPersonIdDocument = gql` - query GetTimelineThreadsFromPersonId($personId: String!) { - getTimelineThreadsFromPersonId(personId: $personId) { - body - numberOfMessagesInThread - read - receivedAt - senderName - senderPictureUrl - subject + query GetTimelineThreadsFromPersonId($personId: ID!, $page: Int!, $pageSize: Int!) { + getTimelineThreadsFromPersonId( + personId: $personId + page: $page + pageSize: $pageSize + ) { + ...TimelineThreadFragment } } - `; + ${TimelineThreadFragmentFragmentDoc}`; /** * __useGetTimelineThreadsFromPersonIdQuery__ @@ -978,6 +1029,8 @@ export const GetTimelineThreadsFromPersonIdDocument = gql` * const { data, loading, error } = useGetTimelineThreadsFromPersonIdQuery({ * variables: { * personId: // value for 'personId' + * page: // value for 'page' + * pageSize: // value for 'pageSize' * }, * }); */ diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx index cd87008bd..a9549c123 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx @@ -92,20 +92,22 @@ export const EmailThreadPreview = ({ onClick()} divider={divider}> - {thread.senderName} + + {thread.firstParticipant.displayName} + {thread.numberOfMessagesInThread} {thread.subject} - {thread.body} + {thread.lastMessageBody} - {formatToHumanReadableDate(thread.receivedAt)} + {formatToHumanReadableDate(thread.lastMessageReceivedAt)} ); diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx index d36f176ed..0b4bedfc2 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx @@ -2,11 +2,8 @@ import { useQuery } from '@apollo/client'; import styled from '@emotion/styled'; import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview'; +import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/messaging.constants'; import { useEmailThread } from '@/activities/emails/hooks/useEmailThread'; -import { - mockedEmailThreads, - MockedThread, -} from '@/activities/emails/mocks/mockedEmailThreads'; import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId'; import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; @@ -17,6 +14,7 @@ import { } from '@/ui/display/typography/components/H1Title'; import { Card } from '@/ui/layout/card/components/Card'; import { Section } from '@/ui/layout/section/components/Section'; +import { TimelineThread } from '~/generated/graphql'; const StyledContainer = styled.div` display: flex; @@ -46,10 +44,13 @@ export const EmailThreads = ({ ? getTimelineThreadsFromPersonId : getTimelineThreadsFromCompanyId; - const threadQueryVariables = - entity.targetObjectNameSingular === CoreObjectNameSingular.Person + const threadQueryVariables = { + ...(entity.targetObjectNameSingular === CoreObjectNameSingular.Person ? { personId: entity.id } - : { companyId: entity.id }; + : { companyId: entity.id }), + page: 1, + pageSize: TIMELINE_THREADS_DEFAULT_PAGE_SIZE, + }; const threads = useQuery(threadQuery, { variables: threadQueryVariables, @@ -59,16 +60,12 @@ export const EmailThreads = ({ return; } - // To use once the id is returned by the query - - // const fetchedTimelineThreads: TimelineThread[] = - // threads.data[ - // entity.targetObjectNameSingular === CoreObjectNameSingular.Person - // ? 'getTimelineThreadsFromPersonId' - // : 'getTimelineThreadsFromCompanyId' - // ]; - - const timelineThreads = mockedEmailThreads; + const timelineThreads: TimelineThread[] = + threads.data[ + entity.targetObjectNameSingular === CoreObjectNameSingular.Person + ? 'getTimelineThreadsFromPersonId' + : 'getTimelineThreadsFromCompanyId' + ]; return ( @@ -77,16 +74,14 @@ export const EmailThreads = ({ title={ <> Inbox{' '} - - {timelineThreads && timelineThreads.length} - + {timelineThreads?.length} } fontColor={H1TitleFontColor.Primary} /> {timelineThreads && - timelineThreads.map((thread: MockedThread, index: number) => ( + timelineThreads.map((thread: TimelineThread, index: number) => ( { const [, setViewableEmailThread] = useRecoilState(viewableEmailThreadState); const openEmailThredRightDrawer = useOpenEmailThreadRightDrawer(); - const openEmailThread = (thread: MockedThread) => { + const openEmailThread = (thread: TimelineThread) => { openEmailThredRightDrawer(); setViewableEmailThread(thread); diff --git a/packages/twenty-front/src/modules/activities/emails/mocks/mockedEmailThreads.ts b/packages/twenty-front/src/modules/activities/emails/mocks/mockedEmailThreads.ts deleted file mode 100644 index 85a8e6716..000000000 --- a/packages/twenty-front/src/modules/activities/emails/mocks/mockedEmailThreads.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Scalars, TimelineThread } from '~/generated/graphql'; - -export type MockedThread = { - id: string; -} & TimelineThread; - -export const mockedEmailThreads: MockedThread[] = [ - { - __typename: 'TimelineThread', - id: 'ec7e12b9-4063-410f-ae9a-30e32452b9c0', - body: 'This is a test email' as Scalars['String'], - numberOfMessagesInThread: 5 as Scalars['Float'], - read: true as Scalars['Boolean'], - receivedAt: new Date().toISOString() as Scalars['DateTime'], - senderName: 'Thom Trp' as Scalars['String'], - senderPictureUrl: '' as Scalars['String'], - subject: 'Test email' as Scalars['String'], - }, - { - __typename: 'TimelineThread', - id: 'ec7e12b9-4063-410f-ae9a-30e32452b9c0', - body: 'This is a second test email' as Scalars['String'], - numberOfMessagesInThread: 5 as Scalars['Float'], - read: true as Scalars['Boolean'], - receivedAt: new Date().toISOString() as Scalars['DateTime'], - senderName: 'Coco Den' as Scalars['String'], - senderPictureUrl: '' as Scalars['String'], - subject: 'Test email number 2' as Scalars['String'], - }, -]; diff --git a/packages/twenty-front/src/modules/activities/emails/queries/fragments/participantFragment.ts b/packages/twenty-front/src/modules/activities/emails/queries/fragments/participantFragment.ts new file mode 100644 index 000000000..fa3341431 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/queries/fragments/participantFragment.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const participantFragment = gql` + fragment ParticipantFragment on TimelineThreadParticipant { + personId + workspaceMemberId + firstName + lastName + displayName + avatarUrl + handle + } +`; diff --git a/packages/twenty-front/src/modules/activities/emails/queries/fragments/timelineThreadFragment.ts b/packages/twenty-front/src/modules/activities/emails/queries/fragments/timelineThreadFragment.ts new file mode 100644 index 000000000..c58458658 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/queries/fragments/timelineThreadFragment.ts @@ -0,0 +1,22 @@ +import { gql } from '@apollo/client'; + +import { participantFragment } from '@/activities/emails/queries/fragments/participantFragment'; + +export const timelineThreadFragment = gql` + fragment TimelineThreadFragment on TimelineThread { + id + read + firstParticipant { + ...ParticipantFragment + } + lastTwoParticipants { + ...ParticipantFragment + } + lastMessageReceivedAt + lastMessageBody + subject + numberOfMessagesInThread + participantCount + } + ${participantFragment} +`; diff --git a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts index d8bb4a931..1001f623b 100644 --- a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts +++ b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts @@ -1,15 +1,20 @@ import { gql } from '@apollo/client'; +import { timelineThreadFragment } from '@/activities/emails/queries/fragments/timelineThreadFragment'; + export const getTimelineThreadsFromCompanyId = gql` - query GetTimelineThreadsFromCompanyId($companyId: String!) { - getTimelineThreadsFromCompanyId(companyId: $companyId) { - body - numberOfMessagesInThread - read - receivedAt - senderName - senderPictureUrl - subject + query GetTimelineThreadsFromCompanyId( + $companyId: ID! + $page: Int! + $pageSize: Int! + ) { + getTimelineThreadsFromCompanyId( + companyId: $companyId + page: $page + pageSize: $pageSize + ) { + ...TimelineThreadFragment } } + ${timelineThreadFragment} `; diff --git a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts index b1257eb9f..a5e73991e 100644 --- a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts +++ b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts @@ -1,15 +1,20 @@ import { gql } from '@apollo/client'; +import { timelineThreadFragment } from '@/activities/emails/queries/fragments/timelineThreadFragment'; + export const getTimelineThreadsFromPersonId = gql` - query GetTimelineThreadsFromPersonId($personId: String!) { - getTimelineThreadsFromPersonId(personId: $personId) { - body - numberOfMessagesInThread - read - receivedAt - senderName - senderPictureUrl - subject + query GetTimelineThreadsFromPersonId( + $personId: ID! + $page: Int! + $pageSize: Int! + ) { + getTimelineThreadsFromPersonId( + personId: $personId + page: $page + pageSize: $pageSize + ) { + ...TimelineThreadFragment } } + ${timelineThreadFragment} `; diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx index cee56f7d4..2609f77e8 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx @@ -50,7 +50,7 @@ export const RightDrawerEmailThread = () => { {messages.map((message) => ( ({ +export const viewableEmailThreadState = atom({ key: 'viewableEmailThreadState', default: null, }); diff --git a/packages/twenty-front/src/testing/mock-data/activities.ts b/packages/twenty-front/src/testing/mock-data/activities.ts index 8b3621d8d..f929922df 100644 --- a/packages/twenty-front/src/testing/mock-data/activities.ts +++ b/packages/twenty-front/src/testing/mock-data/activities.ts @@ -4,7 +4,6 @@ import { Comment } from '@/activities/types/Comment'; import { Company } from '@/companies/types/Company'; import { Person } from '@/people/types/Person'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; -import { TimelineThread } from '~/generated/graphql'; type MockedActivity = Pick< Activity, @@ -211,24 +210,3 @@ export const mockedActivities: Array = [ __typename: 'Activity', }, ]; - -export const mockedEmailThreads: TimelineThread[] = [ - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras dignissim nisi eu tellus dapibus, egestas placerat risus placerat. Praesent eget arcu consectetur, efficitur felis.', - numberOfMessagesInThread: 4, - read: false, - receivedAt: new Date('11/04/2023').toISOString(), - senderName: 'Steve Anahi', - senderPictureUrl: '', - subject: 'Partnerships', - }, - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras dignissim nisi eu tellus dapibus, egestas placerat risus placerat. Praesent eget arcu consectetur, efficitur felis.', - numberOfMessagesInThread: 3, - read: true, - receivedAt: new Date('11/04/2023').toISOString(), - senderName: 'Alexandre Prot', - senderPictureUrl: '', - subject: 'Next step', - }, -]; diff --git a/packages/twenty-server/src/core/messaging/constants/messaging.constants.ts b/packages/twenty-server/src/core/messaging/constants/messaging.constants.ts new file mode 100644 index 000000000..4a7563923 --- /dev/null +++ b/packages/twenty-server/src/core/messaging/constants/messaging.constants.ts @@ -0,0 +1,2 @@ +export const TIMELINE_THREADS_DEFAULT_PAGE_SIZE = 20; +export const TIMELINE_THREADS_MAX_PAGE_SIZE = 50; diff --git a/packages/twenty-server/src/core/messaging/dtos/timeline-thread-participant.dto.ts b/packages/twenty-server/src/core/messaging/dtos/timeline-thread-participant.dto.ts new file mode 100644 index 000000000..7d675d968 --- /dev/null +++ b/packages/twenty-server/src/core/messaging/dtos/timeline-thread-participant.dto.ts @@ -0,0 +1,25 @@ +import { ObjectType, Field, ID } from '@nestjs/graphql'; + +@ObjectType('TimelineThreadParticipant') +export class TimelineThreadParticipant { + @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/core/messaging/dtos/timeline-thread.dto.ts b/packages/twenty-server/src/core/messaging/dtos/timeline-thread.dto.ts new file mode 100644 index 000000000..6590e85ac --- /dev/null +++ b/packages/twenty-server/src/core/messaging/dtos/timeline-thread.dto.ts @@ -0,0 +1,35 @@ +import { ObjectType, Field, ID } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +import { TimelineThreadParticipant } from 'src/core/messaging/dtos/timeline-thread-participant.dto'; + +@ObjectType('TimelineThread') +export class TimelineThread { + @IDField(() => ID) + id: string; + + @Field() + read: boolean; + + @Field() + firstParticipant: TimelineThreadParticipant; + + @Field(() => [TimelineThreadParticipant]) + lastTwoParticipants: TimelineThreadParticipant[]; + + @Field() + lastMessageReceivedAt: Date; + + @Field() + lastMessageBody: string; + + @Field() + subject: string; + + @Field() + numberOfMessagesInThread: number; + + @Field() + participantCount: number; +} diff --git a/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts b/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts index f98df9e13..d77b043d6 100644 --- a/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts +++ b/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts @@ -1,43 +1,47 @@ -import { Args, Query, Field, Resolver, ObjectType } from '@nestjs/graphql'; +import { + Args, + Query, + Resolver, + Int, + ArgsType, + Field, + ID, +} from '@nestjs/graphql'; import { UseGuards } from '@nestjs/common'; -import { Column, Entity } from 'typeorm'; +import { Max } from 'class-validator'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { Workspace } from 'src/core/workspace/workspace.entity'; import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; import { TimelineMessagingService } from 'src/core/messaging/timeline-messaging.service'; +import { TimelineThread } from 'src/core/messaging/dtos/timeline-thread.dto'; +import { TIMELINE_THREADS_MAX_PAGE_SIZE } from 'src/core/messaging/constants/messaging.constants'; -@Entity({ name: 'timelineThread', schema: 'core' }) -@ObjectType('TimelineThread') -export class TimelineThread { - @Field() - @Column() - read: boolean; +@ArgsType() +class GetTimelineThreadsFromPersonIdArgs { + @Field(() => ID) + personId: string; - @Field() - @Column() - senderName: string; + @Field(() => Int) + page: number; - @Field() - @Column() - senderPictureUrl: string; + @Field(() => Int) + @Max(TIMELINE_THREADS_MAX_PAGE_SIZE) + pageSize: number; +} - @Field() - @Column() - numberOfMessagesInThread: number; +@ArgsType() +class GetTimelineThreadsFromCompanyIdArgs { + @Field(() => ID) + companyId: string; - @Field() - @Column() - subject: string; + @Field(() => Int) + page: number; - @Field() - @Column() - body: string; - - @Field() - @Column() - receivedAt: Date; + @Field(() => Int) + @Max(TIMELINE_THREADS_MAX_PAGE_SIZE) + pageSize: number; } @UseGuards(JwtAuthGuard) @@ -50,12 +54,14 @@ export class TimelineMessagingResolver { @Query(() => [TimelineThread]) async getTimelineThreadsFromPersonId( @AuthWorkspace() { id: workspaceId }: Workspace, - @Args('personId') personId: string, + @Args() { personId, page, pageSize }: GetTimelineThreadsFromPersonIdArgs, ) { const timelineThreads = await this.timelineMessagingService.getMessagesFromPersonIds( workspaceId, [personId], + page, + pageSize, ); return timelineThreads; @@ -64,12 +70,14 @@ export class TimelineMessagingResolver { @Query(() => [TimelineThread]) async getTimelineThreadsFromCompanyId( @AuthWorkspace() { id: workspaceId }: Workspace, - @Args('companyId') companyId: string, + @Args() { companyId, page, pageSize }: GetTimelineThreadsFromCompanyIdArgs, ) { const timelineThreads = await this.timelineMessagingService.getMessagesFromCompanyId( workspaceId, companyId, + page, + pageSize, ); return timelineThreads; diff --git a/packages/twenty-server/src/core/messaging/timeline-messaging.service.ts b/packages/twenty-server/src/core/messaging/timeline-messaging.service.ts index ca932a922..6b64fdad0 100644 --- a/packages/twenty-server/src/core/messaging/timeline-messaging.service.ts +++ b/packages/twenty-server/src/core/messaging/timeline-messaging.service.ts @@ -1,9 +1,20 @@ import { Injectable } from '@nestjs/common'; -import { TimelineThread } from 'src/core/messaging/timeline-messaging.resolver'; +import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from 'src/core/messaging/constants/messaging.constants'; +import { TimelineThread } from 'src/core/messaging/dtos/timeline-thread.dto'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { DataSourceService } from 'src/metadata/data-source/data-source.service'; +type TimelineThreadParticipant = { + personId: string; + workspaceMemberId: string; + firstName: string; + lastName: string; + displayName: string; + avatarUrl: string; + handle: string; +}; + @Injectable() export class TimelineMessagingService { constructor( @@ -14,7 +25,11 @@ export class TimelineMessagingService { async getMessagesFromPersonIds( workspaceId: string, personIds: string[], + page: number = 1, + pageSize: number = TIMELINE_THREADS_DEFAULT_PAGE_SIZE, ): Promise { + const offset = (page - 1) * TIMELINE_THREADS_DEFAULT_PAGE_SIZE; + const dataSourceMetadata = await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( workspaceId, @@ -23,61 +38,327 @@ export class TimelineMessagingService { const workspaceDataSource = await this.typeORMService.connectToDataSource(dataSourceMetadata); - // 10 first threads This hard limit is just for the POC, we will implement pagination later - const messageThreads = await workspaceDataSource?.query( + const messageThreads: + | { + id: string; + lastMessageReceivedAt: Date; + lastMessageId: string; + lastMessageBody: string; + rowNumber: number; + }[] + | undefined = await workspaceDataSource?.query( ` - SELECT - subquery.*, - message_count, - last_message_subject, - last_message_text, - last_message_received_at, - last_message_participant_handle, - last_message_participant_displayName - FROM ( - SELECT - mt.*, - COUNT(m."id") OVER (PARTITION BY mt."id") AS message_count, - FIRST_VALUE(m."subject") OVER (PARTITION BY mt."id" ORDER BY m."receivedAt" DESC) AS last_message_subject, - FIRST_VALUE(m."text") OVER (PARTITION BY mt."id" ORDER BY m."receivedAt" DESC) AS last_message_text, - FIRST_VALUE(m."receivedAt") OVER (PARTITION BY mt."id" ORDER BY m."receivedAt" DESC) AS last_message_received_at, - FIRST_VALUE(mr."handle") OVER (PARTITION BY mt."id" ORDER BY m."receivedAt" DESC) AS last_message_participant_handle, - FIRST_VALUE(mr."displayName") OVER (PARTITION BY mt."id" ORDER BY m."receivedAt" DESC) AS last_message_participant_displayName, - ROW_NUMBER() OVER (PARTITION BY mt."id" ORDER BY m."receivedAt" DESC) AS rn - FROM - ${dataSourceMetadata.schema}."messageThread" mt - LEFT JOIN - ${dataSourceMetadata.schema}."message" m ON mt."id" = m."messageThreadId" - LEFT JOIN - ${dataSourceMetadata.schema}."messageParticipant" mr ON m."id" = mr."messageId" - WHERE - mr."personId" IN (SELECT unnest($1::uuid[])) - ) AS subquery - WHERE - subquery.rn = 1 - ORDER BY - subquery.last_message_received_at DESC - LIMIT 10; -`, - [personIds], + SELECT * + FROM + (SELECT "messageThread".id, + MAX(message."receivedAt") AS "lastMessageReceivedAt", + message.id AS "lastMessageId", + message.text AS "lastMessageBody", + ROW_NUMBER() OVER (PARTITION BY "messageThread".id ORDER BY MAX(message."receivedAt") DESC) AS "rowNumber" + FROM + ${dataSourceMetadata.schema}."message" message + LEFT JOIN + ${dataSourceMetadata.schema}."messageThread" "messageThread" ON "messageThread".id = message."messageThreadId" + LEFT JOIN + ${dataSourceMetadata.schema}."messageParticipant" "messageParticipant" ON "messageParticipant"."messageId" = message.id + LEFT JOIN + ${dataSourceMetadata.schema}."person" person ON person.id = "messageParticipant"."personId" + LEFT JOIN + ${dataSourceMetadata.schema}."workspaceMember" "workspaceMember" ON "workspaceMember".id = "messageParticipant"."workspaceMemberId" + WHERE + person.id = ANY($1) + GROUP BY + "messageThread".id, + message.id + ORDER BY + message."receivedAt" DESC + ) AS "messageThreads" + WHERE + "rowNumber" = 1 + LIMIT $2 + OFFSET $3 + `, + [personIds, pageSize, offset], ); - const formattedMessageThreads = messageThreads.map((messageThread) => { + if (!messageThreads) { + return []; + } + + const messageThreadIds = messageThreads.map( + (messageThread) => messageThread.id, + ); + + const threadSubjects: + | { + id: string; + subject: string; + }[] + | undefined = await workspaceDataSource?.query( + ` + SELECT * + FROM + (SELECT + "messageThread".id, + message.subject, + ROW_NUMBER() OVER (PARTITION BY "messageThread".id ORDER BY MAX(message."receivedAt") ASC) AS "rowNumber" + FROM + ${dataSourceMetadata.schema}."message" message + LEFT JOIN + ${dataSourceMetadata.schema}."messageThread" "messageThread" ON "messageThread".id = message."messageThreadId" + WHERE + "messageThread".id = ANY($1) + GROUP BY + "messageThread".id, + message.id + ORDER BY + message."receivedAt" DESC + ) AS "messageThreads" + WHERE + "rowNumber" = 1 + `, + [messageThreadIds], + ); + + const numberOfMessagesInThread: + | { + id: string; + numberOfMessagesInThread: number; + }[] + | undefined = await workspaceDataSource?.query( + ` + SELECT + "messageThread".id, + COUNT(message.id) AS "numberOfMessagesInThread" + FROM + ${dataSourceMetadata.schema}."message" message + LEFT JOIN + ${dataSourceMetadata.schema}."messageThread" "messageThread" ON "messageThread".id = message."messageThreadId" + WHERE + "messageThread".id = ANY($1) + GROUP BY + "messageThread".id + `, + [messageThreadIds], + ); + + const messageThreadsByMessageThreadId: { + [key: string]: { + id: string; + lastMessageReceivedAt: Date; + lastMessageBody: string; + }; + } = messageThreads.reduce((messageThreadAcc, messageThread) => { + messageThreadAcc[messageThread.id] = messageThread; + + return messageThreadAcc; + }, {}); + + const subjectsByMessageThreadId: + | { + [key: string]: { + id: string; + subject: string; + }; + } + | undefined = threadSubjects?.reduce( + (threadSubjectAcc, threadSubject) => { + threadSubjectAcc[threadSubject.id] = threadSubject; + + return threadSubjectAcc; + }, + {}, + ); + + const numberOfMessagesByMessageThreadId: + | { + [key: string]: { + id: string; + numberOfMessagesInThread: number; + }; + } + | undefined = numberOfMessagesInThread?.reduce( + (numberOfMessagesAcc, numberOfMessages) => { + numberOfMessagesAcc[numberOfMessages.id] = numberOfMessages; + + return numberOfMessagesAcc; + }, + {}, + ); + + const threadMessagesFromActiveParticipants: + | { + id: string; + messageId: string; + receivedAt: Date; + body: string; + subject: string; + personId: string; + workspaceMemberId: string; + handle: string; + personFirstName: string; + personLastName: string; + personAvatarUrl: string; + workspaceMemberFirstName: string; + workspaceMemberLastName: string; + workspaceMemberAvatarUrl: string; + messageDisplayName: string; + }[] + | undefined = await workspaceDataSource?.query( + ` + SELECT DISTINCT "messageThread".id, + message.id AS "messageId", + message."receivedAt", + message.text, + message."subject", + "messageParticipant"."personId", + "messageParticipant"."workspaceMemberId", + "messageParticipant".handle, + "person"."nameFirstName" as "personFirstName", + "person"."nameLastName" as "personLastName", + "person"."avatarUrl" as "personAvatarUrl", + "workspaceMember"."nameFirstName" as "workspaceMemberFirstName", + "workspaceMember"."nameLastName" as "workspaceMemberLastName", + "workspaceMember"."avatarUrl" as "workspaceMemberAvatarUrl", + "messageParticipant"."displayName" as "messageDisplayName" + FROM + ${dataSourceMetadata.schema}."message" message + LEFT JOIN + ${dataSourceMetadata.schema}."messageThread" "messageThread" ON "messageThread".id = message."messageThreadId" + LEFT JOIN + (SELECT * FROM ${dataSourceMetadata.schema}."messageParticipant" WHERE "messageParticipant".role = 'from') "messageParticipant" ON "messageParticipant"."messageId" = message.id + LEFT JOIN + ${dataSourceMetadata.schema}."person" person ON person."id" = "messageParticipant"."personId" + LEFT JOIN + ${dataSourceMetadata.schema}."workspaceMember" "workspaceMember" ON "workspaceMember".id = "messageParticipant"."workspaceMemberId" + WHERE + "messageThread".id = ANY($1) + ORDER BY + message."receivedAt" DESC + `, + [messageThreadIds], + ); + + const threadParticipantsByThreadId: { + [key: string]: TimelineThreadParticipant[]; + } = messageThreadIds.reduce((messageThreadIdAcc, messageThreadId) => { + const threadMessages = threadMessagesFromActiveParticipants?.filter( + (threadMessage) => threadMessage.id === messageThreadId, + ); + + const threadParticipants = threadMessages?.reduce( + ( + threadMessageAcc, + threadMessage, + ): { + [key: string]: TimelineThreadParticipant; + } => { + const threadParticipant = threadMessageAcc[threadMessage.handle]; + + const firstName = + threadMessage.personFirstName || + threadMessage.workspaceMemberFirstName || + ''; + + const lastName = + threadMessage.personLastName || + threadMessage.workspaceMemberLastName || + ''; + + const displayName = + firstName || + threadMessage.messageDisplayName || + threadMessage.handle; + + if (!threadParticipant) { + threadMessageAcc[threadMessage.handle] = { + personId: threadMessage.personId, + workspaceMemberId: threadMessage.workspaceMemberId, + firstName, + lastName, + displayName, + avatarUrl: + threadMessage.personAvatarUrl ?? + threadMessage.workspaceMemberAvatarUrl ?? + '', + handle: threadMessage.handle, + }; + } + + return threadMessageAcc; + }, + {}, + ); + + messageThreadIdAcc[messageThreadId] = threadParticipants + ? Object.values(threadParticipants) + : []; + + return messageThreadIdAcc; + }, {}); + + const timelineThreads = messageThreadIds.map((messageThreadId) => { + const threadParticipants = threadParticipantsByThreadId[messageThreadId]; + + const firstParticipant = threadParticipants[0]; + + const threadParticipantsWithoutFirstParticipant = + threadParticipants.filter( + (threadParticipant) => + threadParticipant.handle !== firstParticipant.handle, + ); + + const lastTwoParticipants: TimelineThreadParticipant[] = []; + + const lastParticipant = + threadParticipantsWithoutFirstParticipant.slice(-1)[0]; + + if (lastParticipant) { + lastTwoParticipants.push(lastParticipant); + + const threadParticipantsWithoutFirstAndLastParticipants = + threadParticipantsWithoutFirstParticipant.filter( + (threadParticipant) => + threadParticipant.handle !== lastParticipant.handle, + ); + + if (threadParticipantsWithoutFirstAndLastParticipants.length > 0) + lastTwoParticipants.push( + threadParticipantsWithoutFirstAndLastParticipants.slice(-1)[0], + ); + } + + const thread = messageThreadsByMessageThreadId[messageThreadId]; + + const threadSubject = + subjectsByMessageThreadId?.[messageThreadId].subject ?? ''; + + const numberOfMessages = + numberOfMessagesByMessageThreadId?.[messageThreadId] + .numberOfMessagesInThread ?? 1; + return { + id: messageThreadId, read: true, - senderName: messageThread.last_message_participant_handle, - senderPictureUrl: '', - numberOfMessagesInThread: messageThread.message_count, - subject: messageThread.last_message_subject, - body: messageThread.last_message_text, - receivedAt: messageThread.last_message_received_at, + firstParticipant, + lastTwoParticipants, + lastMessageReceivedAt: thread.lastMessageReceivedAt, + lastMessageBody: thread.lastMessageBody, + subject: threadSubject, + numberOfMessagesInThread: numberOfMessages, + participantCount: threadParticipants.length, }; }); - return formattedMessageThreads; + return timelineThreads; } - async getMessagesFromCompanyId(workspaceId: string, companyId: string) { + async getMessagesFromCompanyId( + workspaceId: string, + companyId: string, + page: number = 1, + pageSize: number = TIMELINE_THREADS_DEFAULT_PAGE_SIZE, + ) { const dataSourceMetadata = await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( workspaceId, @@ -102,11 +383,15 @@ export class TimelineMessagingService { return []; } - const formattedPersonIds = personIds.map((personId) => personId.id); + const formattedPersonIds = personIds.map( + (personId: { id: string }) => personId.id, + ); const messageThreads = await this.getMessagesFromPersonIds( workspaceId, formattedPersonIds, + page, + pageSize, ); return messageThreads;