From be96c68416cee67cfb342a561eae62751eeefd66 Mon Sep 17 00:00:00 2001 From: Weiko Date: Tue, 11 Jun 2024 18:53:28 +0200 Subject: [PATCH] POC timeline activity (#5697) TODO: - remove WorkspaceIsNotAuditLogged decorators on activity/activityTarget to log task/note creations - handle attachments - fix css and remove unnecessary styled components or duplicates --- .../emails/components/EmailThreadHeader.tsx | 8 +- .../components/RightDrawerEmailThread.tsx | 15 +- .../useRightDrawerEmailThread.test.tsx | 16 ++ .../hooks/useRightDrawerEmailThread.ts | 24 +- .../activities/emails/types/EmailThread.ts | 8 + .../emails/types/EmailThreadMessage.ts | 2 + .../TimelineActivities.stories.tsx | 80 ++++++ .../components/EventList.tsx | 1 - .../components/EventRow.tsx | 241 +++++------------- .../components/EventsGroup.tsx | 2 + .../components/TimelineActivities.tsx | 10 +- .../TimelineActivitiesQueryEffect.tsx | 19 ++ .../contexts/TimelineActivityContext.ts | 10 + .../hooks/useTimelineActivities.tsx | 21 +- .../activity/components/EventRowActivity.tsx | 40 +++ .../components/EventCardCalendarEvent.tsx | 169 ++++++++++++ .../components/EventRowCalendarEvent.tsx | 62 +++++ .../rows/components/EventCard.tsx | 70 +++++ .../components/EventRowDynamicComponent.tsx | 102 ++++++++ .../mainObject/components/EventFieldDiff.tsx | 51 ++++ .../components/EventFieldDiffLabel.tsx | 46 ++++ .../components/EventFieldDiffValue.tsx | 53 ++++ .../components/EventFieldDiffValueEffect.tsx | 42 +++ .../components/EventRowMainObject.tsx | 52 ++++ .../components/EventRowMainObjectUpdated.tsx | 125 +++++++++ .../message/components/EventCardMessage.tsx | 127 +++++++++ .../components/EventCardMessageNotShared.tsx | 86 +++++++ .../message/components/EventRowMessage.tsx | 70 +++++ .../__tests__/groupEventsByMonth.test.ts | 10 +- .../states/currentWorkspaceMemberState.ts | 13 +- .../utils/getObjectMetadataItemsMock.ts | 36 +++ .../components/ShowPageRightContainer.tsx | 110 ++++---- .../pages/object-record/RecordShowPage.tsx | 17 +- .../testing/mock-data/timeline-activities.ts | 102 +++++++- packages/twenty-front/src/utils/index.ts | 25 ++ .../typeorm-seeds/core/feature-flags.ts | 2 +- .../listeners/entity-events-to-db.listener.ts | 4 +- .../integrations/message-queue/jobs.module.ts | 4 +- .../activity-target.workspace-entity.ts | 2 - .../activity.workspace-entity.ts | 2 - .../comment.workspace-entity.ts | 2 - .../src/modules/calendar/calendar.module.ts | 20 +- ...eate-company-and-contact-after-sync.job.ts | 20 +- .../calendar-event-participant.listener.ts | 71 ++++++ .../calendar-event-participant.repository.ts | 17 ++ .../calendar-event-participant.service.ts | 49 ++-- .../google-calendar-sync.service.ts | 33 ++- .../jobs/create-company-and-contact.job.ts | 8 +- .../create-company-and-contact.service.ts | 48 +++- .../can-access-message-thread.service.ts | 64 +++++ .../message-find-many.pre-query.hook.ts | 61 +---- .../message-find-one.pre-query-hook.ts | 37 ++- .../messaging-query-hook.module.ts | 2 + .../message-participant.repository.ts | 17 ++ .../messaging-message-participant.service.ts | 50 ++-- ...es-and-enqueue-contact-creation.service.ts | 28 +- ...eate-company-and-contact-after-sync.job.ts | 20 +- .../listeners/message-participant.listener.ts | 72 ++++++ .../messaging-participants-manager.module.ts | 11 + .../timeline-activity.repository.ts | 68 ++++- 60 files changed, 2134 insertions(+), 443 deletions(-) create mode 100644 packages/twenty-front/src/modules/activities/emails/types/EmailThread.ts create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/__stories__/TimelineActivities.stories.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineActivitiesQueryEffect.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/contexts/TimelineActivityContext.ts create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/activity/components/EventRowActivity.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventRowCalendarEvent.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventCard.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventRowDynamicComponent.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiff.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffLabel.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValue.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValueEffect.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventRowMainObject.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventRowMainObjectUpdated.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessage.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessageNotShared.tsx create mode 100644 packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventRowMessage.tsx create mode 100644 packages/twenty-server/src/modules/calendar/listeners/calendar-event-participant.listener.ts create mode 100644 packages/twenty-server/src/modules/messaging/common/query-hooks/message/can-access-message-thread.service.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-participants-manager/listeners/message-participant.listener.ts diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx index 29cd280ed..bd28d7997 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx @@ -44,9 +44,11 @@ export const EmailThreadHeader = ({ {subject} - - Last message {beautifyPastDateRelativeToNow(lastMessageSentAt)} - + {lastMessageSentAt && ( + + Last message {beautifyPastDateRelativeToNow(lastMessageSentAt)} + + )} ); 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 f668c08ad..1ec76b8a9 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 @@ -43,11 +43,14 @@ export const RightDrawerEmailThread = () => { useRegisterClickOutsideListenerCallback({ callbackId: - 'EmailThreadClickOutsideCallBack-' + (thread.id ?? 'no-thread-id'), + 'EmailThreadClickOutsideCallBack-' + (thread?.id ?? 'no-thread-id'), callbackFunction: useRecoilCallback( ({ set }) => () => { - set(emailThreadIdWhenEmailThreadWasClosedState, thread.id); + set( + emailThreadIdWhenEmailThreadWasClosedState, + thread?.id ?? 'no-thread-id', + ); }, [thread], ), @@ -71,14 +74,14 @@ export const RightDrawerEmailThread = () => { return ( - {loading ? ( ) : ( <> + {firstMessages.map((message) => ( ({ + __esModule: true, + useFindOneRecord: jest.fn(), +})); + jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ __esModule: true, useFindManyRecords: jest.fn(), @@ -13,11 +19,21 @@ jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ describe('useRightDrawerEmailThread', () => { it('should return correct values', async () => { + const mockThread = { id: '1' }; + const mockMessages = [ { id: '1', text: 'Message 1' }, { id: '2', text: 'Message 2' }, ]; + const mockFetchMoreRecords = jest.fn(); + + (useFindOneRecord as jest.Mock).mockReturnValue({ + record: mockThread, + loading: false, + fetchMoreRecords: mockFetchMoreRecords, + }); + (useFindManyRecords as jest.Mock).mockReturnValue({ records: mockMessages, loading: false, diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts index bc415e406..b63e429ef 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts @@ -1,26 +1,26 @@ import { useCallback } from 'react'; -import { useApolloClient } from '@apollo/client'; -import gql from 'graphql-tag'; import { useRecoilValue } from 'recoil'; import { fetchAllThreadMessagesOperationSignatureFactory } from '@/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory'; +import { EmailThread } from '@/activities/emails/types/EmailThread'; import { EmailThreadMessage as EmailThreadMessageType } from '@/activities/emails/types/EmailThreadMessage'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; +import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; export const useRightDrawerEmailThread = () => { const viewableRecordId = useRecoilValue(viewableRecordIdState); + const { setRecords } = useSetRecordInStore(); - const apolloClient = useApolloClient(); - const thread = apolloClient.readFragment({ - id: `TimelineThread:${viewableRecordId}`, - fragment: gql` - fragment timelineThread on TimelineThread { - id - subject - lastMessageReceivedAt - } - `, + const { record: thread } = useFindOneRecord({ + objectNameSingular: CoreObjectNameSingular.MessageThread, + objectRecordId: viewableRecordId ?? '', + recordGqlFields: { + id: true, + }, + onCompleted: (record) => setRecords([record]), }); const FETCH_ALL_MESSAGES_OPERATION_SIGNATURE = diff --git a/packages/twenty-front/src/modules/activities/emails/types/EmailThread.ts b/packages/twenty-front/src/modules/activities/emails/types/EmailThread.ts new file mode 100644 index 000000000..55a68eaff --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/types/EmailThread.ts @@ -0,0 +1,8 @@ +import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage'; + +export type EmailThread = { + id: string; + subject: string; + messages: EmailThreadMessage[]; + __typename: 'EmailThread'; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/types/EmailThreadMessage.ts b/packages/twenty-front/src/modules/activities/emails/types/EmailThreadMessage.ts index f585af847..01f6555b9 100644 --- a/packages/twenty-front/src/modules/activities/emails/types/EmailThreadMessage.ts +++ b/packages/twenty-front/src/modules/activities/emails/types/EmailThreadMessage.ts @@ -4,6 +4,8 @@ export type EmailThreadMessage = { id: string; text: string; receivedAt: string; + subject: string; + messageThreadId: string; messageParticipants: EmailThreadMessageParticipant[]; __typename: 'EmailThreadMessage'; }; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/__stories__/TimelineActivities.stories.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/__stories__/TimelineActivities.stories.tsx new file mode 100644 index 000000000..d04d8281c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/__stories__/TimelineActivities.stories.tsx @@ -0,0 +1,80 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { graphql, HttpResponse } from 'msw'; +import { ComponentDecorator } from 'twenty-ui'; + +import { TimelineActivities } from '@/activities/timelineActivities/components/TimelineActivities'; +import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; +import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; +import { mockedTimelineActivities } from '~/testing/mock-data/timeline-activities'; + +const meta: Meta = { + title: 'Modules/TimelineActivities/TimelineActivities', + component: TimelineActivities, + decorators: [ + ComponentDecorator, + ObjectMetadataItemsDecorator, + SnackBarDecorator, + (Story) => { + return ( + + + + ); + }, + ], + args: { + targetableObject: { + id: '1', + targetObjectNameSingular: 'company', + }, + }, + parameters: { + msw: { + handlers: [ + graphql.query('FindManyActivities', () => { + return HttpResponse.json({ + data: { + activities: { + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }, + }, + }); + }), + graphql.query('FindManyTimelineActivities', () => { + return HttpResponse.json({ + data: { + timelineActivities: { + edges: mockedTimelineActivities.map((activity) => ({ + node: activity, + cursor: activity.id, + })), + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }, + }, + }); + }), + ], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventList.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventList.tsx index 43f731264..803f36160 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventList.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventList.tsx @@ -25,7 +25,6 @@ const StyledTimelineContainer = styled.div` gap: ${({ theme }) => theme.spacing(1)}; justify-content: flex-start; - padding: ${({ theme }) => theme.spacing(4)}; width: calc(100% - ${({ theme }) => theme.spacing(8)}); `; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventRow.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventRow.tsx index 24a5400f4..a70cba5e1 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventRow.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventRow.tsx @@ -1,77 +1,46 @@ -import { Tooltip } from 'react-tooltip'; +import { useContext } from 'react'; import styled from '@emotion/styled'; -import { - IconCheckbox, - IconCirclePlus, - IconEditCircle, - IconFocusCentered, - IconNotes, - useIcons, -} from 'twenty-ui'; +import { useRecoilValue } from 'recoil'; -import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; import { useLinkedObject } from '@/activities/timeline/hooks/useLinkedObject'; -import { EventUpdateProperty } from '@/activities/timelineActivities/components/EventUpdateProperty'; +import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext'; +import { + EventIconDynamicComponent, + EventRowDynamicComponent, +} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; +import { + CurrentWorkspaceMember, + currentWorkspaceMemberState, +} from '@/auth/states/currentWorkspaceMemberState'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; -import { - beautifyExactDateTime, - beautifyPastDateRelativeToNow, -} from '~/utils/date-utils'; +import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; +import { isDefined } from '~/utils/isDefined'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; const StyledIconContainer = styled.div` - align-items: center; - color: ${({ theme }) => theme.font.color.tertiary}; display: flex; - user-select: none; - height: 16px; - margin: 5px; + align-items: center; justify-content: center; - text-decoration-line: underline; + color: ${({ theme }) => theme.font.color.tertiary}; + height: 16px; width: 16px; + margin: 5px; + user-select: none; + text-decoration-line: underline; z-index: 2; -`; - -const StyledActionName = styled.span` - overflow: hidden; - flex: none; - white-space: nowrap; + align-self: normal; `; const StyledItemContainer = styled.div` - align-content: center; + display: flex; align-items: center; - color: ${({ theme }) => theme.font.color.tertiary}; - display: flex; + gap: ${({ theme }) => theme.spacing(1)}; flex: 1; - gap: ${({ theme }) => theme.spacing(1)}; - span { - color: ${({ theme }) => theme.font.color.secondary}; - } overflow: hidden; `; -const StyledItemAuthorText = styled.span` - display: flex; - color: ${({ theme }) => theme.font.color.primary}; - gap: ${({ theme }) => theme.spacing(1)}; - white-space: nowrap; -`; - -const StyledItemTitle = styled.span` - display: flex; - flex-flow: row nowrap; - overflow: hidden; - white-space: nowrap; -`; - -const StyledLinkedObject = styled.span` - cursor: pointer; - text-decoration: underline; -`; - const StyledItemTitleDate = styled.div` align-items: center; color: ${({ theme }) => theme.font.color.tertiary}; @@ -98,25 +67,10 @@ const StyledVerticalLine = styled.div` width: 2px; `; -const StyledTooltip = styled(Tooltip)` - background-color: ${({ theme }) => theme.background.primary}; - - box-shadow: 0px 2px 4px 3px - ${({ theme }) => theme.background.transparent.light}; - - box-shadow: 2px 4px 16px 6px - ${({ theme }) => theme.background.transparent.light}; - - color: ${({ theme }) => theme.font.color.primary}; - - opacity: 1; - padding: ${({ theme }) => theme.spacing(2)}; -`; - const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>` - align-items: center; - align-self: stretch; display: flex; + align-items: center; + justify-content: space-between; gap: ${({ theme }) => theme.spacing(4)}; height: ${({ isGap, theme }) => isGap ? (useIsMobile() ? theme.spacing(6) : theme.spacing(3)) : 'auto'}; @@ -127,8 +81,9 @@ const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>` const StyledSummary = styled.summary` display: flex; flex: 1; - flex-flow: row ${() => (useIsMobile() ? 'wrap' : 'nowrap')}; + flex-direction: row; gap: ${({ theme }) => theme.spacing(1)}; + align-items: center; overflow: hidden; `; @@ -138,135 +93,69 @@ type EventRowProps = { event: TimelineActivity; }; +const getAuthorFullName = ( + event: TimelineActivity, + currentWorkspaceMember: CurrentWorkspaceMember, +) => { + if (isDefined(event.workspaceMember)) { + return currentWorkspaceMember.id === event.workspaceMember.id + ? 'You' + : `${event.workspaceMember?.name.firstName} ${event.workspaceMember?.name.lastName}`; + } + return 'Twenty'; +}; + export const EventRow = ({ isLastEvent, event, mainObjectMetadataItem, }: EventRowProps) => { + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + + const { labelIdentifierValue } = useContext(TimelineActivityContext); const beautifiedCreatedAt = beautifyPastDateRelativeToNow(event.createdAt); - const exactCreatedAt = beautifyExactDateTime(event.createdAt); + const linkedObjectMetadataItem = useLinkedObject( + event.linkedObjectMetadataId, + ); - const properties = JSON.parse(event.properties); - const diff: Record = properties?.diff; - - const isEventType = (type: 'created' | 'updated') => { - if (event.name.includes('.')) { - return event.name.split('.')[1] === type; - } - return false; - }; - - const { getIcon } = useIcons(); - - const linkedObjectMetadata = useLinkedObject(event.linkedObjectMetadataId); - - const linkedObjectLabel = event.name.includes('note') - ? 'note' - : event.name.includes('task') - ? 'task' - : linkedObjectMetadata?.labelSingular; - - const ActivityIcon = event.linkedObjectMetadataId - ? event.name.includes('note') - ? IconNotes - : event.name.includes('task') - ? IconCheckbox - : getIcon(linkedObjectMetadata?.icon) - : isEventType('created') - ? IconCirclePlus - : isEventType('updated') - ? IconEditCircle - : IconFocusCentered; - - const author = - event.workspaceMember?.name.firstName + - ' ' + - event.workspaceMember?.name.lastName; - - const action = isEventType('created') - ? 'created' - : isEventType('updated') - ? 'updated' - : event.name; - - let description; - - if (!isUndefinedOrNull(linkedObjectMetadata)) { - description = 'a ' + linkedObjectLabel; - } else if (!event.linkedObjectMetadataId && isEventType('created')) { - description = `a new ${mainObjectMetadataItem?.labelSingular}`; - } else if (isEventType('updated')) { - const diffKeys = Object.keys(diff); - if (diffKeys.length === 0) { - description = `a ${mainObjectMetadataItem?.labelSingular}`; - } else if (diffKeys.length === 1) { - const [key, value] = Object.entries(diff)[0]; - description = [ - , - ]; - } else if (diffKeys.length === 2) { - description = - mainObjectMetadataItem?.fields.find( - (field) => diffKeys[0] === field.name, - )?.label + - ' and ' + - mainObjectMetadataItem?.fields.find( - (field) => diffKeys[1] === field.name, - )?.label; - } else if (diffKeys.length > 2) { - description = - diffKeys[0] + ' and ' + (diffKeys.length - 1) + ' other fields'; - } - } else if (!isEventType('created') && !isEventType('updated')) { - description = JSON.stringify(diff); + if (isUndefinedOrNull(currentWorkspaceMember)) { + return null; } - const details = JSON.stringify(diff); - const openActivityRightDrawer = useOpenActivityRightDrawer(); + const authorFullName = getAuthorFullName(event, currentWorkspaceMember); + + if (isUndefinedOrNull(mainObjectMetadataItem)) { + return null; + } return ( <> - + -
- - {author} - {action} - {description} - {isUndefinedOrNull(linkedObjectMetadata) ? ( - <> - ) : ( - openActivityRightDrawer(event.linkedRecordId)} - > - {event.linkedRecordCachedName} - - )} - - {details} -
- + + + {beautifiedCreatedAt} -
{!isLastEvent && ( - + )} diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventsGroup.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventsGroup.tsx index afb561724..430968f4f 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventsGroup.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/components/EventsGroup.tsx @@ -45,6 +45,8 @@ const StyledMonthSeperator = styled.div` color: ${({ theme }) => theme.font.color.light}; display: flex; gap: ${({ theme }) => theme.spacing(4)}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + font-size: ${({ theme }) => theme.font.size.xs}; `; const StyledMonthSeperatorLine = styled.div` background: ${({ theme }) => theme.border.color.light}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineActivities.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineActivities.tsx index cf05f9808..fac968ef1 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineActivities.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineActivities.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import { isNonEmptyArray } from '@sniptt/guards'; +import { FetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader'; import { TimelineCreateButtonGroup } from '@/activities/timeline/components/TimelineCreateButtonGroup'; import { EventList } from '@/activities/timelineActivities/components/EventList'; import { useTimelineActivities } from '@/activities/timelineActivities/hooks/useTimelineActivities'; @@ -24,6 +25,11 @@ const StyledMainContainer = styled.div` height: 100%; justify-content: center; + padding-top: ${({ theme }) => theme.spacing(6)}; + padding-right: ${({ theme }) => theme.spacing(6)}; + padding-bottom: ${({ theme }) => theme.spacing(16)}; + padding-left: ${({ theme }) => theme.spacing(6)}; + gap: ${({ theme }) => theme.spacing(4)}; `; export const TimelineActivities = ({ @@ -31,7 +37,8 @@ export const TimelineActivities = ({ }: { targetableObject: ActivityTargetableObject; }) => { - const { timelineActivities } = useTimelineActivities(targetableObject); + const { timelineActivities, loading, fetchMoreRecords } = + useTimelineActivities(targetableObject); if (!isNonEmptyArray(timelineActivities)) { return ( @@ -57,6 +64,7 @@ export const TimelineActivities = ({ title="All" events={timelineActivities ?? []} /> + ); }; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineActivitiesQueryEffect.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineActivitiesQueryEffect.tsx new file mode 100644 index 000000000..ef455eca9 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineActivitiesQueryEffect.tsx @@ -0,0 +1,19 @@ +import { useActivities } from '@/activities/hooks/useActivities'; +import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FindManyTimelineActivitiesOrderBy'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { isDefined } from '~/utils/isDefined'; + +export const TimelineActivitiesQueryEffect = ({ + targetableObject, +}: { + targetableObject: ActivityTargetableObject; +}) => { + useActivities({ + targetableObjects: [targetableObject], + activitiesFilters: {}, + activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY, + skip: !isDefined(targetableObject), + }); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/contexts/TimelineActivityContext.ts b/packages/twenty-front/src/modules/activities/timelineActivities/contexts/TimelineActivityContext.ts new file mode 100644 index 000000000..cd50acc26 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/contexts/TimelineActivityContext.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +type TimelineActivityContextValue = { + labelIdentifierValue: string; +}; + +export const TimelineActivityContext = + createContext({ + labelIdentifierValue: '', + }); diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/hooks/useTimelineActivities.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/hooks/useTimelineActivities.tsx index d80726468..60aff85e9 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/hooks/useTimelineActivities.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/hooks/useTimelineActivities.tsx @@ -12,7 +12,11 @@ export const useTimelineActivities = ( nameSingular: targetableObject.targetObjectNameSingular, }); - const { records: TimelineActivities } = useFindManyRecords({ + const { + records: TimelineActivities, + loading, + fetchMoreRecords, + } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.TimelineActivity, filter: { [targetableObjectFieldIdName]: { @@ -22,10 +26,23 @@ export const useTimelineActivities = ( orderBy: { createdAt: 'DescNullsFirst', }, - fetchPolicy: 'network-only', + recordGqlFields: { + id: true, + createdAt: true, + linkedObjectMetadataId: true, + linkedRecordCachedName: true, + linkedRecordId: true, + name: true, + properties: true, + happensAt: true, + workspaceMember: true, + person: true, + }, }); return { timelineActivities: TimelineActivities as TimelineActivity[], + loading, + fetchMoreRecords, }; }; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/activity/components/EventRowActivity.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/activity/components/EventRowActivity.tsx new file mode 100644 index 000000000..00cfa8395 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/activity/components/EventRowActivity.tsx @@ -0,0 +1,40 @@ +import styled from '@emotion/styled'; + +import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; +import { + EventRowDynamicComponentProps, + StyledItemAction, + StyledItemAuthorText, +} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; + +type EventRowActivityProps = EventRowDynamicComponentProps; + +const StyledLinkedActivity = styled.span` + cursor: pointer; + text-decoration: underline; +`; + +export const EventRowActivity: React.FC = ({ + event, + authorFullName, +}: EventRowActivityProps) => { + const [, eventAction] = event.name.split('.'); + + const openActivityRightDrawer = useOpenActivityRightDrawer(); + + if (!event.linkedRecordId) { + throw new Error('Could not find linked record id for event'); + } + + return ( + <> + {authorFullName} + {eventAction} + openActivityRightDrawer(event.linkedRecordId)} + > + {event.linkedRecordCachedName} + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx new file mode 100644 index 000000000..943473d8c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx @@ -0,0 +1,169 @@ +import styled from '@emotion/styled'; +import { isUndefined } from '@sniptt/guards'; + +import { useOpenCalendarEventRightDrawer } from '@/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer'; +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { + formatToHumanReadableDay, + formatToHumanReadableMonth, + formatToHumanReadableTime, +} from '~/utils'; +import { isDefined } from '~/utils/isDefined'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; + +const StyledEventCardCalendarEventContainer = styled.div` + cursor: pointer; + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(2)}; + width: 100%; +`; + +const StyledCalendarEventContent = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; + justify-content: center; +`; + +const StyledCalendarEventTop = styled.div` + align-items: center; + align-self: stretch; + display: flex; + justify-content: space-between; +`; + +const StyledCalendarEventTitle = styled.div` + color: ${({ theme }) => theme.font.color.primary}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + display: flex; +`; + +const StyledCalendarEventBody = styled.div` + align-items: flex-start; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + flex: 1 0 0; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; + + justify-content: center; +`; + +const StyledCalendarEventDateCard = styled.div` + display: flex; + padding: ${({ theme }) => theme.spacing(1)}; + flex-direction: column; + justify-content: center; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; + + border-radius: ${({ theme }) => theme.spacing(1)}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; +`; + +const StyledCalendarEventDateCardMonth = styled.div` + color: ${({ theme }) => theme.font.color.danger}; + font-size: ${({ theme }) => theme.font.size.xxs}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; +`; + +const StyledCalendarEventDateCardDay = styled.div` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +export const EventCardCalendarEvent = ({ + calendarEventId, +}: { + calendarEventId: string; +}) => { + const { setRecords } = useSetRecordInStore(); + + const { + record: calendarEvent, + loading, + error, + } = useFindOneRecord({ + objectNameSingular: CoreObjectNameSingular.CalendarEvent, + objectRecordId: calendarEventId, + recordGqlFields: { + id: true, + title: true, + startsAt: true, + endsAt: true, + }, + onCompleted: (data) => { + setRecords([data]); + }, + }); + + const { openCalendarEventRightDrawer } = useOpenCalendarEventRightDrawer(); + + if (isDefined(error)) { + const shouldHideMessageContent = error.graphQLErrors.some( + (e) => e.extensions?.code === 'FORBIDDEN', + ); + + if (shouldHideMessageContent) { + return
Calendar event not shared
; + } + + const shouldHandleNotFound = error.graphQLErrors.some( + (e) => e.extensions?.code === 'NOT_FOUND', + ); + + if (shouldHandleNotFound) { + return
Calendar event not found
; + } + + return
Error loading message
; + } + + if (loading || isUndefined(calendarEvent)) { + return
Loading...
; + } + + const startsAtDate = calendarEvent?.startsAt; + const endsAtDate = calendarEvent?.endsAt; + + if (isUndefinedOrNull(startsAtDate)) { + throw new Error("Can't render a calendarEvent without a start date"); + } + + const startsAtMonth = formatToHumanReadableMonth(startsAtDate); + + const startsAtDay = formatToHumanReadableDay(startsAtDate); + + const startsAtHour = formatToHumanReadableTime(startsAtDate); + const endsAtHour = endsAtDate ? formatToHumanReadableTime(endsAtDate) : null; + + return ( + openCalendarEventRightDrawer(calendarEvent.id)} + > + + + {startsAtMonth} + + + {startsAtDay} + + + + + + {calendarEvent.title} + + + + {startsAtHour} {endsAtHour && <>→ {endsAtHour}} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventRowCalendarEvent.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventRowCalendarEvent.tsx new file mode 100644 index 000000000..53447661f --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventRowCalendarEvent.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import styled from '@emotion/styled'; + +import { EventCardCalendarEvent } from '@/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent'; +import { + EventCard, + EventCardToggleButton, +} from '@/activities/timelineActivities/rows/components/EventCard'; +import { + EventRowDynamicComponentProps, + StyledItemAction, + StyledItemAuthorText, +} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; + +type EventRowCalendarEventProps = EventRowDynamicComponentProps; + +const StyledEventRowCalendarEventContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledRowContainer = styled.div` + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +export const EventRowCalendarEvent: React.FC = ({ + event, + authorFullName, + labelIdentifierValue, +}: EventRowCalendarEventProps) => { + const [, eventAction] = event.name.split('.'); + const [isOpen, setIsOpen] = useState(false); + + const renderRow = () => { + switch (eventAction) { + case 'linked': { + return ( + + linked a calendar event with {labelIdentifierValue} + + ); + } + default: + throw new Error('Invalid event action for calendarEvent event type.'); + } + }; + return ( + + + {authorFullName} + {renderRow()} + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventCard.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventCard.tsx new file mode 100644 index 000000000..b802c3047 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventCard.tsx @@ -0,0 +1,70 @@ +import styled from '@emotion/styled'; +import { IconChevronDown, IconChevronUp } from 'twenty-ui'; + +import { IconButton } from '@/ui/input/button/components/IconButton'; +import { Card } from '@/ui/layout/card/components/Card'; + +type EventCardProps = { + children: React.ReactNode; + isOpen: boolean; +}; + +type EventCardToggleButtonProps = { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; +}; + +const StyledButtonContainer = styled.div` + border-radius: ${({ theme }) => theme.border.radius.sm}; +`; + +const StyledCardContainer = styled.div` + align-items: flex-start; + display: flex; + flex-direction: column; + flex-grow: 1; + gap: ${({ theme }) => theme.spacing(2)}; + width: 400px; + padding: ${({ theme }) => theme.spacing(2)} 0px + ${({ theme }) => theme.spacing(4)} 0px; +`; + +const StyledCard = styled(Card)` + background: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + + display: flex; + padding: ${({ theme }) => theme.spacing(2)}; + flex-direction: column; + align-items: flex-start; + gap: ${({ theme }) => theme.spacing(2)}; + justify-content: center; + align-self: stretch; +`; + +export const EventCard = ({ children, isOpen }: EventCardProps) => { + return ( + isOpen && ( + + {children} + + ) + ); +}; + +export const EventCardToggleButton = ({ + isOpen, + setIsOpen, +}: EventCardToggleButtonProps) => { + return ( + + setIsOpen(!isOpen)} + size="small" + variant="secondary" + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventRowDynamicComponent.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventRowDynamicComponent.tsx new file mode 100644 index 000000000..0c51cdf65 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventRowDynamicComponent.tsx @@ -0,0 +1,102 @@ +import styled from '@emotion/styled'; +import { IconCirclePlus, IconEditCircle, useIcons } from 'twenty-ui'; + +import { EventRowActivity } from '@/activities/timelineActivities/rows/activity/components/EventRowActivity'; +import { EventRowCalendarEvent } from '@/activities/timelineActivities/rows/calendar/components/EventRowCalendarEvent'; +import { EventRowMainObject } from '@/activities/timelineActivities/rows/mainObject/components/EventRowMainObject'; +import { EventRowMessage } from '@/activities/timelineActivities/rows/message/components/EventRowMessage'; +import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isDefined } from '~/utils/isDefined'; + +export interface EventRowDynamicComponentProps { + labelIdentifierValue: string; + event: TimelineActivity; + mainObjectMetadataItem: ObjectMetadataItem; + linkedObjectMetadataItem: ObjectMetadataItem | null; + authorFullName: string; +} + +const StyledItemColumn = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.primary}; + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +export const StyledItemAuthorText = styled(StyledItemColumn)``; + +export const StyledItemLabelIdentifier = styled(StyledItemColumn)``; + +export const StyledItemAction = styled(StyledItemColumn)` + color: ${({ theme }) => theme.font.color.secondary}; +`; + +const eventRowComponentMap: { + [key: string]: React.FC; +} = { + calendarEvent: EventRowCalendarEvent, + message: EventRowMessage, + task: EventRowActivity, + note: EventRowActivity, +}; + +export const EventRowDynamicComponent = ({ + labelIdentifierValue, + event, + mainObjectMetadataItem, + linkedObjectMetadataItem, + authorFullName, +}: EventRowDynamicComponentProps) => { + const [eventName] = event.name.split('.'); + const EventRowComponent = eventRowComponentMap[eventName]; + + if (isDefined(EventRowComponent)) { + return ( + + ); + } + + if (eventName === mainObjectMetadataItem?.nameSingular) { + return ( + + ); + } + + throw new Error(`Cannot find event component for event name ${eventName}`); +}; + +export const EventIconDynamicComponent = ({ + event, + linkedObjectMetadataItem, +}: { + event: TimelineActivity; + linkedObjectMetadataItem: ObjectMetadataItem | null; +}) => { + const { getIcon } = useIcons(); + const [, eventAction] = event.name.split('.'); + + if (eventAction === 'created') { + return ; + } + if (eventAction === 'updated') { + return ; + } + + const IconComponent = getIcon(linkedObjectMetadataItem?.icon); + + return ; +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiff.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiff.tsx new file mode 100644 index 000000000..d948b1c4c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiff.tsx @@ -0,0 +1,51 @@ +import styled from '@emotion/styled'; + +import { EventFieldDiffLabel } from '@/activities/timelineActivities/rows/mainObject/components/EventFieldDiffLabel'; +import { EventFieldDiffValue } from '@/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValue'; +import { EventFieldDiffValueEffect } from '@/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValueEffect'; +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +type EventFieldDiffProps = { + diffRecord: Record; + mainObjectMetadataItem: ObjectMetadataItem; + fieldMetadataItem: FieldMetadataItem | undefined; + forgedRecordId: string; +}; + +const StyledEventFieldDiffContainer = styled.div` + align-items: center; + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(1)}; + height: 24px; + width: 250px; +`; + +export const EventFieldDiff = ({ + diffRecord, + mainObjectMetadataItem, + fieldMetadataItem, + forgedRecordId, +}: EventFieldDiffProps) => { + if (!fieldMetadataItem) { + return null; + } + + return ( + + → + + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffLabel.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffLabel.tsx new file mode 100644 index 000000000..9ee7393ef --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffLabel.tsx @@ -0,0 +1,46 @@ +import styled from '@emotion/styled'; +import { Icon123, useIcons } from 'twenty-ui'; + +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; + +type EventFieldDiffLabelProps = { + fieldMetadataItem: FieldMetadataItem; +}; + +const StyledUpdatedFieldContainer = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledUpdatedFieldIconContainer = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + flex-direction: row; + height: 14px; + width: 14px; +`; + +const StyledUpdatedFieldLabel = styled.div``; + +export const EventFieldDiffLabel = ({ + fieldMetadataItem, +}: EventFieldDiffLabelProps) => { + const { getIcon } = useIcons(); + + const IconComponent = fieldMetadataItem?.icon + ? getIcon(fieldMetadataItem?.icon) + : Icon123; + + return ( + + + + + + {fieldMetadataItem.label} + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValue.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValue.tsx new file mode 100644 index 000000000..e37cbdec3 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValue.tsx @@ -0,0 +1,53 @@ +import styled from '@emotion/styled'; + +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; +import { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay'; +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; + +type EventFieldDiffValueProps = { + forgedRecordId: string; + mainObjectMetadataItem: ObjectMetadataItem; + fieldMetadataItem: FieldMetadataItem; +}; + +const StyledEventFieldDiffValue = styled.div` + align-items: center; + display: flex; +`; + +export const EventFieldDiffValue = ({ + forgedRecordId, + mainObjectMetadataItem, + fieldMetadataItem, +}: EventFieldDiffValueProps) => { + return ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValueEffect.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValueEffect.tsx new file mode 100644 index 000000000..fac5a4474 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventFieldDiffValueEffect.tsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { useSetRecoilState } from 'recoil'; + +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; + +export const EventFieldDiffValueEffect = ({ + forgedRecordId, + diffRecord, + mainObjectMetadataItem, + fieldMetadataItem, +}: { + forgedRecordId: string; + diffRecord: Record | undefined; + mainObjectMetadataItem: ObjectMetadataItem; + fieldMetadataItem: FieldMetadataItem; +}) => { + const setEntityFields = useSetRecoilState( + recordStoreFamilyState(forgedRecordId), + ); + + useEffect(() => { + if (!diffRecord) return; + + const forgedObjectRecord = { + __typename: mainObjectMetadataItem.nameSingular, + id: forgedRecordId, + [fieldMetadataItem.name]: diffRecord, + }; + + setEntityFields(forgedObjectRecord); + }, [ + diffRecord, + forgedRecordId, + fieldMetadataItem.name, + mainObjectMetadataItem.nameSingular, + setEntityFields, + ]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventRowMainObject.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventRowMainObject.tsx new file mode 100644 index 000000000..f2aa06509 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventRowMainObject.tsx @@ -0,0 +1,52 @@ +import styled from '@emotion/styled'; + +import { + EventRowDynamicComponentProps, + StyledItemAction, + StyledItemAuthorText, + StyledItemLabelIdentifier, +} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; +import { EventRowMainObjectUpdated } from '@/activities/timelineActivities/rows/mainObject/components/EventRowMainObjectUpdated'; + +type EventRowMainObjectProps = EventRowDynamicComponentProps; + +const StyledMainContainer = styled.div` + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +export const EventRowMainObject = ({ + authorFullName, + labelIdentifierValue, + event, + mainObjectMetadataItem, +}: EventRowMainObjectProps) => { + const [, eventAction] = event.name.split('.'); + + switch (eventAction) { + case 'created': { + return ( + + + {labelIdentifierValue} + + was created by + {authorFullName} + + ); + } + case 'updated': { + return ( + + ); + } + default: + return null; + } +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventRowMainObjectUpdated.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventRowMainObjectUpdated.tsx new file mode 100644 index 000000000..435a834bb --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/mainObject/components/EventRowMainObjectUpdated.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import styled from '@emotion/styled'; + +import { + EventCard, + EventCardToggleButton, +} from '@/activities/timelineActivities/rows/components/EventCard'; +import { + StyledItemAction, + StyledItemAuthorText, +} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; +import { EventFieldDiff } from '@/activities/timelineActivities/rows/mainObject/components/EventFieldDiff'; +import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +type EventRowMainObjectUpdatedProps = { + mainObjectMetadataItem: ObjectMetadataItem; + authorFullName: string; + labelIdentifierValue: string; + event: TimelineActivity; +}; + +const StyledRowContainer = styled.div` + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledEventRowMainObjectUpdatedContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const renderUpdateDescription = ( + mainObjectMetadataItem: ObjectMetadataItem, + diffKey: string, + diffValue: any, + eventId: string, + fieldMetadataItemMap: Record, +) => { + const fieldMetadataItem = fieldMetadataItemMap[diffKey]; + + if (!fieldMetadataItem) { + throw new Error( + `Cannot find field metadata item for field name ${diffKey} on object ${mainObjectMetadataItem.nameSingular}`, + ); + } + + const forgedRecordId = eventId + '--' + fieldMetadataItem.id; + + return ( + + ); +}; + +export const EventRowMainObjectUpdated = ({ + authorFullName, + labelIdentifierValue, + event, + mainObjectMetadataItem, +}: EventRowMainObjectUpdatedProps) => { + const diff: Record = + event.properties?.diff; + + const [isOpen, setIsOpen] = useState(true); + + const fieldMetadataItemMap: Record = + mainObjectMetadataItem.fields.reduce( + (acc, field) => ({ ...acc, [field.name]: field }), + {}, + ); + + const diffEntries = Object.entries(diff); + if (diffEntries.length === 0) { + throw new Error('Cannot render update description without changes'); + } + + return ( + + + {authorFullName} + + updated + {diffEntries.length === 1 && + renderUpdateDescription( + mainObjectMetadataItem, + diffEntries[0][0], + diffEntries[0][1].after, + event.id, + fieldMetadataItemMap, + )} + {diffEntries.length > 1 && ( + <> + + {diffEntries.length} fields on {labelIdentifierValue} + + + + )} + + + {diffEntries.length > 1 && ( + + {diffEntries.map(([diffKey, diffValue]) => + renderUpdateDescription( + mainObjectMetadataItem, + diffKey, + diffValue.after, + event.id, + fieldMetadataItemMap, + ), + )} + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessage.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessage.tsx new file mode 100644 index 000000000..4f85b7463 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessage.tsx @@ -0,0 +1,127 @@ +import styled from '@emotion/styled'; +import { isUndefined } from '@sniptt/guards'; +import { OverflowingTextWithTooltip } from 'twenty-ui'; + +import { useEmailThread } from '@/activities/emails/hooks/useEmailThread'; +import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage'; +import { EventCardMessageNotShared } from '@/activities/timelineActivities/rows/message/components/EventCardMessageNotShared'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { isDefined } from '~/utils/isDefined'; + +const StyledEventCardMessageContainer = styled.div` + display: flex; + flex-direction: column; +`; + +const StyledEmailContent = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; + justify-content: center; +`; + +const StyledEmailTop = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledEmailTitle = styled.div` + color: ${({ theme }) => theme.font.color.primary}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + display: flex; +`; + +const StyledEmailParticipants = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + font-size: ${({ theme }) => theme.font.size.sm}; +`; + +const StyledEmailBody = styled.div` + cursor: pointer; + display: flex; +`; + +export const EventCardMessage = ({ + messageId, + authorFullName, +}: { + messageId: string; + authorFullName: string; +}) => { + const { setRecords } = useSetRecordInStore(); + + const { + record: message, + loading, + error, + } = useFindOneRecord({ + objectNameSingular: CoreObjectNameSingular.Message, + objectRecordId: messageId, + recordGqlFields: { + text: true, + subject: true, + direction: true, + messageThreadId: true, + messageParticipants: { + handle: true, + }, + }, + onCompleted: (data) => { + setRecords([data]); + }, + }); + + const { openEmailThread } = useEmailThread(); + + if (isDefined(error)) { + const shouldHideMessageContent = error.graphQLErrors.some( + (e) => e.extensions?.code === 'FORBIDDEN', + ); + + if (shouldHideMessageContent) { + return ; + } + + const shouldHandleNotFound = error.graphQLErrors.some( + (e) => e.extensions?.code === 'NOT_FOUND', + ); + + if (shouldHandleNotFound) { + return
Message not found
; + } + + return
Error loading message
; + } + + if (loading || isUndefined(message)) { + return
Loading...
; + } + + const messageParticipantHandles = message?.messageParticipants + .map((participant) => participant.handle) + .join(', '); + + return ( + + + + +
{message.subject}
+
+ + + +
+ openEmailThread(message.messageThreadId)} + > + {message.text} + +
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessageNotShared.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessageNotShared.tsx new file mode 100644 index 000000000..93106c4fd --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessageNotShared.tsx @@ -0,0 +1,86 @@ +import styled from '@emotion/styled'; +import { IconLock } from 'twenty-ui'; + +const StyledEventCardMessageContainer = styled.div` + display: flex; + flex-direction: column; +`; + +const StyledEmailContent = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; + justify-content: center; +`; + +const StyledEmailTop = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledEmailTitle = styled.div` + color: ${({ theme }) => theme.font.color.primary}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + display: flex; +`; + +const StyledEmailBodyNotShareContainer = styled.div` + align-items: center; + align-self: stretch; + background: ${({ theme }) => theme.background.transparent.lighter}; + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.spacing(1)}; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(3)}; + + height: 80px; + justify-content: center; + padding: 0 ${({ theme }) => theme.spacing(1)}; + + color: ${({ theme }) => theme.font.color.light}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +const StyledEmailBodyNotSharedIconContainer = styled.div` + display: flex; + width: 14px; + height: 14px; + justify-content: center; + align-items: center; +`; + +const StyledEmailBodyNotShare = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + padding: 0 ${({ theme }) => theme.spacing(1)}; +`; + +export const EventCardMessageNotShared = ({ + sharedByFullName, +}: { + sharedByFullName: string; +}) => { + return ( + + + + + Subject not shared + + + + + + + + Not shared by {sharedByFullName} + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventRowMessage.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventRowMessage.tsx new file mode 100644 index 000000000..95e34fa8c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventRowMessage.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import styled from '@emotion/styled'; + +import { + EventCard, + EventCardToggleButton, +} from '@/activities/timelineActivities/rows/components/EventCard'; +import { + EventRowDynamicComponentProps, + StyledItemAction, + StyledItemAuthorText, + StyledItemLabelIdentifier, +} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; +import { EventCardMessage } from '@/activities/timelineActivities/rows/message/components/EventCardMessage'; + +type EventRowMessageProps = EventRowDynamicComponentProps; + +const StyledEventRowMessageContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledRowContainer = styled.div` + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +export const EventRowMessage: React.FC = ({ + labelIdentifierValue, + event, + authorFullName, +}: EventRowMessageProps) => { + const [, eventAction] = event.name.split('.'); + const [isOpen, setIsOpen] = useState(false); + + const renderRow = () => { + switch (eventAction) { + case 'linked': { + return ( + <> + {authorFullName} + linked an email with + + {labelIdentifierValue} + + + ); + } + default: + throw new Error('Invalid event action for message event type.'); + } + }; + + return ( + + + {renderRow()} + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/groupEventsByMonth.test.ts b/packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/groupEventsByMonth.test.ts index b88b89c1d..f4f9a082c 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/groupEventsByMonth.test.ts +++ b/packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/groupEventsByMonth.test.ts @@ -7,13 +7,13 @@ describe('groupEventsByMonth', () => { const grouped = groupEventsByMonth(mockedTimelineActivities); expect(grouped).toHaveLength(2); - expect(grouped[0].items).toHaveLength(1); + expect(grouped[0].items).toHaveLength(4); expect(grouped[1].items).toHaveLength(1); - expect(grouped[0].year).toBe(new Date().getFullYear()); - expect(grouped[1].year).toBe(2023); + expect(grouped[0].year).toBe(2023); + expect(grouped[1].year).toBe(2022); - expect(grouped[0].month).toBe(new Date().getMonth()); - expect(grouped[1].month).toBe(3); + expect(grouped[0].month).toBe(3); + expect(grouped[1].month).toBe(4); }); }); diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts index c471ab583..4af6c5b8f 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts @@ -2,10 +2,13 @@ import { createState } from 'twenty-ui'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; -export const currentWorkspaceMemberState = createState | null>({ - key: 'currentWorkspaceMemberState', - defaultValue: null, -}); +>; + +export const currentWorkspaceMemberState = + createState({ + key: 'currentWorkspaceMemberState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts index dc07a0932..f4baef7cb 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts @@ -3618,6 +3618,42 @@ export const getObjectMetadataItemsMock = () => { }, ], }, + { + __typename: 'object', + id: '20202020-049d-4d0c-9e7c-e74fee3f88b2', + dataSourceId: '20202020-7f63-47a9-b1b3-6c7290ca9fb1', + nameSingular: 'messageThread', + namePlural: 'messageThreads', + labelSingular: 'Message Thread', + labelPlural: 'Message Threads', + description: 'A webhook', + icon: 'IconMessage', + isCustom: false, + isRemote: false, + isActive: true, + isSystem: true, + createdAt: '2023-11-30T11:13:15.206Z', + updatedAt: '2023-11-30T11:13:15.206Z', + fields: [], + }, + { + __typename: 'object', + id: '20202020-049d-4d0c-9e7c-e74fee3f88b2', + dataSourceId: '20202020-7f63-47a9-b1b3-6c7290ca9fb1', + nameSingular: 'timelineActivity', + namePlural: 'timelineActivities', + labelSingular: 'Timeline Activitiy', + labelPlural: 'Timeline Activities', + description: 'A webhook', + icon: 'IconIconTimelineEvent', + isCustom: false, + isRemote: false, + isActive: true, + isSystem: true, + createdAt: '2023-11-30T11:13:15.206Z', + updatedAt: '2023-11-30T11:13:15.206Z', + fields: [], + }, ]; // Todo fix typing here (the backend is not in sync with the frontend) 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 153d2d1ac..1ccef018b 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 @@ -18,6 +18,7 @@ import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks'; import { Timeline } from '@/activities/timeline/components/Timeline'; import { TimelineQueryEffect } from '@/activities/timeline/components/TimelineQueryEffect'; import { TimelineActivities } from '@/activities/timelineActivities/components/TimelineActivities'; +import { TimelineActivitiesQueryEffect } from '@/activities/timelineActivities/components/TimelineActivitiesQueryEffect'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { TabList } from '@/ui/layout/tab/components/TabList'; @@ -74,22 +75,21 @@ export const ShowPageRightContainer = ({ ); const activeTabId = useRecoilValue(activeTabIdState); - const shouldDisplayCalendarTab = - targetableObject.targetObjectNameSingular === - CoreObjectNameSingular.Company || - targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person; + const targetObjectNameSingular = + targetableObject.targetObjectNameSingular as CoreObjectNameSingular; + const isCompanyOrPerson = [ + CoreObjectNameSingular.Company, + CoreObjectNameSingular.Person, + ].includes(targetObjectNameSingular); + + const shouldDisplayCalendarTab = isCompanyOrPerson; const shouldDisplayLogTab = useIsFeatureEnabled('IS_EVENT_OBJECT_ENABLED'); - - const shouldDisplayEmailsTab = - (emails && - targetableObject.targetObjectNameSingular === - CoreObjectNameSingular.Company) || - targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person; + const shouldDisplayEmailsTab = emails && isCompanyOrPerson; const isMobile = useIsMobile() || isRightDrawer; - const TASK_TABS = [ + const tabs = [ { id: 'summary', title: 'Summary', @@ -102,24 +102,9 @@ export const ShowPageRightContainer = ({ Icon: IconTimelineEvent, hide: !timeline || isRightDrawer, }, - { - id: 'tasks', - title: 'Tasks', - Icon: IconCheckbox, - hide: !tasks, - }, - { - id: 'notes', - title: 'Notes', - Icon: IconNotes, - hide: !notes, - }, - { - id: 'files', - title: 'Files', - Icon: IconPaperclip, - hide: !notes, - }, + { id: 'tasks', title: 'Tasks', Icon: IconCheckbox, hide: !tasks }, + { id: 'notes', title: 'Notes', Icon: IconNotes, hide: !notes }, + { id: 'files', title: 'Files', Icon: IconPaperclip, hide: !notes }, { id: 'emails', title: 'Emails', @@ -132,48 +117,51 @@ export const ShowPageRightContainer = ({ Icon: IconCalendarEvent, hide: !shouldDisplayCalendarTab, }, - { - id: 'logs', - title: 'Logs', - Icon: IconTimelineEvent, - hide: !shouldDisplayLogTab, - hasBetaPill: true, - }, ]; + const renderActiveTabContent = () => { + switch (activeTabId) { + case 'timeline': + return shouldDisplayLogTab ? ( + <> + + + + ) : ( + <> + + + + ); + case 'summary': + return summary; + case 'tasks': + return ; + case 'notes': + return ; + case 'files': + return ; + case 'emails': + return ; + case 'calendar': + return ; + default: + return <>; + } + }; + return ( - {activeTabId === 'summary' && summary} - {activeTabId === 'timeline' && ( - <> - - - - )} - {activeTabId === 'tasks' && ( - - )} - {activeTabId === 'notes' && } - {activeTabId === 'files' && ( - - )} - {activeTabId === 'emails' && ( - - )} - {activeTabId === 'calendar' && ( - - )} - {activeTabId === 'logs' && ( - - )} - {} + {renderActiveTabContent()} ); }; diff --git a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx index 215d51973..e8d5bd902 100644 --- a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx @@ -1,5 +1,6 @@ import { useParams } from 'react-router-dom'; +import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext'; import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer'; import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage'; import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; @@ -65,11 +66,17 @@ export const RecordShowPage = () => { - + + + diff --git a/packages/twenty-front/src/testing/mock-data/timeline-activities.ts b/packages/twenty-front/src/testing/mock-data/timeline-activities.ts index 14cb0e5b7..88e272a39 100644 --- a/packages/twenty-front/src/testing/mock-data/timeline-activities.ts +++ b/packages/twenty-front/src/testing/mock-data/timeline-activities.ts @@ -2,13 +2,13 @@ import { TimelineActivity } from '@/activities/timelineActivities/types/Timeline export const mockedTimelineActivities: Array = [ { - properties: '{"diff": {"address": {"after": "TEST", "before": ""}}}', + properties: null, updatedAt: '2023-04-26T10:12:42.33625+00:00', id: '79f84835-b2f9-4ab5-8ab9-17dbcc45dda3', linkedObjectMetadataId: '1ad72a42-6ab4-4474-a62a-a57cae3c0298', - linkedRecordCachedName: 'Test', - linkedRecordId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d0', - name: 'updated.company', + linkedRecordCachedName: '', + linkedRecordId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d3', + name: 'calendarEvent.linked', createdAt: '2023-04-26T10:12:42.33625+00:00', workspaceMember: { __typename: 'WorkspaceMember', @@ -27,15 +27,97 @@ export const mockedTimelineActivities: Array = [ __typename: 'TimelineActivity', }, { - properties: - '{"after": {"id": "ce40eca0-8f4b-4bba-ba91-5cbd870c64d0", "name": "", "xLink": {"url": "", "label": ""}, "events": {"edges": [], "__typename": "eventConnection"}, "people": {"edges": [], "__typename": "personConnection"}, "address": "", "position": 0.5, "createdAt": "2024-03-24T21:33:45.765295", "employees": null, "favorites": {"edges": [], "__typename": "favoriteConnection"}, "updatedAt": "2024-03-24T21:33:45.765295", "__typename": "company", "domainName": "", "attachments": {"edges": [], "__typename": "attachmentConnection"}, "accountOwner": null, "linkedinLink": {"url": "", "label": ""}, "opportunities": {"edges": [], "__typename": "opportunityConnection"}, "accountOwnerId": null, "activityTargets": {"edges": [], "__typename": "activityTargetConnection"}, "idealCustomerProfile": false, "annualRecurringRevenue": {"amountMicros": null, "currencyCode": ""}}}', - updatedAt: new Date().toISOString(), - id: '1ad72a42-6ab4-4474-a62a-a57cae3c0298', - name: 'created.company', + properties: null, + updatedAt: '2023-04-26T10:12:42.33625+00:00', + id: '79f84835-b2f9-4ab5-8ab9-17dbcc45dda3', + linkedObjectMetadataId: '1ad72a42-6ab4-4474-a62a-a57cae3c0298', + linkedRecordCachedName: '', + linkedRecordId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d5', + name: 'message.linked', + createdAt: '2023-04-26T10:12:42.33625+00:00', + workspaceMember: { + __typename: 'WorkspaceMember', + id: '20202020-0687-4c41-b707-ed1bfca972a7', + avatarUrl: '', + locale: 'en', + name: { + __typename: 'FullName', + firstName: 'Tim', + lastName: 'Apple', + }, + colorScheme: 'Light', + }, + workspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7', + deletedAt: null, + __typename: 'TimelineActivity', + }, + { + properties: null, + updatedAt: '2023-04-26T10:12:42.33625+00:00', + id: '79f84835-b2f9-4ab5-8ab9-17dbcc45dda3', + linkedObjectMetadataId: '1ad72a42-6ab4-4474-a62a-a57cae3c0298', + linkedRecordCachedName: 'New Task', + linkedRecordId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d2', + name: 'task.created', + createdAt: '2023-04-26T10:12:42.33625+00:00', + workspaceMember: { + __typename: 'WorkspaceMember', + id: '20202020-0687-4c41-b707-ed1bfca972a7', + avatarUrl: '', + locale: 'en', + name: { + __typename: 'FullName', + firstName: 'Tim', + lastName: 'Apple', + }, + colorScheme: 'Light', + }, + workspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7', + deletedAt: null, + __typename: 'TimelineActivity', + }, + { + properties: { + diff: { + address: { + after: 'TEST', + before: '', + }, + }, + }, + updatedAt: '2023-04-26T10:12:42.33625+00:00', + id: '79f84835-b2f9-4ab5-8ab9-17dbcc45dda3', linkedObjectMetadataId: '1ad72a42-6ab4-4474-a62a-a57cae3c0298', linkedRecordCachedName: 'Test', linkedRecordId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d0', - createdAt: new Date().toISOString(), + name: 'company.updated', + createdAt: '2023-04-26T10:12:42.33625+00:00', + workspaceMember: { + __typename: 'WorkspaceMember', + id: '20202020-1553-45c6-a028-5a9064cce07f', + avatarUrl: '', + locale: 'en', + name: { + __typename: 'FullName', + firstName: 'Jane', + lastName: 'Doe', + }, + colorScheme: 'Light', + }, + workspaceMemberId: '20202020-1553-45c6-a028-5a9064cce07f', + deletedAt: null, + __typename: 'TimelineActivity', + }, + { + properties: + '{"after": {"id": "ce40eca0-8f4b-4bba-ba91-5cbd870c64d0", "name": "", "xLink": {"url": "", "label": ""}, "events": {"edges": [], "__typename": "eventConnection"}, "people": {"edges": [], "__typename": "personConnection"}, "address": "", "position": 0.5, "createdAt": "2024-03-24T21:33:45.765295", "employees": null, "favorites": {"edges": [], "__typename": "favoriteConnection"}, "updatedAt": "2024-03-24T21:33:45.765295", "__typename": "company", "domainName": "", "attachments": {"edges": [], "__typename": "attachmentConnection"}, "accountOwner": null, "linkedinLink": {"url": "", "label": ""}, "opportunities": {"edges": [], "__typename": "opportunityConnection"}, "accountOwnerId": null, "activityTargets": {"edges": [], "__typename": "activityTargetConnection"}, "idealCustomerProfile": false, "annualRecurringRevenue": {"amountMicros": null, "currencyCode": ""}}}', + updatedAt: '2023-05-26T10:12:42.33625+00:00', + id: '1ad72a42-6ab4-4474-a62a-a57cae3c0298', + name: 'company.created', + linkedObjectMetadataId: '1ad72a42-6ab4-4474-a62a-a57cae3c0298', + linkedRecordCachedName: 'Test', + linkedRecordId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d0', + createdAt: '2022-05-26T10:12:42.33625+00:00', workspaceMember: { __typename: 'WorkspaceMember', id: '20202020-0687-4c41-b707-ed1bfca972a7', diff --git a/packages/twenty-front/src/utils/index.ts b/packages/twenty-front/src/utils/index.ts index 9edcbb6b9..25015d069 100644 --- a/packages/twenty-front/src/utils/index.ts +++ b/packages/twenty-front/src/utils/index.ts @@ -22,6 +22,31 @@ export const formatToHumanReadableDateTime = (date: Date | string) => { }).format(parsedJSDate); }; +export const formatToHumanReadableMonth = (date: Date | string) => { + const parsedJSDate = parseDate(date).toJSDate(); + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + }).format(parsedJSDate); +}; + +export const formatToHumanReadableDay = (date: Date | string) => { + const parsedJSDate = parseDate(date).toJSDate(); + + return new Intl.DateTimeFormat(undefined, { + day: 'numeric', + }).format(parsedJSDate); +}; + +export const formatToHumanReadableTime = (date: Date | string) => { + const parsedJSDate = parseDate(date).toJSDate(); + + return new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', + }).format(parsedJSDate); +}; + export const sanitizeURL = (link: string | null | undefined) => { return link ? link.replace(/(https?:\/\/)|(www\.)/g, '').replace(/\/$/, '') diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index 1266723b5..3a36ad753 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -33,7 +33,7 @@ export const seedFeatureFlags = async ( { key: FeatureFlagKeys.IsEventObjectEnabled, workspaceId: workspaceId, - value: true, + value: false, }, { key: FeatureFlagKeys.IsStripeIntegrationEnabled, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts index 6ac775c85..663932ad9 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/listeners/entity-events-to-db.listener.ts @@ -7,7 +7,6 @@ import { Repository } from 'typeorm'; import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service'; import { ObjectRecordCreateEvent } from 'src/engine/integrations/event-emitter/types/object-record-create.event'; -import { CreateAuditLogFromInternalEvent } from 'src/modules/timeline/jobs/create-audit-log-from-internal-event'; import { FeatureFlagEntity, FeatureFlagKeys, @@ -16,6 +15,7 @@ import { objectRecordChangedValues } from 'src/engine/integrations/event-emitter import { ObjectRecordUpdateEvent } from 'src/engine/integrations/event-emitter/types/object-record-update.event'; import { ObjectRecordBaseEvent } from 'src/engine/integrations/event-emitter/types/object-record.base.event'; import { UpsertTimelineActivityFromInternalEvent } from 'src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job'; +import { CreateAuditLogFromInternalEvent } from 'src/modules/timeline/jobs/create-audit-log-from-internal-event'; @Injectable() export class EntityEventsToDbListener { @@ -48,7 +48,7 @@ export class EntityEventsToDbListener { // @OnEvent('*.restored') - TODO: implement when we soft delete has been implemented // .... - private async handle(payload: ObjectRecordCreateEvent) { + private async handle(payload: ObjectRecordBaseEvent) { if (!payload.objectMetadata.isAuditLogged) { return; } diff --git a/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts index e2223913e..5447402d6 100644 --- a/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts @@ -23,8 +23,9 @@ import { CalendarMessagingParticipantJobModule } from 'src/modules/calendar-mess import { CalendarCronJobModule } from 'src/modules/calendar/crons/jobs/calendar-cron-job.module'; import { CalendarJobModule } from 'src/modules/calendar/jobs/calendar-job.module'; import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/jobs/auto-companies-and-contacts-creation-job.module'; -import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module'; +import { CalendarModule } from 'src/modules/calendar/calendar.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module'; +import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module'; @Module({ imports: [ @@ -41,6 +42,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module'; CalendarEventParticipantModule, TimelineActivityModule, StripeModule, + CalendarModule, // JobsModules WorkspaceQueryRunnerJobModule, CalendarMessagingParticipantJobModule, diff --git a/packages/twenty-server/src/modules/activity/standard-objects/activity-target.workspace-entity.ts b/packages/twenty-server/src/modules/activity/standard-objects/activity-target.workspace-entity.ts index f2b03bec7..c99325d13 100644 --- a/packages/twenty-server/src/modules/activity/standard-objects/activity-target.workspace-entity.ts +++ b/packages/twenty-server/src/modules/activity/standard-objects/activity-target.workspace-entity.ts @@ -9,7 +9,6 @@ import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-obj import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; -import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { WorkspaceDynamicRelation } from 'src/engine/twenty-orm/decorators/workspace-dynamic-relation.decorator'; import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; @@ -25,7 +24,6 @@ import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity icon: 'IconCheckbox', }) @WorkspaceIsSystem() -@WorkspaceIsNotAuditLogged() export class ActivityTargetWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceRelation({ standardId: ACTIVITY_TARGET_STANDARD_FIELD_IDS.activity, diff --git a/packages/twenty-server/src/modules/activity/standard-objects/activity.workspace-entity.ts b/packages/twenty-server/src/modules/activity/standard-objects/activity.workspace-entity.ts index aa13ddea2..8c6460eb4 100644 --- a/packages/twenty-server/src/modules/activity/standard-objects/activity.workspace-entity.ts +++ b/packages/twenty-server/src/modules/activity/standard-objects/activity.workspace-entity.ts @@ -12,7 +12,6 @@ import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objec import { CommentWorkspaceEntity } from 'src/modules/activity/standard-objects/comment.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; -import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; @@ -27,7 +26,6 @@ import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity description: 'An activity', icon: 'IconCheckbox', }) -@WorkspaceIsNotAuditLogged() @WorkspaceIsSystem() export class ActivityWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceField({ diff --git a/packages/twenty-server/src/modules/activity/standard-objects/comment.workspace-entity.ts b/packages/twenty-server/src/modules/activity/standard-objects/comment.workspace-entity.ts index 2392857f4..b198e36da 100644 --- a/packages/twenty-server/src/modules/activity/standard-objects/comment.workspace-entity.ts +++ b/packages/twenty-server/src/modules/activity/standard-objects/comment.workspace-entity.ts @@ -7,7 +7,6 @@ import { ActivityWorkspaceEntity } from 'src/modules/activity/standard-objects/a import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; -import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; @@ -22,7 +21,6 @@ import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity icon: 'IconMessageCircle', }) @WorkspaceIsSystem() -@WorkspaceIsNotAuditLogged() export class CommentWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceField({ standardId: COMMENT_STANDARD_FIELD_IDS.body, diff --git a/packages/twenty-server/src/modules/calendar/calendar.module.ts b/packages/twenty-server/src/modules/calendar/calendar.module.ts index 5e7b314e1..d6a63b5a2 100644 --- a/packages/twenty-server/src/modules/calendar/calendar.module.ts +++ b/packages/twenty-server/src/modules/calendar/calendar.module.ts @@ -1,11 +1,27 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { CalendarBlocklistListener } from 'src/modules/calendar/listeners/calendar-blocklist.listener'; import { CalendarChannelListener } from 'src/modules/calendar/listeners/calendar-channel.listener'; +import { CalendarEventParticipantListener } from 'src/modules/calendar/listeners/calendar-event-participant.listener'; +import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; @Module({ - imports: [], - providers: [CalendarChannelListener, CalendarBlocklistListener], + imports: [ + WorkspaceDataSourceModule, + ObjectMetadataRepositoryModule.forFeature([ + TimelineActivityWorkspaceEntity, + ]), + TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'), + ], + providers: [ + CalendarChannelListener, + CalendarBlocklistListener, + CalendarEventParticipantListener, + ], exports: [], }) export class CalendarModule {} diff --git a/packages/twenty-server/src/modules/calendar/jobs/calendar-create-company-and-contact-after-sync.job.ts b/packages/twenty-server/src/modules/calendar/jobs/calendar-create-company-and-contact-after-sync.job.ts index 2e9d9d238..3c1b9046b 100644 --- a/packages/twenty-server/src/modules/calendar/jobs/calendar-create-company-and-contact-after-sync.job.ts +++ b/packages/twenty-server/src/modules/calendar/jobs/calendar-create-company-and-contact-after-sync.job.ts @@ -8,6 +8,8 @@ import { CalendarEventParticipantRepository } from 'src/modules/calendar/reposit import { CalendarChannelWorkspaceEntity } from 'src/modules/calendar/standard-objects/calendar-channel.workspace-entity'; import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/standard-objects/calendar-event-participant.workspace-entity'; import { CreateCompanyAndContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service'; +import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; export type CalendarCreateCompanyAndContactAfterSyncJobData = { workspaceId: string; @@ -27,6 +29,8 @@ export class CalendarCreateCompanyAndContactAfterSyncJob private readonly calendarChannelService: CalendarChannelRepository, @InjectObjectMetadataRepository(CalendarEventParticipantWorkspaceEntity) private readonly calendarEventParticipantRepository: CalendarEventParticipantRepository, + @InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity) + private readonly connectedAccountRepository: ConnectedAccountRepository, ) {} async handle( @@ -48,12 +52,24 @@ export class CalendarCreateCompanyAndContactAfterSyncJob ); } - const { handle, isContactAutoCreationEnabled } = calendarChannels[0]; + const { handle, isContactAutoCreationEnabled, connectedAccountId } = + calendarChannels[0]; if (!isContactAutoCreationEnabled || !handle) { return; } + const connectedAccount = await this.connectedAccountRepository.getById( + connectedAccountId, + workspaceId, + ); + + if (!connectedAccount) { + throw new Error( + `Connected account with id ${connectedAccountId} not found in workspace ${workspaceId}`, + ); + } + const calendarEventParticipantsWithoutPersonIdAndWorkspaceMemberId = await this.calendarEventParticipantRepository.getByCalendarChannelIdWithoutPersonIdAndWorkspaceMemberId( calendarChannelId, @@ -61,7 +77,7 @@ export class CalendarCreateCompanyAndContactAfterSyncJob ); await this.createCompanyAndContactService.createCompaniesAndContactsAndUpdateParticipants( - handle, + connectedAccount, calendarEventParticipantsWithoutPersonIdAndWorkspaceMemberId, workspaceId, ); diff --git a/packages/twenty-server/src/modules/calendar/listeners/calendar-event-participant.listener.ts b/packages/twenty-server/src/modules/calendar/listeners/calendar-event-participant.listener.ts new file mode 100644 index 000000000..79025878c --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/listeners/calendar-event-participant.listener.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/standard-objects/calendar-event-participant.workspace-entity'; +import { TimelineActivityRepository } from 'src/modules/timeline/repositiories/timeline-activity.repository'; +import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; +import { ObjectRecord } from 'src/engine/workspace-manager/workspace-sync-metadata/types/object-record'; + +@Injectable() +export class CalendarEventParticipantListener { + constructor( + @InjectObjectMetadataRepository(TimelineActivityWorkspaceEntity) + private readonly timelineActivityRepository: TimelineActivityRepository, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + @InjectRepository(ObjectMetadataEntity, 'metadata') + private readonly objectMetadataRepository: Repository, + ) {} + + @OnEvent('calendarEventParticipant.matched') + public async handleCalendarEventParticipantMatchedEvent(payload: { + workspaceId: string; + userId: string; + calendarEventParticipants: ObjectRecord[]; + }): Promise { + const calendarEventParticipants = payload.calendarEventParticipants ?? []; + + // TODO: move to a job? + + const dataSourceSchema = this.workspaceDataSourceService.getSchemaName( + payload.workspaceId, + ); + + const calendarEventObjectMetadata = + await this.objectMetadataRepository.findOneOrFail({ + where: { + nameSingular: 'calendarEvent', + workspaceId: payload.workspaceId, + }, + }); + + const calendarEventParticipantsWithPersonId = + calendarEventParticipants.filter((participant) => participant.personId); + + if (calendarEventParticipantsWithPersonId.length === 0) { + return; + } + + await this.timelineActivityRepository.insertTimelineActivitiesForObject( + 'person', + calendarEventParticipantsWithPersonId.map((participant) => ({ + dataSourceSchema, + name: 'calendarEvent.linked', + properties: null, + objectName: 'calendarEvent', + recordId: participant.personId, + workspaceMemberId: payload.userId, + workspaceId: payload.workspaceId, + linkedObjectMetadataId: calendarEventObjectMetadata.id, + linkedRecordId: participant.calendarEventId, + linkedRecordCachedName: '', + })), + payload.workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/modules/calendar/repositories/calendar-event-participant.repository.ts b/packages/twenty-server/src/modules/calendar/repositories/calendar-event-participant.repository.ts index 2820a182d..f095e100e 100644 --- a/packages/twenty-server/src/modules/calendar/repositories/calendar-event-participant.repository.ts +++ b/packages/twenty-server/src/modules/calendar/repositories/calendar-event-participant.repository.ts @@ -51,6 +51,23 @@ export class CalendarEventParticipantRepository { ); } + public async updateParticipantsPersonIdAndReturn( + participantIds: string[], + personId: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise[]> { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + return await this.workspaceDataSourceService.executeRawQuery( + `UPDATE ${dataSourceSchema}."calendarEventParticipant" SET "personId" = $1 WHERE "id" = ANY($2) RETURNING *`, + [personId, participantIds], + workspaceId, + transactionManager, + ); + } + public async updateParticipantsWorkspaceMemberId( participantIds: string[], workspaceMemberId: string, diff --git a/packages/twenty-server/src/modules/calendar/services/calendar-event-participant/calendar-event-participant.service.ts b/packages/twenty-server/src/modules/calendar/services/calendar-event-participant/calendar-event-participant.service.ts index a9e8c0b77..4b524de0d 100644 --- a/packages/twenty-server/src/modules/calendar/services/calendar-event-participant/calendar-event-participant.service.ts +++ b/packages/twenty-server/src/modules/calendar/services/calendar-event-participant/calendar-event-participant.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { EntityManager } from 'typeorm'; @@ -22,20 +23,21 @@ export class CalendarEventParticipantService { @InjectObjectMetadataRepository(PersonWorkspaceEntity) private readonly personRepository: PersonRepository, private readonly addPersonIdAndWorkspaceMemberIdService: AddPersonIdAndWorkspaceMemberIdService, + private readonly eventEmitter: EventEmitter2, ) {} public async updateCalendarEventParticipantsAfterPeopleCreation( createdPeople: ObjectRecord[], workspaceId: string, transactionManager?: EntityManager, - ): Promise { + ): Promise[]> { const participants = await this.calendarEventParticipantRepository.getByHandles( createdPeople.map((person) => person.email), workspaceId, ); - if (!participants) return; + if (!participants) return []; const dataSourceSchema = this.workspaceDataSourceService.getSchemaName(workspaceId); @@ -57,7 +59,7 @@ export class CalendarEventParticipantService { }), ); - if (calendarEventParticipantsToUpdate.length === 0) return; + if (calendarEventParticipantsToUpdate.length === 0) return []; const { flattenedValues, valuesString } = getFlattenedValuesAndValuesStringForBatchRawQuery( @@ -68,23 +70,26 @@ export class CalendarEventParticipantService { }, ); - await this.workspaceDataSourceService.executeRawQuery( - `UPDATE ${dataSourceSchema}."calendarEventParticipant" AS "calendarEventParticipant" SET "personId" = "data"."personId" + return ( + await this.workspaceDataSourceService.executeRawQuery( + `UPDATE ${dataSourceSchema}."calendarEventParticipant" AS "calendarEventParticipant" SET "personId" = "data"."personId" FROM (VALUES ${valuesString}) AS "data"("id", "personId") - WHERE "calendarEventParticipant"."id" = "data"."id"`, - flattenedValues, - workspaceId, - transactionManager, - ); + WHERE "calendarEventParticipant"."id" = "data"."id" + RETURNING *`, + flattenedValues, + workspaceId, + transactionManager, + ) + ).flat(); } public async saveCalendarEventParticipants( calendarEventParticipants: CalendarEventParticipant[], workspaceId: string, transactionManager?: EntityManager, - ): Promise { + ): Promise[]> { if (calendarEventParticipants.length === 0) { - return; + return []; } const dataSourceSchema = @@ -111,8 +116,9 @@ export class CalendarEventParticipantService { }, ); - await this.workspaceDataSourceService.executeRawQuery( - `INSERT INTO ${dataSourceSchema}."calendarEventParticipant" ("calendarEventId", "handle", "displayName", "isOrganizer", "responseStatus", "personId", "workspaceMemberId") VALUES ${valuesString}`, + return await this.workspaceDataSourceService.executeRawQuery( + `INSERT INTO ${dataSourceSchema}."calendarEventParticipant" ("calendarEventId", "handle", "displayName", "isOrganizer", "responseStatus", "personId", "workspaceMemberId") VALUES ${valuesString} + RETURNING *`, flattenedValues, workspaceId, transactionManager, @@ -135,11 +141,18 @@ export class CalendarEventParticipantService { calendarEventParticipantsToUpdate.map((participant) => participant.id); if (personId) { - await this.calendarEventParticipantRepository.updateParticipantsPersonId( - calendarEventParticipantIdsToUpdate, - personId, + const updatedCalendarEventParticipants = + await this.calendarEventParticipantRepository.updateParticipantsPersonIdAndReturn( + calendarEventParticipantIdsToUpdate, + personId, + workspaceId, + ); + + this.eventEmitter.emit(`calendarEventParticipant.matched`, { workspaceId, - ); + userId: null, + calendarEventParticipants: updatedCalendarEventParticipants, + }); } if (workspaceMemberId) { await this.calendarEventParticipantRepository.updateParticipantsWorkspaceMemberId( diff --git a/packages/twenty-server/src/modules/calendar/services/google-calendar-sync/google-calendar-sync.service.ts b/packages/twenty-server/src/modules/calendar/services/google-calendar-sync/google-calendar-sync.service.ts index 762bf0609..1fdb9f8fb 100644 --- a/packages/twenty-server/src/modules/calendar/services/google-calendar-sync/google-calendar-sync.service.ts +++ b/packages/twenty-server/src/modules/calendar/services/google-calendar-sync/google-calendar-sync.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Repository } from 'typeorm'; import { calendar_v3 as calendarV3 } from 'googleapis'; @@ -33,9 +34,10 @@ import { InjectMessageQueue } from 'src/engine/integrations/message-queue/decora import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service'; import { - CreateCompanyAndContactJobData, CreateCompanyAndContactJob, + CreateCompanyAndContactJobData, } from 'src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job'; +import { ObjectRecord } from 'src/engine/workspace-manager/workspace-sync-metadata/types/object-record'; @Injectable() export class GoogleCalendarSyncService { @@ -64,6 +66,7 @@ export class GoogleCalendarSyncService { private readonly calendarEventParticipantsService: CalendarEventParticipantService, @InjectMessageQueue(MessageQueue.emailQueue) private readonly messageQueueService: MessageQueueService, + private readonly eventEmitter: EventEmitter2, ) {} public async startGoogleCalendarSync( @@ -389,7 +392,7 @@ export class GoogleCalendarSyncService { eventExternalId: string; calendarChannelId: string; }[], - connectedAccount: ConnectedAccountWorkspaceEntity, + connectedAccount: ObjectRecord, calendarChannel: CalendarChannelWorkspaceEntity, workspaceId: string, ): Promise { @@ -409,8 +412,11 @@ export class GoogleCalendarSyncService { let startTime: number; let endTime: number; + const savedCalendarEventParticipantsToEmit: ObjectRecord[] = + []; + try { - dataSourceMetadata?.transaction(async (transactionManager) => { + await dataSourceMetadata?.transaction(async (transactionManager) => { startTime = Date.now(); await this.calendarEventRepository.saveCalendarEvents( @@ -484,10 +490,15 @@ export class GoogleCalendarSyncService { startTime = Date.now(); - await this.calendarEventParticipantsService.saveCalendarEventParticipants( - participantsToSave, - workspaceId, - transactionManager, + const savedCalendarEventParticipants = + await this.calendarEventParticipantsService.saveCalendarEventParticipants( + participantsToSave, + workspaceId, + transactionManager, + ); + + savedCalendarEventParticipantsToEmit.push( + ...savedCalendarEventParticipants, ); endTime = Date.now(); @@ -499,12 +510,18 @@ export class GoogleCalendarSyncService { ); }); + this.eventEmitter.emit(`calendarEventParticipant.matched`, { + workspaceId, + userId: connectedAccount.accountOwnerId, + calendarEventParticipants: savedCalendarEventParticipantsToEmit, + }); + if (calendarChannel.isContactAutoCreationEnabled) { await this.messageQueueService.add( CreateCompanyAndContactJob.name, { workspaceId, - connectedAccountHandle: connectedAccount.handle, + connectedAccount, contactsToCreate: participantsToSave, }, ); diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job.ts index e9717649c..7c31e16e8 100644 --- a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job.ts +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job.ts @@ -2,11 +2,13 @@ import { Injectable } from '@nestjs/common'; import { MessageQueueJob } from 'src/engine/integrations/message-queue/interfaces/message-queue-job.interface'; +import { ObjectRecord } from 'src/engine/workspace-manager/workspace-sync-metadata/types/object-record'; import { CreateCompanyAndContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; export type CreateCompanyAndContactJobData = { workspaceId: string; - connectedAccountHandle: string; + connectedAccount: ObjectRecord; contactsToCreate: { displayName: string; handle: string; @@ -22,10 +24,10 @@ export class CreateCompanyAndContactJob ) {} async handle(data: CreateCompanyAndContactJobData): Promise { - const { workspaceId, connectedAccountHandle, contactsToCreate } = data; + const { workspaceId, connectedAccount, contactsToCreate } = data; await this.createCompanyAndContactService.createCompaniesAndContactsAndUpdateParticipants( - connectedAccountHandle, + connectedAccount, contactsToCreate.map((contact) => ({ handle: contact.handle, displayName: contact.displayName, diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service.ts index fce9ef65b..7fd468510 100644 --- a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service.ts +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { EntityManager } from 'typeorm'; import compact from 'lodash.compact'; @@ -19,6 +20,9 @@ import { CalendarEventParticipantService } from 'src/modules/calendar/services/c import { filterOutContactsFromCompanyOrWorkspace } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util'; import { ObjectRecord } from 'src/engine/workspace-manager/workspace-sync-metadata/types/object-record'; import { MessagingMessageParticipantService } from 'src/modules/messaging/common/services/messaging-message-participant.service'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; +import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity'; +import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/standard-objects/calendar-event-participant.workspace-entity'; @Injectable() export class CreateCompanyAndContactService { @@ -32,6 +36,7 @@ export class CreateCompanyAndContactService { private readonly workspaceDataSourceService: WorkspaceDataSourceService, private readonly messageParticipantService: MessagingMessageParticipantService, private readonly calendarEventParticipantService: CalendarEventParticipantService, + private readonly eventEmitter: EventEmitter2, ) {} async createCompaniesAndPeople( @@ -125,7 +130,7 @@ export class CreateCompanyAndContactService { } async createCompaniesAndContactsAndUpdateParticipants( - connectedAccountHandle: string, + connectedAccount: ObjectRecord, contactsToCreate: Contacts, workspaceId: string, ) { @@ -134,27 +139,46 @@ export class CreateCompanyAndContactService { workspaceId, ); + let updatedMessageParticipants: ObjectRecord[] = + []; + let updatedCalendarEventParticipants: ObjectRecord[] = + []; + await workspaceDataSource?.transaction( async (transactionManager: EntityManager) => { const createdPeople = await this.createCompaniesAndPeople( - connectedAccountHandle, + connectedAccount.handle, contactsToCreate, workspaceId, transactionManager, ); - await this.messageParticipantService.updateMessageParticipantsAfterPeopleCreation( - createdPeople, - workspaceId, - transactionManager, - ); + updatedMessageParticipants = + await this.messageParticipantService.updateMessageParticipantsAfterPeopleCreation( + createdPeople, + workspaceId, + transactionManager, + ); - await this.calendarEventParticipantService.updateCalendarEventParticipantsAfterPeopleCreation( - createdPeople, - workspaceId, - transactionManager, - ); + updatedCalendarEventParticipants = + await this.calendarEventParticipantService.updateCalendarEventParticipantsAfterPeopleCreation( + createdPeople, + workspaceId, + transactionManager, + ); }, ); + + this.eventEmitter.emit(`messageParticipant.matched`, { + workspaceId, + userId: connectedAccount.accountOwnerId, + messageParticipants: updatedMessageParticipants, + }); + + this.eventEmitter.emit(`calendarEventParticipant.matched`, { + workspaceId, + userId: connectedAccount.accountOwnerId, + calendarEventParticipants: updatedCalendarEventParticipants, + }); } } diff --git a/packages/twenty-server/src/modules/messaging/common/query-hooks/message/can-access-message-thread.service.ts b/packages/twenty-server/src/modules/messaging/common/query-hooks/message/can-access-message-thread.service.ts new file mode 100644 index 000000000..e514e2218 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/common/query-hooks/message/can-access-message-thread.service.ts @@ -0,0 +1,64 @@ +import { ForbiddenException } from '@nestjs/common'; + +import groupBy from 'lodash.groupby'; + +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; +import { MessageChannelRepository } from 'src/modules/messaging/common/repositories/message-channel.repository'; +import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; +import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +export class CanAccessMessageThreadService { + constructor( + @InjectObjectMetadataRepository(MessageChannelWorkspaceEntity) + private readonly messageChannelService: MessageChannelRepository, + @InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity) + private readonly connectedAccountRepository: ConnectedAccountRepository, + @InjectObjectMetadataRepository(WorkspaceMemberWorkspaceEntity) + private readonly workspaceMemberRepository: WorkspaceMemberRepository, + ) {} + + public async canAccessMessageThread( + userId: string, + workspaceId: string, + messageChannelMessageAssociations: any[], + ) { + const messageChannels = await this.messageChannelService.getByIds( + messageChannelMessageAssociations.map( + (association) => association.messageChannelId, + ), + workspaceId, + ); + + const messageChannelsGroupByVisibility = groupBy( + messageChannels, + (channel) => channel.visibility, + ); + + if (messageChannelsGroupByVisibility.share_everything) { + return; + } + + const currentWorkspaceMember = + await this.workspaceMemberRepository.getByIdOrFail(userId, workspaceId); + + const messageChannelsConnectedAccounts = + await this.connectedAccountRepository.getByIds( + messageChannels.map((channel) => channel.connectedAccountId), + workspaceId, + ); + + const messageChannelsWorkspaceMemberIds = + messageChannelsConnectedAccounts.map( + (connectedAccount) => connectedAccount.accountOwnerId, + ); + + if (messageChannelsWorkspaceMemberIds.includes(currentWorkspaceMember.id)) { + return; + } + + throw new ForbiddenException(); + } +} diff --git a/packages/twenty-server/src/modules/messaging/common/query-hooks/message/message-find-many.pre-query.hook.ts b/packages/twenty-server/src/modules/messaging/common/query-hooks/message/message-find-many.pre-query.hook.ts index 81fdea601..8e82c578a 100644 --- a/packages/twenty-server/src/modules/messaging/common/query-hooks/message/message-find-many.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/messaging/common/query-hooks/message/message-find-many.pre-query.hook.ts @@ -1,24 +1,16 @@ import { BadRequestException, - ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; -import groupBy from 'lodash.groupby'; - import { WorkspacePreQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/interfaces/workspace-pre-query-hook.interface'; import { FindManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; -import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository'; -import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository'; import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; -import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; -import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { CanAccessMessageThreadService } from 'src/modules/messaging/common/query-hooks/message/can-access-message-thread.service'; import { MessageChannelMessageAssociationRepository } from 'src/modules/messaging/common/repositories/message-channel-message-association.repository'; -import { MessageChannelRepository } from 'src/modules/messaging/common/repositories/message-channel.repository'; import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity'; -import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; @Injectable() export class MessageFindManyPreQueryHook implements WorkspacePreQueryHook { @@ -27,12 +19,7 @@ export class MessageFindManyPreQueryHook implements WorkspacePreQueryHook { MessageChannelMessageAssociationWorkspaceEntity, ) private readonly messageChannelMessageAssociationService: MessageChannelMessageAssociationRepository, - @InjectObjectMetadataRepository(MessageChannelWorkspaceEntity) - private readonly messageChannelService: MessageChannelRepository, - @InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity) - private readonly connectedAccountRepository: ConnectedAccountRepository, - @InjectObjectMetadataRepository(WorkspaceMemberWorkspaceEntity) - private readonly workspaceMemberRepository: WorkspaceMemberRepository, + private readonly canAccessMessageThreadService: CanAccessMessageThreadService, ) {} async execute( @@ -54,52 +41,10 @@ export class MessageFindManyPreQueryHook implements WorkspacePreQueryHook { throw new NotFoundException(); } - await this.canAccessMessageThread( + await this.canAccessMessageThreadService.canAccessMessageThread( userId, workspaceId, messageChannelMessageAssociations, ); } - - private async canAccessMessageThread( - userId: string, - workspaceId: string, - messageChannelMessageAssociations: any[], - ) { - const messageChannels = await this.messageChannelService.getByIds( - messageChannelMessageAssociations.map( - (association) => association.messageChannelId, - ), - workspaceId, - ); - - const messageChannelsGroupByVisibility = groupBy( - messageChannels, - (channel) => channel.visibility, - ); - - if (messageChannelsGroupByVisibility.SHARE_EVERYTHING) { - return; - } - - const currentWorkspaceMember = - await this.workspaceMemberRepository.getByIdOrFail(userId, workspaceId); - - const messageChannelsConnectedAccounts = - await this.connectedAccountRepository.getByIds( - messageChannels.map((channel) => channel.connectedAccountId), - workspaceId, - ); - - const messageChannelsWorkspaceMemberIds = - messageChannelsConnectedAccounts.map( - (connectedAccount) => connectedAccount.accountOwnerId, - ); - - if (messageChannelsWorkspaceMemberIds.includes(currentWorkspaceMember.id)) { - return; - } - - throw new ForbiddenException(); - } } diff --git a/packages/twenty-server/src/modules/messaging/common/query-hooks/message/message-find-one.pre-query-hook.ts b/packages/twenty-server/src/modules/messaging/common/query-hooks/message/message-find-one.pre-query-hook.ts index c5ef24158..2140ac010 100644 --- a/packages/twenty-server/src/modules/messaging/common/query-hooks/message/message-find-one.pre-query-hook.ts +++ b/packages/twenty-server/src/modules/messaging/common/query-hooks/message/message-find-one.pre-query-hook.ts @@ -1,16 +1,43 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { Injectable, MethodNotAllowedException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { WorkspacePreQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/interfaces/workspace-pre-query-hook.interface'; import { FindOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { CanAccessMessageThreadService } from 'src/modules/messaging/common/query-hooks/message/can-access-message-thread.service'; +import { MessageChannelMessageAssociationRepository } from 'src/modules/messaging/common/repositories/message-channel-message-association.repository'; +import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity'; + @Injectable() export class MessageFindOnePreQueryHook implements WorkspacePreQueryHook { + constructor( + @InjectObjectMetadataRepository( + MessageChannelMessageAssociationWorkspaceEntity, + ) + private readonly messageChannelMessageAssociationService: MessageChannelMessageAssociationRepository, + private readonly canAccessMessageThreadService: CanAccessMessageThreadService, + ) {} + async execute( - _userId: string, - _workspaceId: string, - _payload: FindOneResolverArgs, + userId: string, + workspaceId: string, + payload: FindOneResolverArgs, ): Promise { - throw new MethodNotAllowedException('Method not allowed.'); + const messageChannelMessageAssociations = + await this.messageChannelMessageAssociationService.getByMessageIds( + [payload?.filter?.id?.eq], + workspaceId, + ); + + if (messageChannelMessageAssociations.length === 0) { + throw new NotFoundException(); + } + + await this.canAccessMessageThreadService.canAccessMessageThread( + userId, + workspaceId, + messageChannelMessageAssociations, + ); } } diff --git a/packages/twenty-server/src/modules/messaging/common/query-hooks/messaging-query-hook.module.ts b/packages/twenty-server/src/modules/messaging/common/query-hooks/messaging-query-hook.module.ts index 2a80f5152..f27adf462 100644 --- a/packages/twenty-server/src/modules/messaging/common/query-hooks/messaging-query-hook.module.ts +++ b/packages/twenty-server/src/modules/messaging/common/query-hooks/messaging-query-hook.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; +import { CanAccessMessageThreadService } from 'src/modules/messaging/common/query-hooks/message/can-access-message-thread.service'; import { MessageFindManyPreQueryHook } from 'src/modules/messaging/common/query-hooks/message/message-find-many.pre-query.hook'; import { MessageFindOnePreQueryHook } from 'src/modules/messaging/common/query-hooks/message/message-find-one.pre-query-hook'; import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity'; @@ -18,6 +19,7 @@ import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/stan ]), ], providers: [ + CanAccessMessageThreadService, { provide: MessageFindOnePreQueryHook.name, useClass: MessageFindOnePreQueryHook, diff --git a/packages/twenty-server/src/modules/messaging/common/repositories/message-participant.repository.ts b/packages/twenty-server/src/modules/messaging/common/repositories/message-participant.repository.ts index c92b19d8e..7d6515ae4 100644 --- a/packages/twenty-server/src/modules/messaging/common/repositories/message-participant.repository.ts +++ b/packages/twenty-server/src/modules/messaging/common/repositories/message-participant.repository.ts @@ -46,6 +46,23 @@ export class MessageParticipantRepository { ); } + public async updateParticipantsPersonIdAndReturn( + participantIds: string[], + personId: string, + workspaceId: string, + transactionManager?: EntityManager, + ) { + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + return await this.workspaceDataSourceService.executeRawQuery( + `UPDATE ${dataSourceSchema}."messageParticipant" SET "personId" = $1 WHERE "id" = ANY($2) RETURNING *`, + [personId, participantIds], + workspaceId, + transactionManager, + ); + } + public async updateParticipantsWorkspaceMemberId( participantIds: string[], workspaceMemberId: string, diff --git a/packages/twenty-server/src/modules/messaging/common/services/messaging-message-participant.service.ts b/packages/twenty-server/src/modules/messaging/common/services/messaging-message-participant.service.ts index c9538d444..78a5623bb 100644 --- a/packages/twenty-server/src/modules/messaging/common/services/messaging-message-participant.service.ts +++ b/packages/twenty-server/src/modules/messaging/common/services/messaging-message-participant.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { EntityManager } from 'typeorm'; @@ -24,20 +25,21 @@ export class MessagingMessageParticipantService { @InjectObjectMetadataRepository(PersonWorkspaceEntity) private readonly personRepository: PersonRepository, private readonly addPersonIdAndWorkspaceMemberIdService: AddPersonIdAndWorkspaceMemberIdService, + private readonly eventEmitter: EventEmitter2, ) {} public async updateMessageParticipantsAfterPeopleCreation( createdPeople: ObjectRecord[], workspaceId: string, transactionManager?: EntityManager, - ): Promise { + ): Promise[]> { const participants = await this.messageParticipantRepository.getByHandles( createdPeople.map((person) => person.email), workspaceId, transactionManager, ); - if (!participants) return; + if (!participants) return []; const dataSourceSchema = this.workspaceDataSourceService.getSchemaName(workspaceId); @@ -57,7 +59,7 @@ export class MessagingMessageParticipantService { )?.id, })); - if (messageParticipantsToUpdate.length === 0) return; + if (messageParticipantsToUpdate.length === 0) return []; const { flattenedValues, valuesString } = getFlattenedValuesAndValuesStringForBatchRawQuery( @@ -68,22 +70,25 @@ export class MessagingMessageParticipantService { }, ); - await this.workspaceDataSourceService.executeRawQuery( - `UPDATE ${dataSourceSchema}."messageParticipant" AS "messageParticipant" SET "personId" = "data"."personId" + return ( + await this.workspaceDataSourceService.executeRawQuery( + `UPDATE ${dataSourceSchema}."messageParticipant" AS "messageParticipant" SET "personId" = "data"."personId" FROM (VALUES ${valuesString}) AS "data"("id", "personId") - WHERE "messageParticipant"."id" = "data"."id"`, - flattenedValues, - workspaceId, - transactionManager, - ); + WHERE "messageParticipant"."id" = "data"."id" + RETURNING *`, + flattenedValues, + workspaceId, + transactionManager, + ) + ).flat(); } public async saveMessageParticipants( participants: ParticipantWithMessageId[], workspaceId: string, transactionManager?: EntityManager, - ): Promise { - if (!participants) return; + ): Promise[]> { + if (!participants) return []; const dataSourceSchema = this.workspaceDataSourceService.getSchemaName(workspaceId); @@ -108,10 +113,10 @@ export class MessagingMessageParticipantService { }, ); - if (messageParticipantsToSave.length === 0) return; + if (messageParticipantsToSave.length === 0) return []; - await this.workspaceDataSourceService.executeRawQuery( - `INSERT INTO ${dataSourceSchema}."messageParticipant" ("messageId", "role", "handle", "displayName", "personId", "workspaceMemberId") VALUES ${valuesString}`, + return await this.workspaceDataSourceService.executeRawQuery( + `INSERT INTO ${dataSourceSchema}."messageParticipant" ("messageId", "role", "handle", "displayName", "personId", "workspaceMemberId") VALUES ${valuesString} RETURNING *`, flattenedValues, workspaceId, transactionManager, @@ -135,11 +140,18 @@ export class MessagingMessageParticipantService { ); if (personId) { - await this.messageParticipantRepository.updateParticipantsPersonId( - messageParticipantIdsToUpdate, - personId, + const updatedMessageParticipants = + await this.messageParticipantRepository.updateParticipantsPersonIdAndReturn( + messageParticipantIdsToUpdate, + personId, + workspaceId, + ); + + this.eventEmitter.emit(`messageParticipant.matched`, { workspaceId, - ); + userId: null, + messageParticipants: updatedMessageParticipants, + }); } if (workspaceMemberId) { await this.messageParticipantRepository.updateParticipantsWorkspaceMemberId( diff --git a/packages/twenty-server/src/modules/messaging/common/services/messaging-save-messages-and-enqueue-contact-creation.service.ts b/packages/twenty-server/src/modules/messaging/common/services/messaging-save-messages-and-enqueue-contact-creation.service.ts index 4e14d46a5..163c4427b 100644 --- a/packages/twenty-server/src/modules/messaging/common/services/messaging-save-messages-and-enqueue-contact-creation.service.ts +++ b/packages/twenty-server/src/modules/messaging/common/services/messaging-save-messages-and-enqueue-contact-creation.service.ts @@ -1,5 +1,6 @@ import { Injectable, Inject } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { EntityManager, Repository } from 'typeorm'; @@ -19,10 +20,12 @@ import { import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; import { GmailMessage, + Participant, ParticipantWithMessageId, } from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-message'; import { MessagingMessageService } from 'src/modules/messaging/common/services/messaging-message.service'; import { MessagingMessageParticipantService } from 'src/modules/messaging/common/services/messaging-message-participant.service'; +import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity'; @Injectable() export class MessagingSaveMessagesAndEnqueueContactCreationService { @@ -34,6 +37,7 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService { private readonly messageParticipantService: MessagingMessageParticipantService, @InjectRepository(FeatureFlagEntity, 'core') private readonly featureFlagRepository: Repository, + private readonly eventEmitter: EventEmitter2, ) {} async saveMessagesAndEnqueueContactCreationJob( @@ -57,6 +61,9 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService { const isContactCreationForSentAndReceivedEmailsEnabled = isContactCreationForSentAndReceivedEmailsEnabledFeatureFlag?.value; + let savedMessageParticipants: ObjectRecord[] = + []; + const participantsWithMessageId = await workspaceDataSource?.transaction( async (transactionManager: EntityManager) => { const messageExternalIdsAndIdsMap = @@ -74,7 +81,7 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService { const messageId = messageExternalIdsAndIdsMap.get(message.externalId); return messageId - ? message.participants.map((participant) => ({ + ? message.participants.map((participant: Participant) => ({ ...participant, messageId, shouldCreateContact: @@ -86,16 +93,23 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService { : []; }); - await this.messageParticipantService.saveMessageParticipants( - participantsWithMessageId, - workspaceId, - transactionManager, - ); + savedMessageParticipants = + await this.messageParticipantService.saveMessageParticipants( + participantsWithMessageId, + workspaceId, + transactionManager, + ); return participantsWithMessageId; }, ); + this.eventEmitter.emit(`messageParticipant.matched`, { + workspaceId, + userId: connectedAccount.accountOwnerId, + messageParticipants: savedMessageParticipants, + }); + if (messageChannel.isContactAutoCreationEnabled) { const contactsToCreate = participantsWithMessageId.filter( (participant) => participant.shouldCreateContact, @@ -105,7 +119,7 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService { CreateCompanyAndContactJob.name, { workspaceId, - connectedAccountHandle: connectedAccount.handle, + connectedAccount, contactsToCreate, }, ); diff --git a/packages/twenty-server/src/modules/messaging/message-participants-manager/jobs/messaging-create-company-and-contact-after-sync.job.ts b/packages/twenty-server/src/modules/messaging/message-participants-manager/jobs/messaging-create-company-and-contact-after-sync.job.ts index 1f2b3a7e6..268fd74cc 100644 --- a/packages/twenty-server/src/modules/messaging/message-participants-manager/jobs/messaging-create-company-and-contact-after-sync.job.ts +++ b/packages/twenty-server/src/modules/messaging/message-participants-manager/jobs/messaging-create-company-and-contact-after-sync.job.ts @@ -15,6 +15,8 @@ import { MessageChannelRepository } from 'src/modules/messaging/common/repositor import { MessageParticipantRepository } from 'src/modules/messaging/common/repositories/message-participant.repository'; import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; +import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository'; export type MessagingCreateCompanyAndContactAfterSyncJobData = { workspaceId: string; @@ -36,6 +38,8 @@ export class MessagingCreateCompanyAndContactAfterSyncJob private readonly messageParticipantRepository: MessageParticipantRepository, @InjectRepository(FeatureFlagEntity, 'core') private readonly featureFlagRepository: Repository, + @InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity) + private readonly connectedAccountRepository: ConnectedAccountRepository, ) {} async handle( @@ -51,12 +55,24 @@ export class MessagingCreateCompanyAndContactAfterSyncJob workspaceId, ); - const { handle, isContactAutoCreationEnabled } = messageChannel[0]; + const { isContactAutoCreationEnabled, connectedAccountId } = + messageChannel[0]; if (!isContactAutoCreationEnabled) { return; } + const connectedAccount = await this.connectedAccountRepository.getById( + connectedAccountId, + workspaceId, + ); + + if (!connectedAccount) { + throw new Error( + `Connected account with id ${connectedAccountId} not found in workspace ${workspaceId}`, + ); + } + const isContactCreationForSentAndReceivedEmailsEnabledFeatureFlag = await this.featureFlagRepository.findOneBy({ workspaceId: workspaceId, @@ -78,7 +94,7 @@ export class MessagingCreateCompanyAndContactAfterSyncJob ); await this.createCompanyAndContactService.createCompaniesAndContactsAndUpdateParticipants( - handle, + connectedAccount, contactsToCreate, workspaceId, ); diff --git a/packages/twenty-server/src/modules/messaging/message-participants-manager/listeners/message-participant.listener.ts b/packages/twenty-server/src/modules/messaging/message-participants-manager/listeners/message-participant.listener.ts new file mode 100644 index 000000000..92853ba5d --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-participants-manager/listeners/message-participant.listener.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { TimelineActivityRepository } from 'src/modules/timeline/repositiories/timeline-activity.repository'; +import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; +import { ObjectRecord } from 'src/engine/workspace-manager/workspace-sync-metadata/types/object-record'; +import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity'; + +@Injectable() +export class MessageParticipantListener { + constructor( + @InjectObjectMetadataRepository(TimelineActivityWorkspaceEntity) + private readonly timelineActivityRepository: TimelineActivityRepository, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + @InjectRepository(ObjectMetadataEntity, 'metadata') + private readonly objectMetadataRepository: Repository, + ) {} + + @OnEvent('messageParticipant.matched') + public async handleMessageParticipantMatched(payload: { + workspaceId: string; + userId: string; + messageParticipants: ObjectRecord[]; + }): Promise { + const messageParticipants = payload.messageParticipants ?? []; + + // TODO: move to a job? + + const dataSourceSchema = this.workspaceDataSourceService.getSchemaName( + payload.workspaceId, + ); + + const messageObjectMetadata = + await this.objectMetadataRepository.findOneOrFail({ + where: { + nameSingular: 'message', + workspaceId: payload.workspaceId, + }, + }); + + const messageParticipantsWithPersonId = messageParticipants.filter( + (participant) => participant.personId, + ); + + if (messageParticipantsWithPersonId.length === 0) { + return; + } + + await this.timelineActivityRepository.insertTimelineActivitiesForObject( + 'person', + messageParticipantsWithPersonId.map((participant) => ({ + dataSourceSchema, + name: 'message.linked', + properties: null, + objectName: 'message', + recordId: participant.personId, + workspaceMemberId: payload.userId, + workspaceId: payload.workspaceId, + linkedObjectMetadataId: messageObjectMetadata.id, + linkedRecordId: participant.messageId, + linkedRecordCachedName: '', + })), + payload.workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-participants-manager/messaging-participants-manager.module.ts b/packages/twenty-server/src/modules/messaging/message-participants-manager/messaging-participants-manager.module.ts index f7744916f..589d3eb47 100644 --- a/packages/twenty-server/src/modules/messaging/message-participants-manager/messaging-participants-manager.module.ts +++ b/packages/twenty-server/src/modules/messaging/message-participants-manager/messaging-participants-manager.module.ts @@ -3,9 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AnalyticsModule } from 'src/engine/core-modules/analytics/analytics.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { AutoCompaniesAndContactsCreationModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/auto-companies-and-contacts-creation.module'; import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module'; import { MessagingCreateCompanyAndContactAfterSyncJob } from 'src/modules/messaging/message-participants-manager/jobs/messaging-create-company-and-contact-after-sync.job'; +import { MessageParticipantListener } from 'src/modules/messaging/message-participants-manager/listeners/message-participant.listener'; +import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; @Module({ imports: [ @@ -13,12 +18,18 @@ import { MessagingCreateCompanyAndContactAfterSyncJob } from 'src/modules/messag AnalyticsModule, MessagingGmailDriverModule, AutoCompaniesAndContactsCreationModule, + WorkspaceDataSourceModule, + ObjectMetadataRepositoryModule.forFeature([ + TimelineActivityWorkspaceEntity, + ]), + TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'), ], providers: [ { provide: MessagingCreateCompanyAndContactAfterSyncJob.name, useClass: MessagingCreateCompanyAndContactAfterSyncJob, }, + MessageParticipantListener, ], }) export class MessaginParticipantsManagerModule {} diff --git a/packages/twenty-server/src/modules/timeline/repositiories/timeline-activity.repository.ts b/packages/twenty-server/src/modules/timeline/repositiories/timeline-activity.repository.ts index 0c88db351..6d6cc30c2 100644 --- a/packages/twenty-server/src/modules/timeline/repositiories/timeline-activity.repository.ts +++ b/packages/twenty-server/src/modules/timeline/repositiories/timeline-activity.repository.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; + import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; import { objectRecordDiffMerge } from 'src/engine/integrations/event-emitter/utils/object-record-diff-merge'; @@ -74,17 +76,15 @@ export class TimelineActivityRepository { return this.workspaceDataSourceService.executeRawQuery( `SELECT * FROM ${dataSourceSchema}."timelineActivity" WHERE "${objectName}Id" = $1 - AND ("name" = $2 OR "name" = $3) - AND "workspaceMemberId" = $4 - AND "linkedRecordId" = $5 + AND "name" = $2 + AND "workspaceMemberId" = $3 + AND ${ + linkedRecordId ? `"linkedRecordId" = $4` : `"linkedRecordId" IS NULL` + } AND "createdAt" >= NOW() - interval '10 minutes'`, - [ - recordId, - name, - name.replace(/\.updated$/, '.created'), - workspaceMemberId, - linkedRecordId, - ], + linkedRecordId + ? [recordId, name, workspaceMemberId, linkedRecordId] + : [recordId, name, workspaceMemberId], workspaceId, ); } @@ -133,4 +133,52 @@ export class TimelineActivityRepository { workspaceId, ); } + + public async insertTimelineActivitiesForObject( + objectName: string, + activities: { + name: string; + properties: Record | null; + workspaceMemberId: string | undefined; + recordId: string; + linkedRecordCachedName: string; + linkedRecordId: string | undefined; + linkedObjectMetadataId: string | undefined; + }[], + workspaceId: string, + transactionManager?: EntityManager, + ) { + if (activities.length === 0) { + return; + } + + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + return this.workspaceDataSourceService.executeRawQuery( + `INSERT INTO ${dataSourceSchema}."timelineActivity" + ("name", "properties", "workspaceMemberId", "${objectName}Id", "linkedRecordCachedName", "linkedRecordId", "linkedObjectMetadataId") + VALUES ${activities + .map( + (_, index) => + `($${index * 7 + 1}, $${index * 7 + 2}, $${index * 7 + 3}, $${ + index * 7 + 4 + }, $${index * 7 + 5}, $${index * 7 + 6}, $${index * 7 + 7})`, + ) + .join(',')}`, + activities + .map((activity) => [ + activity.name, + activity.properties, + activity.workspaceMemberId, + activity.recordId, + activity.linkedRecordCachedName ?? '', + activity.linkedRecordId, + activity.linkedObjectMetadataId, + ]) + .flat(), + workspaceId, + transactionManager, + ); + } }