From 1b7580476d5b4455daf324f39b0340783d6c84a7 Mon Sep 17 00:00:00 2001 From: bosiraphael <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 21 Dec 2023 18:21:07 +0100 Subject: [PATCH] 2929 fetch emails from backend and display them in the UI (#3092) * sending mock data from the resolver * add sql raw query to the resolver * improve query * fix email component css * fix query * css adjustments * create hard limit for mail display * fix display name ellipsis * add service * fetching email on company page is working * graphql generate * move queries into separate files * add types * renaming * add early return * modified according to comments * graphql data generate * fix bug after renaming * fix issue with mock data --- .../twenty-front/src/generated/graphql.tsx | 119 ++++++++++++++++++ .../activities/emails/components/Emails.tsx | 47 ------- .../{EmailPreview.tsx => ThreadPreview.tsx} | 48 ++++--- .../activities/emails/components/Threads.tsx | 82 ++++++++++++ .../components/__stories__/Emails.stories.tsx | 13 -- .../__stories__/Threads.stories.tsx | 13 ++ .../getTimelineThreadsFromCompanyId.ts | 15 +++ .../queries/getTimelineThreadsFromPersonId.ts | 15 +++ .../modules/activities/emails/types/email.ts | 9 -- .../components/ShowPageRightContainer.tsx | 4 +- .../src/testing/mock-data/activities.ts | 12 +- .../twenty-server/src/core/core.module.ts | 3 + .../messaging/timeline-messaging.module.ts | 13 ++ .../messaging/timeline-messaging.resolver.ts | 77 ++++++++++++ .../messaging/timeline-messaging.service.ts | 112 +++++++++++++++++ 15 files changed, 489 insertions(+), 93 deletions(-) delete mode 100644 packages/twenty-front/src/modules/activities/emails/components/Emails.tsx rename packages/twenty-front/src/modules/activities/emails/components/{EmailPreview.tsx => ThreadPreview.tsx} (64%) create mode 100644 packages/twenty-front/src/modules/activities/emails/components/Threads.tsx delete mode 100644 packages/twenty-front/src/modules/activities/emails/components/__stories__/Emails.stories.tsx create mode 100644 packages/twenty-front/src/modules/activities/emails/components/__stories__/Threads.stories.tsx create mode 100644 packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts create mode 100644 packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts delete mode 100644 packages/twenty-front/src/modules/activities/emails/types/email.ts create mode 100644 packages/twenty-server/src/core/messaging/timeline-messaging.module.ts create mode 100644 packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts create mode 100644 packages/twenty-server/src/core/messaging/timeline-messaging.service.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 33cd54fe0..2c35466a7 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -350,6 +350,8 @@ export type Query = { currentUser: User; currentWorkspace: Workspace; findWorkspaceFromInviteHash: Workspace; + getTimelineThreadsFromCompanyId: Array; + getTimelineThreadsFromPersonId: Array; object: Object; objects: ObjectConnection; }; @@ -369,6 +371,16 @@ export type QueryFindWorkspaceFromInviteHashArgs = { inviteHash: Scalars['String']; }; + +export type QueryGetTimelineThreadsFromCompanyIdArgs = { + companyId: Scalars['String']; +}; + + +export type QueryGetTimelineThreadsFromPersonIdArgs = { + personId: Scalars['String']; +}; + export type RefreshToken = { __typename?: 'RefreshToken'; createdAt: Scalars['DateTime']; @@ -438,6 +450,17 @@ export type Telemetry = { enabled: Scalars['Boolean']; }; +export type TimelineThread = { + __typename?: 'TimelineThread'; + body: Scalars['String']; + numberOfMessagesInThread: Scalars['Float']; + read: Scalars['Boolean']; + receivedAt: Scalars['DateTime']; + senderName: Scalars['String']; + senderPictureUrl: Scalars['String']; + subject: Scalars['String']; +}; + export type TransientToken = { __typename?: 'TransientToken'; transientToken: AuthToken; @@ -630,6 +653,20 @@ export type RelationEdge = { node: Relation; }; +export type GetTimelineThreadsFromCompanyIdQueryVariables = Exact<{ + companyId: Scalars['String']; +}>; + + +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 GetTimelineThreadsFromPersonIdQueryVariables = Exact<{ + personId: Scalars['String']; +}>; + + +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 CreateEventMutationVariables = Exact<{ type: Scalars['String']; data: Scalars['JSON']; @@ -817,6 +854,88 @@ export const UserQueryFragmentFragmentDoc = gql` } } `; +export const GetTimelineThreadsFromCompanyIdDocument = gql` + query GetTimelineThreadsFromCompanyId($companyId: String!) { + getTimelineThreadsFromCompanyId(companyId: $companyId) { + body + numberOfMessagesInThread + read + receivedAt + senderName + senderPictureUrl + subject + } +} + `; + +/** + * __useGetTimelineThreadsFromCompanyIdQuery__ + * + * To run a query within a React component, call `useGetTimelineThreadsFromCompanyIdQuery` and pass it any options that fit your needs. + * When your component renders, `useGetTimelineThreadsFromCompanyIdQuery` 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 } = useGetTimelineThreadsFromCompanyIdQuery({ + * variables: { + * companyId: // value for 'companyId' + * }, + * }); + */ +export function useGetTimelineThreadsFromCompanyIdQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetTimelineThreadsFromCompanyIdDocument, options); + } +export function useGetTimelineThreadsFromCompanyIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetTimelineThreadsFromCompanyIdDocument, options); + } +export type GetTimelineThreadsFromCompanyIdQueryHookResult = ReturnType; +export type GetTimelineThreadsFromCompanyIdLazyQueryHookResult = ReturnType; +export type GetTimelineThreadsFromCompanyIdQueryResult = Apollo.QueryResult; +export const GetTimelineThreadsFromPersonIdDocument = gql` + query GetTimelineThreadsFromPersonId($personId: String!) { + getTimelineThreadsFromPersonId(personId: $personId) { + body + numberOfMessagesInThread + read + receivedAt + senderName + senderPictureUrl + subject + } +} + `; + +/** + * __useGetTimelineThreadsFromPersonIdQuery__ + * + * To run a query within a React component, call `useGetTimelineThreadsFromPersonIdQuery` and pass it any options that fit your needs. + * When your component renders, `useGetTimelineThreadsFromPersonIdQuery` 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 } = useGetTimelineThreadsFromPersonIdQuery({ + * variables: { + * personId: // value for 'personId' + * }, + * }); + */ +export function useGetTimelineThreadsFromPersonIdQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetTimelineThreadsFromPersonIdDocument, options); + } +export function useGetTimelineThreadsFromPersonIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetTimelineThreadsFromPersonIdDocument, options); + } +export type GetTimelineThreadsFromPersonIdQueryHookResult = ReturnType; +export type GetTimelineThreadsFromPersonIdLazyQueryHookResult = ReturnType; +export type GetTimelineThreadsFromPersonIdQueryResult = Apollo.QueryResult; export const CreateEventDocument = gql` mutation CreateEvent($type: String!, $data: JSON!) { createEvent(type: $type, data: $data) { diff --git a/packages/twenty-front/src/modules/activities/emails/components/Emails.tsx b/packages/twenty-front/src/modules/activities/emails/components/Emails.tsx deleted file mode 100644 index 368e3faa6..000000000 --- a/packages/twenty-front/src/modules/activities/emails/components/Emails.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import styled from '@emotion/styled'; - -import { - H1Title, - H1TitleFontColor, -} from '@/ui/display/typography/components/H1Title'; -import { Card } from '@/ui/layout/card/components/Card'; -import { Section } from '@/ui/layout/section/components/Section'; -import { mockedEmails as emails } from '~/testing/mock-data/activities'; - -import { EmailPreview } from './EmailPreview'; - -const StyledContainer = styled.div` - display: flex; - flex-direction: column; - gap: ${({ theme }) => theme.spacing(6)}; - padding: ${({ theme }) => theme.spacing(6, 6, 2)}; -`; - -const StyledH1Title = styled(H1Title)` - display: flex; - gap: ${({ theme }) => theme.spacing(2)}; -`; - -const StyledEmailCount = styled.span` - color: ${({ theme }) => theme.font.color.light}; -`; - -export const Emails = () => ( - -
- - Inbox {emails.length} - - } - fontColor={H1TitleFontColor.Primary} - /> - - {emails.map((email, index) => ( - - ))} - -
-
-); diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailPreview.tsx b/packages/twenty-front/src/modules/activities/emails/components/ThreadPreview.tsx similarity index 64% rename from packages/twenty-front/src/modules/activities/emails/components/EmailPreview.tsx rename to packages/twenty-front/src/modules/activities/emails/components/ThreadPreview.tsx index d595fdf18..c82396c60 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailPreview.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/ThreadPreview.tsx @@ -2,10 +2,9 @@ import styled from '@emotion/styled'; import { CardContent } from '@/ui/layout/card/components/CardContent'; import { Avatar } from '@/users/components/Avatar'; +import { TimelineThread } from '~/generated/graphql'; import { formatToHumanReadableDate } from '~/utils'; -import { Email } from '../types/email'; - const StyledCardContent = styled(CardContent)` align-items: center; display: flex; @@ -22,6 +21,7 @@ const StyledHeading = styled.div<{ unread: boolean }>` font-weight: ${({ theme, unread }) => unread ? theme.font.weight.medium : theme.font.weight.regular}; gap: ${({ theme }) => theme.spacing(1)}; + overflow: hidden; width: 160px; :before { @@ -39,50 +39,66 @@ const StyledAvatar = styled(Avatar)` margin: ${({ theme }) => theme.spacing(0, 1)}; `; +const StyledSenderName = styled.span` + overflow: hidden; + text-overflow: ellipsis; +`; + const StyledThreadCount = styled.span` color: ${({ theme }) => theme.font.color.tertiary}; `; -const StyledSubject = styled.div<{ unread: boolean }>` +const StyledSubject = styled.span<{ unread: boolean }>` color: ${({ theme, unread }) => unread ? theme.font.color.primary : theme.font.color.secondary}; + white-space: nowrap; `; -const StyledBody = styled.div` +const StyledBody = styled.span` color: ${({ theme }) => theme.font.color.tertiary}; - flex: 1 0 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; `; +const StyledSubjectAndBody = styled.div` + display: flex; + flex: 1; + gap: ${({ theme }) => theme.spacing(2)}; + overflow: hidden; + text-overflow: ellipsis; +`; + const StyledReceivedAt = styled.div` font-size: ${({ theme }) => theme.font.size.sm}; font-weight: ${({ theme }) => theme.font.weight.regular}; padding: ${({ theme }) => theme.spacing(0, 1)}; `; -type EmailPreviewProps = { +type ThreadPreviewProps = { divider?: boolean; - email: Email; + thread: TimelineThread; }; -export const EmailPreview = ({ divider, email }: EmailPreviewProps) => ( +export const ThreadPreview = ({ divider, thread }: ThreadPreviewProps) => ( - + - {email.senderName}{' '} - {email.numberOfEmailsInThread} + {thread.senderName} + {thread.numberOfMessagesInThread} - {email.subject} - {email.body} + + + {thread.subject} + {thread.body} + - {formatToHumanReadableDate(email.receivedAt)} + {formatToHumanReadableDate(thread.receivedAt)} ); diff --git a/packages/twenty-front/src/modules/activities/emails/components/Threads.tsx b/packages/twenty-front/src/modules/activities/emails/components/Threads.tsx new file mode 100644 index 000000000..952e7958c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/components/Threads.tsx @@ -0,0 +1,82 @@ +import { useQuery } from '@apollo/client'; +import styled from '@emotion/styled'; + +import { ThreadPreview } from '@/activities/emails/components/ThreadPreview'; +import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId'; +import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId'; +import { ActivityTargetableEntity } from '@/activities/types/ActivityTargetableEntity'; +import { + H1Title, + H1TitleFontColor, +} 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; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(6)}; + padding: ${({ theme }) => theme.spacing(6, 6, 2)}; +`; + +const StyledH1Title = styled(H1Title)` + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledEmailCount = styled.span` + color: ${({ theme }) => theme.font.color.light}; +`; + +export const Threads = ({ entity }: { entity: ActivityTargetableEntity }) => { + const threadQuery = + entity.type === 'Person' + ? getTimelineThreadsFromPersonId + : getTimelineThreadsFromCompanyId; + + const threadQueryVariables = + entity.type === 'Person' + ? { personId: entity.id } + : { companyId: entity.id }; + + const threads = useQuery(threadQuery, { + variables: threadQueryVariables, + }); + + if (threads.loading) { + return; + } + + const timelineThreads: TimelineThread[] = + threads.data[ + entity.type === 'Person' + ? 'getTimelineThreadsFromPersonId' + : 'getTimelineThreadsFromCompanyId' + ]; + + return ( + +
+ + Inbox{' '} + {timelineThreads.length} + + } + fontColor={H1TitleFontColor.Primary} + /> + + {timelineThreads.map((thread: TimelineThread, index: number) => ( + + ))} + +
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/activities/emails/components/__stories__/Emails.stories.tsx b/packages/twenty-front/src/modules/activities/emails/components/__stories__/Emails.stories.tsx deleted file mode 100644 index c6712a903..000000000 --- a/packages/twenty-front/src/modules/activities/emails/components/__stories__/Emails.stories.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; - -import { Emails } from '../Emails'; - -const meta: Meta = { - title: 'Modules/Activity/Emails/Emails', - component: Emails, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/activities/emails/components/__stories__/Threads.stories.tsx b/packages/twenty-front/src/modules/activities/emails/components/__stories__/Threads.stories.tsx new file mode 100644 index 000000000..583aec863 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/components/__stories__/Threads.stories.tsx @@ -0,0 +1,13 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Threads } from '../Threads'; + +const meta: Meta = { + title: 'Modules/Activity/Emails/Threads', + component: Threads, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts new file mode 100644 index 000000000..d8bb4a931 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts @@ -0,0 +1,15 @@ +import { gql } from '@apollo/client'; + +export const getTimelineThreadsFromCompanyId = gql` + query GetTimelineThreadsFromCompanyId($companyId: String!) { + getTimelineThreadsFromCompanyId(companyId: $companyId) { + body + numberOfMessagesInThread + read + receivedAt + senderName + senderPictureUrl + subject + } + } +`; diff --git a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts new file mode 100644 index 000000000..b1257eb9f --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts @@ -0,0 +1,15 @@ +import { gql } from '@apollo/client'; + +export const getTimelineThreadsFromPersonId = gql` + query GetTimelineThreadsFromPersonId($personId: String!) { + getTimelineThreadsFromPersonId(personId: $personId) { + body + numberOfMessagesInThread + read + receivedAt + senderName + senderPictureUrl + subject + } + } +`; diff --git a/packages/twenty-front/src/modules/activities/emails/types/email.ts b/packages/twenty-front/src/modules/activities/emails/types/email.ts deleted file mode 100644 index a49c2be3f..000000000 --- a/packages/twenty-front/src/modules/activities/emails/types/email.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type Email = { - body: string; - numberOfEmailsInThread: number; - read: boolean; - receivedAt: Date; - senderName: string; - senderPictureUrl: string; - subject: string; -}; diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx index 5e5336837..6a82de0ce 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -import { Emails } from '@/activities/emails/components/Emails'; +import { Threads } from '@/activities/emails/components/Threads'; import { Attachments } from '@/activities/files/components/Attachments'; import { Notes } from '@/activities/notes/components/Notes'; import { EntityTasks } from '@/activities/tasks/components/EntityTasks'; @@ -108,7 +108,7 @@ export const ShowPageRightContainer = ({ {activeTabId === 'tasks' && } {activeTabId === 'notes' && } {activeTabId === 'files' && } - {activeTabId === 'emails' && } + {activeTabId === 'emails' && } ); }; diff --git a/packages/twenty-front/src/testing/mock-data/activities.ts b/packages/twenty-front/src/testing/mock-data/activities.ts index 0ea1407e3..c911cf065 100644 --- a/packages/twenty-front/src/testing/mock-data/activities.ts +++ b/packages/twenty-front/src/testing/mock-data/activities.ts @@ -1,10 +1,10 @@ -import { Email } from '@/activities/emails/types/email'; import { Activity } from '@/activities/types/Activity'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; 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, @@ -207,21 +207,21 @@ export const mockedActivities: Array = [ }, ]; -export const mockedEmails: Email[] = [ +export const mockedThreads: 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.', - numberOfEmailsInThread: 4, + numberOfMessagesInThread: 4, read: false, - receivedAt: new Date('11/04/2023'), + 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.', - numberOfEmailsInThread: 3, + numberOfMessagesInThread: 3, read: true, - receivedAt: new Date('11/04/2023'), + receivedAt: new Date('11/04/2023').toISOString(), senderName: 'Alexandre Prot', senderPictureUrl: '', subject: 'Next step', diff --git a/packages/twenty-server/src/core/core.module.ts b/packages/twenty-server/src/core/core.module.ts index 9c225defe..cab93d790 100644 --- a/packages/twenty-server/src/core/core.module.ts +++ b/packages/twenty-server/src/core/core.module.ts @@ -7,6 +7,7 @@ import { AuthModule } from 'src/core/auth/auth.module'; import { ApiRestModule } from 'src/core/api-rest/api-rest.module'; import { FeatureFlagModule } from 'src/core/feature-flag/feature-flag.module'; import { OpenApiModule } from 'src/core/open-api/open-api.module'; +import { TimelineMessagingModule } from 'src/core/messaging/timeline-messaging.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { FileModule } from './file/file.module'; @@ -24,6 +25,7 @@ import { ClientConfigModule } from './client-config/client-config.module'; ApiRestModule, OpenApiModule, FeatureFlagModule, + TimelineMessagingModule, ], exports: [ AuthModule, @@ -31,6 +33,7 @@ import { ClientConfigModule } from './client-config/client-config.module'; UserModule, AnalyticsModule, FeatureFlagModule, + TimelineMessagingModule, ], }) export class CoreModule {} diff --git a/packages/twenty-server/src/core/messaging/timeline-messaging.module.ts b/packages/twenty-server/src/core/messaging/timeline-messaging.module.ts new file mode 100644 index 000000000..2a6fb1a32 --- /dev/null +++ b/packages/twenty-server/src/core/messaging/timeline-messaging.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { TimelineMessagingResolver } from 'src/core/messaging/timeline-messaging.resolver'; +import { TimelineMessagingService } from 'src/core/messaging/timeline-messaging.service'; +import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { DataSourceModule } from 'src/metadata/data-source/data-source.module'; + +@Module({ + imports: [DataSourceModule, TypeORMModule], + exports: [], + providers: [TimelineMessagingResolver, TimelineMessagingService], +}) +export class TimelineMessagingModule {} diff --git a/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts b/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts new file mode 100644 index 000000000..591af18b6 --- /dev/null +++ b/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts @@ -0,0 +1,77 @@ +import { Args, Query, Field, Resolver, ObjectType } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; + +import { Column, Entity } from 'typeorm'; + +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'; + +@Entity({ name: 'timelineThread', schema: 'core' }) +@ObjectType('TimelineThread') +class TimelineThread { + @Field() + @Column() + read: boolean; + + @Field() + @Column() + senderName: string; + + @Field() + @Column() + senderPictureUrl: string; + + @Field() + @Column() + numberOfMessagesInThread: number; + + @Field() + @Column() + subject: string; + + @Field() + @Column() + body: string; + + @Field() + @Column() + receivedAt: Date; +} + +@UseGuards(JwtAuthGuard) +@Resolver(() => [TimelineThread]) +export class TimelineMessagingResolver { + constructor( + private readonly timelineMessagingService: TimelineMessagingService, + ) {} + + @Query(() => [TimelineThread]) + async getTimelineThreadsFromPersonId( + @AuthWorkspace() { id: workspaceId }: Workspace, + @Args('personId') personId: string, + ) { + const timelineThreads = + await this.timelineMessagingService.getMessagesFromPersonIds( + workspaceId, + [personId], + ); + + return timelineThreads; + } + + @Query(() => [TimelineThread]) + async getTimelineThreadsFromCompanyId( + @AuthWorkspace() { id: workspaceId }: Workspace, + @Args('companyId') companyId: string, + ) { + const timelineThreads = + await this.timelineMessagingService.getMessagesFromCompanyId( + workspaceId, + companyId, + ); + + 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 new file mode 100644 index 000000000..dbc407395 --- /dev/null +++ b/packages/twenty-server/src/core/messaging/timeline-messaging.service.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@nestjs/common'; + +import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +import { DataSourceService } from 'src/metadata/data-source/data-source.service'; + +@Injectable() +export class TimelineMessagingService { + constructor( + private readonly dataSourceService: DataSourceService, + private readonly typeORMService: TypeORMService, + ) {} + + async getMessagesFromPersonIds(workspaceId: string, personIds: string[]) { + const dataSourceMetadata = + await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + workspaceId, + ); + + 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( + ` + SELECT + subquery.*, + message_count, + last_message_subject, + last_message_body, + last_message_date, + last_message_recipient_handle, + last_message_recipient_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."date" DESC) AS last_message_subject, + FIRST_VALUE(m."body") OVER (PARTITION BY mt."id" ORDER BY m."date" DESC) AS last_message_body, + FIRST_VALUE(m."date") OVER (PARTITION BY mt."id" ORDER BY m."date" DESC) AS last_message_date, + FIRST_VALUE(mr."handle") OVER (PARTITION BY mt."id" ORDER BY m."date" DESC) AS last_message_recipient_handle, + FIRST_VALUE(mr."displayName") OVER (PARTITION BY mt."id" ORDER BY m."date" DESC) AS last_message_recipient_displayName, + ROW_NUMBER() OVER (PARTITION BY mt."id" ORDER BY m."date" DESC) AS rn + FROM + ${dataSourceMetadata.schema}."messageThread" mt + LEFT JOIN + ${dataSourceMetadata.schema}."message" m ON mt."id" = m."messageThreadId" + LEFT JOIN + ${dataSourceMetadata.schema}."messageRecipient" 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_date DESC + LIMIT 10; +`, + [personIds], + ); + + const formattedMessageThreads = messageThreads.map((messageThread) => { + return { + read: true, + senderName: messageThread.last_message_recipient_handle, + senderPictureUrl: '', + numberOfMessagesInThread: messageThread.message_count, + subject: messageThread.last_message_subject, + body: messageThread.last_message_body, + receivedAt: messageThread.last_message_date, + }; + }); + + return formattedMessageThreads; + } + + async getMessagesFromCompanyId(workspaceId: string, companyId: string) { + const dataSourceMetadata = + await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + workspaceId, + ); + + const workspaceDataSource = await this.typeORMService.connectToDataSource( + dataSourceMetadata, + ); + + const personIds = await workspaceDataSource?.query( + ` + SELECT + p."id" + FROM + ${dataSourceMetadata.schema}."person" p + WHERE + p."companyId" = $1 + `, + [companyId], + ); + + if (!personIds) { + return []; + } + + const formattedPersonIds = personIds.map((personId) => personId.id); + + const messageThreads = await this.getMessagesFromPersonIds( + workspaceId, + formattedPersonIds, + ); + + return messageThreads; + } +}