({
+ 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,
+ );
+ }
}