POC timeline activity (#5697)

TODO: 
- remove WorkspaceIsNotAuditLogged decorators on activity/activityTarget
to log task/note creations
- handle attachments
-  fix css and remove unnecessary styled components or duplicates
This commit is contained in:
Weiko 2024-06-11 18:53:28 +02:00 committed by GitHub
parent 64b8e4ec4d
commit be96c68416
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 2134 additions and 443 deletions

View File

@ -44,9 +44,11 @@ export const EmailThreadHeader = ({
<StyledContainer>
<StyledHead>
<StyledHeading>{subject}</StyledHeading>
<StyledContent>
Last message {beautifyPastDateRelativeToNow(lastMessageSentAt)}
</StyledContent>
{lastMessageSentAt && (
<StyledContent>
Last message {beautifyPastDateRelativeToNow(lastMessageSentAt)}
</StyledContent>
)}
</StyledHead>
</StyledContainer>
);

View File

@ -43,11 +43,14 @@ export const RightDrawerEmailThread = () => {
useRegisterClickOutsideListenerCallback({
callbackId:
'EmailThreadClickOutsideCallBack-' + (thread.id ?? 'no-thread-id'),
'EmailThreadClickOutsideCallBack-' + (thread?.id ?? 'no-thread-id'),
callbackFunction: useRecoilCallback(
({ set }) =>
() => {
set(emailThreadIdWhenEmailThreadWasClosedState, thread.id);
set(
emailThreadIdWhenEmailThreadWasClosedState,
thread?.id ?? 'no-thread-id',
);
},
[thread],
),
@ -71,14 +74,14 @@ export const RightDrawerEmailThread = () => {
return (
<StyledContainer>
<EmailThreadHeader
subject={thread.subject}
lastMessageSentAt={thread.lastMessageReceivedAt}
/>
{loading ? (
<EmailLoader loadingText="Loading thread" />
) : (
<>
<EmailThreadHeader
subject={thread.subject}
lastMessageSentAt={lastMessage.receivedAt}
/>
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}

View File

@ -3,9 +3,15 @@ import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useRightDrawerEmailThread } from '../useRightDrawerEmailThread';
jest.mock('@/object-record/hooks/useFindOneRecord', () => ({
__esModule: true,
useFindOneRecord: jest.fn(),
}));
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
__esModule: true,
useFindManyRecords: jest.fn(),
@ -13,11 +19,21 @@ jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
describe('useRightDrawerEmailThread', () => {
it('should return correct values', async () => {
const mockThread = { id: '1' };
const mockMessages = [
{ id: '1', text: 'Message 1' },
{ id: '2', text: 'Message 2' },
];
const mockFetchMoreRecords = jest.fn();
(useFindOneRecord as jest.Mock).mockReturnValue({
record: mockThread,
loading: false,
fetchMoreRecords: mockFetchMoreRecords,
});
(useFindManyRecords as jest.Mock).mockReturnValue({
records: mockMessages,
loading: false,

View File

@ -1,26 +1,26 @@
import { useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import gql from 'graphql-tag';
import { useRecoilValue } from 'recoil';
import { fetchAllThreadMessagesOperationSignatureFactory } from '@/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory';
import { EmailThread } from '@/activities/emails/types/EmailThread';
import { EmailThreadMessage as EmailThreadMessageType } from '@/activities/emails/types/EmailThreadMessage';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore';
export const useRightDrawerEmailThread = () => {
const viewableRecordId = useRecoilValue(viewableRecordIdState);
const { setRecords } = useSetRecordInStore();
const apolloClient = useApolloClient();
const thread = apolloClient.readFragment({
id: `TimelineThread:${viewableRecordId}`,
fragment: gql`
fragment timelineThread on TimelineThread {
id
subject
lastMessageReceivedAt
}
`,
const { record: thread } = useFindOneRecord<EmailThread>({
objectNameSingular: CoreObjectNameSingular.MessageThread,
objectRecordId: viewableRecordId ?? '',
recordGqlFields: {
id: true,
},
onCompleted: (record) => setRecords([record]),
});
const FETCH_ALL_MESSAGES_OPERATION_SIGNATURE =

View File

@ -0,0 +1,8 @@
import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
export type EmailThread = {
id: string;
subject: string;
messages: EmailThreadMessage[];
__typename: 'EmailThread';
};

View File

@ -4,6 +4,8 @@ export type EmailThreadMessage = {
id: string;
text: string;
receivedAt: string;
subject: string;
messageThreadId: string;
messageParticipants: EmailThreadMessageParticipant[];
__typename: 'EmailThreadMessage';
};

View File

@ -0,0 +1,80 @@
import { Meta, StoryObj } from '@storybook/react';
import { graphql, HttpResponse } from 'msw';
import { ComponentDecorator } from 'twenty-ui';
import { TimelineActivities } from '@/activities/timelineActivities/components/TimelineActivities';
import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { mockedTimelineActivities } from '~/testing/mock-data/timeline-activities';
const meta: Meta<typeof TimelineActivities> = {
title: 'Modules/TimelineActivities/TimelineActivities',
component: TimelineActivities,
decorators: [
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
(Story) => {
return (
<TimelineActivityContext.Provider
value={{
labelIdentifierValue: 'Mock',
}}
>
<Story />
</TimelineActivityContext.Provider>
);
},
],
args: {
targetableObject: {
id: '1',
targetObjectNameSingular: 'company',
},
},
parameters: {
msw: {
handlers: [
graphql.query('FindManyActivities', () => {
return HttpResponse.json({
data: {
activities: {
edges: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
},
},
});
}),
graphql.query('FindManyTimelineActivities', () => {
return HttpResponse.json({
data: {
timelineActivities: {
edges: mockedTimelineActivities.map((activity) => ({
node: activity,
cursor: activity.id,
})),
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
},
},
});
}),
],
},
},
};
export default meta;
type Story = StoryObj<typeof TimelineActivities>;
export const Default: Story = {};

View File

@ -25,7 +25,6 @@ const StyledTimelineContainer = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
justify-content: flex-start;
padding: ${({ theme }) => theme.spacing(4)};
width: calc(100% - ${({ theme }) => theme.spacing(8)});
`;

View File

@ -1,77 +1,46 @@
import { Tooltip } from 'react-tooltip';
import { useContext } from 'react';
import styled from '@emotion/styled';
import {
IconCheckbox,
IconCirclePlus,
IconEditCircle,
IconFocusCentered,
IconNotes,
useIcons,
} from 'twenty-ui';
import { useRecoilValue } from 'recoil';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { useLinkedObject } from '@/activities/timeline/hooks/useLinkedObject';
import { EventUpdateProperty } from '@/activities/timelineActivities/components/EventUpdateProperty';
import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext';
import {
EventIconDynamicComponent,
EventRowDynamicComponent,
} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent';
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
import {
CurrentWorkspaceMember,
currentWorkspaceMemberState,
} from '@/auth/states/currentWorkspaceMemberState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import {
beautifyExactDateTime,
beautifyPastDateRelativeToNow,
} from '~/utils/date-utils';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const StyledIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
user-select: none;
height: 16px;
margin: 5px;
align-items: center;
justify-content: center;
text-decoration-line: underline;
color: ${({ theme }) => theme.font.color.tertiary};
height: 16px;
width: 16px;
margin: 5px;
user-select: none;
text-decoration-line: underline;
z-index: 2;
`;
const StyledActionName = styled.span`
overflow: hidden;
flex: none;
white-space: nowrap;
align-self: normal;
`;
const StyledItemContainer = styled.div`
align-content: center;
display: flex;
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
flex: 1;
gap: ${({ theme }) => theme.spacing(1)};
span {
color: ${({ theme }) => theme.font.color.secondary};
}
overflow: hidden;
`;
const StyledItemAuthorText = styled.span`
display: flex;
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(1)};
white-space: nowrap;
`;
const StyledItemTitle = styled.span`
display: flex;
flex-flow: row nowrap;
overflow: hidden;
white-space: nowrap;
`;
const StyledLinkedObject = styled.span`
cursor: pointer;
text-decoration: underline;
`;
const StyledItemTitleDate = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
@ -98,25 +67,10 @@ const StyledVerticalLine = styled.div`
width: 2px;
`;
const StyledTooltip = styled(Tooltip)`
background-color: ${({ theme }) => theme.background.primary};
box-shadow: 0px 2px 4px 3px
${({ theme }) => theme.background.transparent.light};
box-shadow: 2px 4px 16px 6px
${({ theme }) => theme.background.transparent.light};
color: ${({ theme }) => theme.font.color.primary};
opacity: 1;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>`
align-items: center;
align-self: stretch;
display: flex;
align-items: center;
justify-content: space-between;
gap: ${({ theme }) => theme.spacing(4)};
height: ${({ isGap, theme }) =>
isGap ? (useIsMobile() ? theme.spacing(6) : theme.spacing(3)) : 'auto'};
@ -127,8 +81,9 @@ const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>`
const StyledSummary = styled.summary`
display: flex;
flex: 1;
flex-flow: row ${() => (useIsMobile() ? 'wrap' : 'nowrap')};
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
align-items: center;
overflow: hidden;
`;
@ -138,135 +93,69 @@ type EventRowProps = {
event: TimelineActivity;
};
const getAuthorFullName = (
event: TimelineActivity,
currentWorkspaceMember: CurrentWorkspaceMember,
) => {
if (isDefined(event.workspaceMember)) {
return currentWorkspaceMember.id === event.workspaceMember.id
? 'You'
: `${event.workspaceMember?.name.firstName} ${event.workspaceMember?.name.lastName}`;
}
return 'Twenty';
};
export const EventRow = ({
isLastEvent,
event,
mainObjectMetadataItem,
}: EventRowProps) => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { labelIdentifierValue } = useContext(TimelineActivityContext);
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(event.createdAt);
const exactCreatedAt = beautifyExactDateTime(event.createdAt);
const linkedObjectMetadataItem = useLinkedObject(
event.linkedObjectMetadataId,
);
const properties = JSON.parse(event.properties);
const diff: Record<string, { before: any; after: any }> = properties?.diff;
const isEventType = (type: 'created' | 'updated') => {
if (event.name.includes('.')) {
return event.name.split('.')[1] === type;
}
return false;
};
const { getIcon } = useIcons();
const linkedObjectMetadata = useLinkedObject(event.linkedObjectMetadataId);
const linkedObjectLabel = event.name.includes('note')
? 'note'
: event.name.includes('task')
? 'task'
: linkedObjectMetadata?.labelSingular;
const ActivityIcon = event.linkedObjectMetadataId
? event.name.includes('note')
? IconNotes
: event.name.includes('task')
? IconCheckbox
: getIcon(linkedObjectMetadata?.icon)
: isEventType('created')
? IconCirclePlus
: isEventType('updated')
? IconEditCircle
: IconFocusCentered;
const author =
event.workspaceMember?.name.firstName +
' ' +
event.workspaceMember?.name.lastName;
const action = isEventType('created')
? 'created'
: isEventType('updated')
? 'updated'
: event.name;
let description;
if (!isUndefinedOrNull(linkedObjectMetadata)) {
description = 'a ' + linkedObjectLabel;
} else if (!event.linkedObjectMetadataId && isEventType('created')) {
description = `a new ${mainObjectMetadataItem?.labelSingular}`;
} else if (isEventType('updated')) {
const diffKeys = Object.keys(diff);
if (diffKeys.length === 0) {
description = `a ${mainObjectMetadataItem?.labelSingular}`;
} else if (diffKeys.length === 1) {
const [key, value] = Object.entries(diff)[0];
description = [
<EventUpdateProperty
propertyName={key}
after={value?.after as string}
/>,
];
} else if (diffKeys.length === 2) {
description =
mainObjectMetadataItem?.fields.find(
(field) => diffKeys[0] === field.name,
)?.label +
' and ' +
mainObjectMetadataItem?.fields.find(
(field) => diffKeys[1] === field.name,
)?.label;
} else if (diffKeys.length > 2) {
description =
diffKeys[0] + ' and ' + (diffKeys.length - 1) + ' other fields';
}
} else if (!isEventType('created') && !isEventType('updated')) {
description = JSON.stringify(diff);
if (isUndefinedOrNull(currentWorkspaceMember)) {
return null;
}
const details = JSON.stringify(diff);
const openActivityRightDrawer = useOpenActivityRightDrawer();
const authorFullName = getAuthorFullName(event, currentWorkspaceMember);
if (isUndefinedOrNull(mainObjectMetadataItem)) {
return null;
}
return (
<>
<StyledTimelineItemContainer>
<StyledIconContainer>
<ActivityIcon />
<EventIconDynamicComponent
event={event}
linkedObjectMetadataItem={linkedObjectMetadataItem}
/>
</StyledIconContainer>
<StyledItemContainer>
<details>
<StyledSummary>
<StyledItemAuthorText>{author}</StyledItemAuthorText>
<StyledActionName>{action}</StyledActionName>
<StyledItemTitle>{description}</StyledItemTitle>
{isUndefinedOrNull(linkedObjectMetadata) ? (
<></>
) : (
<StyledLinkedObject
onClick={() => openActivityRightDrawer(event.linkedRecordId)}
>
{event.linkedRecordCachedName}
</StyledLinkedObject>
)}
</StyledSummary>
{details}
</details>
<StyledSummary>
<EventRowDynamicComponent
authorFullName={authorFullName}
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
linkedObjectMetadataItem={linkedObjectMetadataItem}
/>
</StyledSummary>
<StyledItemTitleDate id={`id-${event.id}`}>
{beautifiedCreatedAt}
</StyledItemTitleDate>
<StyledTooltip
anchorSelect={`#id-${event.id}`}
content={exactCreatedAt}
clickable
noArrow
/>
</StyledItemContainer>
</StyledTimelineItemContainer>
{!isLastEvent && (
<StyledTimelineItemContainer isGap>
<StyledVerticalLineContainer>
<StyledVerticalLine></StyledVerticalLine>
<StyledVerticalLine />
</StyledVerticalLineContainer>
</StyledTimelineItemContainer>
)}

View File

@ -45,6 +45,8 @@ const StyledMonthSeperator = styled.div`
color: ${({ theme }) => theme.font.color.light};
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xs};
`;
const StyledMonthSeperatorLine = styled.div`
background: ${({ theme }) => theme.border.color.light};

View File

@ -1,6 +1,7 @@
import styled from '@emotion/styled';
import { isNonEmptyArray } from '@sniptt/guards';
import { FetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { TimelineCreateButtonGroup } from '@/activities/timeline/components/TimelineCreateButtonGroup';
import { EventList } from '@/activities/timelineActivities/components/EventList';
import { useTimelineActivities } from '@/activities/timelineActivities/hooks/useTimelineActivities';
@ -24,6 +25,11 @@ const StyledMainContainer = styled.div`
height: 100%;
justify-content: center;
padding-top: ${({ theme }) => theme.spacing(6)};
padding-right: ${({ theme }) => theme.spacing(6)};
padding-bottom: ${({ theme }) => theme.spacing(16)};
padding-left: ${({ theme }) => theme.spacing(6)};
gap: ${({ theme }) => theme.spacing(4)};
`;
export const TimelineActivities = ({
@ -31,7 +37,8 @@ export const TimelineActivities = ({
}: {
targetableObject: ActivityTargetableObject;
}) => {
const { timelineActivities } = useTimelineActivities(targetableObject);
const { timelineActivities, loading, fetchMoreRecords } =
useTimelineActivities(targetableObject);
if (!isNonEmptyArray(timelineActivities)) {
return (
@ -57,6 +64,7 @@ export const TimelineActivities = ({
title="All"
events={timelineActivities ?? []}
/>
<FetchMoreLoader loading={loading} onLastRowVisible={fetchMoreRecords} />
</StyledMainContainer>
);
};

View File

@ -0,0 +1,19 @@
import { useActivities } from '@/activities/hooks/useActivities';
import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FindManyTimelineActivitiesOrderBy';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { isDefined } from '~/utils/isDefined';
export const TimelineActivitiesQueryEffect = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
useActivities({
targetableObjects: [targetableObject],
activitiesFilters: {},
activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY,
skip: !isDefined(targetableObject),
});
return <></>;
};

View File

@ -0,0 +1,10 @@
import { createContext } from 'react';
type TimelineActivityContextValue = {
labelIdentifierValue: string;
};
export const TimelineActivityContext =
createContext<TimelineActivityContextValue>({
labelIdentifierValue: '',
});

View File

@ -12,7 +12,11 @@ export const useTimelineActivities = (
nameSingular: targetableObject.targetObjectNameSingular,
});
const { records: TimelineActivities } = useFindManyRecords({
const {
records: TimelineActivities,
loading,
fetchMoreRecords,
} = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.TimelineActivity,
filter: {
[targetableObjectFieldIdName]: {
@ -22,10 +26,23 @@ export const useTimelineActivities = (
orderBy: {
createdAt: 'DescNullsFirst',
},
fetchPolicy: 'network-only',
recordGqlFields: {
id: true,
createdAt: true,
linkedObjectMetadataId: true,
linkedRecordCachedName: true,
linkedRecordId: true,
name: true,
properties: true,
happensAt: true,
workspaceMember: true,
person: true,
},
});
return {
timelineActivities: TimelineActivities as TimelineActivity[],
loading,
fetchMoreRecords,
};
};

View File

@ -0,0 +1,40 @@
import styled from '@emotion/styled';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import {
EventRowDynamicComponentProps,
StyledItemAction,
StyledItemAuthorText,
} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent';
type EventRowActivityProps = EventRowDynamicComponentProps;
const StyledLinkedActivity = styled.span`
cursor: pointer;
text-decoration: underline;
`;
export const EventRowActivity: React.FC<EventRowActivityProps> = ({
event,
authorFullName,
}: EventRowActivityProps) => {
const [, eventAction] = event.name.split('.');
const openActivityRightDrawer = useOpenActivityRightDrawer();
if (!event.linkedRecordId) {
throw new Error('Could not find linked record id for event');
}
return (
<>
<StyledItemAuthorText>{authorFullName}</StyledItemAuthorText>
<StyledItemAction>{eventAction}</StyledItemAction>
<StyledLinkedActivity
onClick={() => openActivityRightDrawer(event.linkedRecordId)}
>
{event.linkedRecordCachedName}
</StyledLinkedActivity>
</>
);
};

View File

@ -0,0 +1,169 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { useOpenCalendarEventRightDrawer } from '@/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer';
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore';
import {
formatToHumanReadableDay,
formatToHumanReadableMonth,
formatToHumanReadableTime,
} from '~/utils';
import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const StyledEventCardCalendarEventContainer = styled.div`
cursor: pointer;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledCalendarEventContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: center;
`;
const StyledCalendarEventTop = styled.div`
align-items: center;
align-self: stretch;
display: flex;
justify-content: space-between;
`;
const StyledCalendarEventTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.medium};
display: flex;
`;
const StyledCalendarEventBody = styled.div`
align-items: flex-start;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
flex: 1 0 0;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: center;
`;
const StyledCalendarEventDateCard = styled.div`
display: flex;
padding: ${({ theme }) => theme.spacing(1)};
flex-direction: column;
justify-content: center;
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.spacing(1)};
border: 1px solid ${({ theme }) => theme.border.color.medium};
`;
const StyledCalendarEventDateCardMonth = styled.div`
color: ${({ theme }) => theme.font.color.danger};
font-size: ${({ theme }) => theme.font.size.xxs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledCalendarEventDateCardDay = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
export const EventCardCalendarEvent = ({
calendarEventId,
}: {
calendarEventId: string;
}) => {
const { setRecords } = useSetRecordInStore();
const {
record: calendarEvent,
loading,
error,
} = useFindOneRecord<CalendarEvent>({
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 <div>Calendar event not shared</div>;
}
const shouldHandleNotFound = error.graphQLErrors.some(
(e) => e.extensions?.code === 'NOT_FOUND',
);
if (shouldHandleNotFound) {
return <div>Calendar event not found</div>;
}
return <div>Error loading message</div>;
}
if (loading || isUndefined(calendarEvent)) {
return <div>Loading...</div>;
}
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 (
<StyledEventCardCalendarEventContainer
onClick={() => openCalendarEventRightDrawer(calendarEvent.id)}
>
<StyledCalendarEventDateCard>
<StyledCalendarEventDateCardMonth>
{startsAtMonth}
</StyledCalendarEventDateCardMonth>
<StyledCalendarEventDateCardDay>
{startsAtDay}
</StyledCalendarEventDateCardDay>
</StyledCalendarEventDateCard>
<StyledCalendarEventContent>
<StyledCalendarEventTop>
<StyledCalendarEventTitle>
{calendarEvent.title}
</StyledCalendarEventTitle>
</StyledCalendarEventTop>
<StyledCalendarEventBody>
{startsAtHour} {endsAtHour && <> {endsAtHour}</>}
</StyledCalendarEventBody>
</StyledCalendarEventContent>
</StyledEventCardCalendarEventContainer>
);
};

View File

@ -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<EventRowCalendarEventProps> = ({
event,
authorFullName,
labelIdentifierValue,
}: EventRowCalendarEventProps) => {
const [, eventAction] = event.name.split('.');
const [isOpen, setIsOpen] = useState(false);
const renderRow = () => {
switch (eventAction) {
case 'linked': {
return (
<StyledItemAction>
linked a calendar event with {labelIdentifierValue}
</StyledItemAction>
);
}
default:
throw new Error('Invalid event action for calendarEvent event type.');
}
};
return (
<StyledEventRowCalendarEventContainer>
<StyledRowContainer>
<StyledItemAuthorText>{authorFullName}</StyledItemAuthorText>
{renderRow()}
<EventCardToggleButton isOpen={isOpen} setIsOpen={setIsOpen} />
</StyledRowContainer>
<EventCard isOpen={isOpen}>
<EventCardCalendarEvent calendarEventId={event.linkedRecordId} />
</EventCard>
</StyledEventRowCalendarEventContainer>
);
};

View File

@ -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 && (
<StyledCardContainer>
<StyledCard fullWidth>{children}</StyledCard>
</StyledCardContainer>
)
);
};
export const EventCardToggleButton = ({
isOpen,
setIsOpen,
}: EventCardToggleButtonProps) => {
return (
<StyledButtonContainer>
<IconButton
Icon={isOpen ? IconChevronUp : IconChevronDown}
onClick={() => setIsOpen(!isOpen)}
size="small"
variant="secondary"
/>
</StyledButtonContainer>
);
};

View File

@ -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<EventRowDynamicComponentProps>;
} = {
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 (
<EventRowComponent
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
linkedObjectMetadataItem={linkedObjectMetadataItem}
authorFullName={authorFullName}
/>
);
}
if (eventName === mainObjectMetadataItem?.nameSingular) {
return (
<EventRowMainObject
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
linkedObjectMetadataItem={linkedObjectMetadataItem}
authorFullName={authorFullName}
/>
);
}
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 <IconCirclePlus />;
}
if (eventAction === 'updated') {
return <IconEditCircle />;
}
const IconComponent = getIcon(linkedObjectMetadataItem?.icon);
return <IconComponent />;
};

View File

@ -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<string, any>;
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 (
<StyledEventFieldDiffContainer>
<EventFieldDiffLabel fieldMetadataItem={fieldMetadataItem} />
<EventFieldDiffValueEffect
forgedRecordId={forgedRecordId}
mainObjectMetadataItem={mainObjectMetadataItem}
fieldMetadataItem={fieldMetadataItem}
diffRecord={diffRecord}
/>
<EventFieldDiffValue
forgedRecordId={forgedRecordId}
mainObjectMetadataItem={mainObjectMetadataItem}
fieldMetadataItem={fieldMetadataItem}
/>
</StyledEventFieldDiffContainer>
);
};

View File

@ -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 (
<StyledUpdatedFieldContainer>
<StyledUpdatedFieldIconContainer>
<IconComponent />
</StyledUpdatedFieldIconContainer>
<StyledUpdatedFieldLabel>
{fieldMetadataItem.label}
</StyledUpdatedFieldLabel>
</StyledUpdatedFieldContainer>
);
};

View File

@ -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 (
<StyledEventFieldDiffValue>
<FieldContext.Provider
value={{
entityId: forgedRecordId,
isLabelIdentifier: isLabelIdentifierField({
fieldMetadataItem,
objectMetadataItem: mainObjectMetadataItem,
}),
fieldDefinition: {
type: fieldMetadataItem.type,
iconName: fieldMetadataItem?.icon || 'FieldIcon',
fieldMetadataId: fieldMetadataItem.id || '',
label: fieldMetadataItem.label,
metadata: {
fieldName: fieldMetadataItem.name,
objectMetadataNameSingular: mainObjectMetadataItem.nameSingular,
options: fieldMetadataItem.options ?? [],
},
defaultValue: fieldMetadataItem.defaultValue,
},
hotkeyScope: 'field-event-diff',
}}
>
<FieldDisplay />
</FieldContext.Provider>
</StyledEventFieldDiffValue>
);
};

View File

@ -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<string, any> | 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 <></>;
};

View File

@ -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 (
<StyledMainContainer>
<StyledItemLabelIdentifier>
{labelIdentifierValue}
</StyledItemLabelIdentifier>
<StyledItemAction>was created by</StyledItemAction>
<StyledItemAuthorText>{authorFullName}</StyledItemAuthorText>
</StyledMainContainer>
);
}
case 'updated': {
return (
<EventRowMainObjectUpdated
authorFullName={authorFullName}
labelIdentifierValue={labelIdentifierValue}
event={event}
mainObjectMetadataItem={mainObjectMetadataItem}
/>
);
}
default:
return null;
}
};

View File

@ -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<string, FieldMetadataItem>,
) => {
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 (
<EventFieldDiff
key={forgedRecordId}
diffRecord={diffValue}
fieldMetadataItem={fieldMetadataItem}
mainObjectMetadataItem={mainObjectMetadataItem}
forgedRecordId={forgedRecordId}
/>
);
};
export const EventRowMainObjectUpdated = ({
authorFullName,
labelIdentifierValue,
event,
mainObjectMetadataItem,
}: EventRowMainObjectUpdatedProps) => {
const diff: Record<string, { before: any; after: any }> =
event.properties?.diff;
const [isOpen, setIsOpen] = useState(true);
const fieldMetadataItemMap: Record<string, FieldMetadataItem> =
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 (
<StyledEventRowMainObjectUpdatedContainer>
<StyledRowContainer>
<StyledItemAuthorText>{authorFullName}</StyledItemAuthorText>
<StyledItemAction>
updated
{diffEntries.length === 1 &&
renderUpdateDescription(
mainObjectMetadataItem,
diffEntries[0][0],
diffEntries[0][1].after,
event.id,
fieldMetadataItemMap,
)}
{diffEntries.length > 1 && (
<>
<span>
{diffEntries.length} fields on {labelIdentifierValue}
</span>
<EventCardToggleButton isOpen={isOpen} setIsOpen={setIsOpen} />
</>
)}
</StyledItemAction>
</StyledRowContainer>
{diffEntries.length > 1 && (
<EventCard isOpen={isOpen}>
{diffEntries.map(([diffKey, diffValue]) =>
renderUpdateDescription(
mainObjectMetadataItem,
diffKey,
diffValue.after,
event.id,
fieldMetadataItemMap,
),
)}
</EventCard>
)}
</StyledEventRowMainObjectUpdatedContainer>
);
};

View File

@ -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<EmailThreadMessage>({
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 <EventCardMessageNotShared sharedByFullName={authorFullName} />;
}
const shouldHandleNotFound = error.graphQLErrors.some(
(e) => e.extensions?.code === 'NOT_FOUND',
);
if (shouldHandleNotFound) {
return <div>Message not found</div>;
}
return <div>Error loading message</div>;
}
if (loading || isUndefined(message)) {
return <div>Loading...</div>;
}
const messageParticipantHandles = message?.messageParticipants
.map((participant) => participant.handle)
.join(', ');
return (
<StyledEventCardMessageContainer>
<StyledEmailContent>
<StyledEmailTop>
<StyledEmailTitle>
<div>{message.subject}</div>
</StyledEmailTitle>
<StyledEmailParticipants>
<OverflowingTextWithTooltip text={messageParticipantHandles} />
</StyledEmailParticipants>
</StyledEmailTop>
<StyledEmailBody
onClick={() => openEmailThread(message.messageThreadId)}
>
{message.text}
</StyledEmailBody>
</StyledEmailContent>
</StyledEventCardMessageContainer>
);
};

View File

@ -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 (
<StyledEventCardMessageContainer>
<StyledEmailContent>
<StyledEmailTop>
<StyledEmailTitle>
<span>Subject not shared</span>
</StyledEmailTitle>
</StyledEmailTop>
<StyledEmailBodyNotShareContainer>
<StyledEmailBodyNotShare>
<StyledEmailBodyNotSharedIconContainer>
<IconLock />
</StyledEmailBodyNotSharedIconContainer>
<span>Not shared by {sharedByFullName}</span>
</StyledEmailBodyNotShare>
</StyledEmailBodyNotShareContainer>
</StyledEmailContent>
</StyledEventCardMessageContainer>
);
};

View File

@ -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<EventRowMessageProps> = ({
labelIdentifierValue,
event,
authorFullName,
}: EventRowMessageProps) => {
const [, eventAction] = event.name.split('.');
const [isOpen, setIsOpen] = useState(false);
const renderRow = () => {
switch (eventAction) {
case 'linked': {
return (
<>
<StyledItemAuthorText>{authorFullName}</StyledItemAuthorText>
<StyledItemAction>linked an email with</StyledItemAction>
<StyledItemLabelIdentifier>
{labelIdentifierValue}
</StyledItemLabelIdentifier>
</>
);
}
default:
throw new Error('Invalid event action for message event type.');
}
};
return (
<StyledEventRowMessageContainer>
<StyledRowContainer>
{renderRow()}
<EventCardToggleButton isOpen={isOpen} setIsOpen={setIsOpen} />
</StyledRowContainer>
<EventCard isOpen={isOpen}>
<EventCardMessage
messageId={event.linkedRecordId}
authorFullName={authorFullName}
/>
</EventCard>
</StyledEventRowMessageContainer>
);
};

View File

@ -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);
});
});

View File

@ -2,10 +2,13 @@ import { createState } from 'twenty-ui';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export const currentWorkspaceMemberState = createState<Omit<
export type CurrentWorkspaceMember = Omit<
WorkspaceMember,
'createdAt' | 'updatedAt' | 'userId' | 'userEmail' | '__typename'
> | null>({
key: 'currentWorkspaceMemberState',
defaultValue: null,
});
>;
export const currentWorkspaceMemberState =
createState<CurrentWorkspaceMember | null>({
key: 'currentWorkspaceMemberState',
defaultValue: null,
});

View File

@ -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)

View File

@ -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 ? (
<>
<TimelineActivitiesQueryEffect
targetableObject={targetableObject}
/>
<TimelineActivities targetableObject={targetableObject} />
</>
) : (
<>
<TimelineQueryEffect targetableObject={targetableObject} />
<Timeline loading={loading} targetableObject={targetableObject} />
</>
);
case 'summary':
return summary;
case 'tasks':
return <ObjectTasks targetableObject={targetableObject} />;
case 'notes':
return <Notes targetableObject={targetableObject} />;
case 'files':
return <Attachments targetableObject={targetableObject} />;
case 'emails':
return <EmailThreads targetableObject={targetableObject} />;
case 'calendar':
return <Calendar targetableObject={targetableObject} />;
default:
return <></>;
}
};
return (
<StyledShowPageRightContainer isMobile={isMobile}>
<StyledTabListContainer>
<TabList
loading={loading}
tabListId={TAB_LIST_COMPONENT_ID + isRightDrawer}
tabs={TASK_TABS}
tabs={tabs}
/>
</StyledTabListContainer>
{activeTabId === 'summary' && summary}
{activeTabId === 'timeline' && (
<>
<TimelineQueryEffect targetableObject={targetableObject} />
<Timeline loading={loading} targetableObject={targetableObject} />
</>
)}
{activeTabId === 'tasks' && (
<ObjectTasks targetableObject={targetableObject} />
)}
{activeTabId === 'notes' && <Notes targetableObject={targetableObject} />}
{activeTabId === 'files' && (
<Attachments targetableObject={targetableObject} />
)}
{activeTabId === 'emails' && (
<EmailThreads targetableObject={targetableObject} />
)}
{activeTabId === 'calendar' && (
<Calendar targetableObject={targetableObject} />
)}
{activeTabId === 'logs' && (
<TimelineActivities targetableObject={targetableObject} />
)}
{}
{renderActiveTabContent()}
</StyledShowPageRightContainer>
);
};

View File

@ -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 = () => {
</>
</PageHeader>
<PageBody>
<RecordShowContainer
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
loading={loading}
/>
<TimelineActivityContext.Provider
value={{
labelIdentifierValue: pageName,
}}
>
<RecordShowContainer
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
loading={loading}
/>
</TimelineActivityContext.Provider>
</PageBody>
</PageContainer>
</RecordFieldValueSelectorContextProvider>

View File

@ -2,13 +2,13 @@ import { TimelineActivity } from '@/activities/timelineActivities/types/Timeline
export const mockedTimelineActivities: Array<TimelineActivity> = [
{
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<TimelineActivity> = [
__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',

View File

@ -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(/\/$/, '')

View File

@ -33,7 +33,7 @@ export const seedFeatureFlags = async (
{
key: FeatureFlagKeys.IsEventObjectEnabled,
workspaceId: workspaceId,
value: true,
value: false,
},
{
key: FeatureFlagKeys.IsStripeIntegrationEnabled,

View File

@ -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<any>) {
private async handle(payload: ObjectRecordBaseEvent) {
if (!payload.objectMetadata.isAuditLogged) {
return;
}

View File

@ -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,

View File

@ -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,

View File

@ -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({

View File

@ -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,

View File

@ -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 {}

View File

@ -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,
);

View File

@ -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<ObjectMetadataEntity>,
) {}
@OnEvent('calendarEventParticipant.matched')
public async handleCalendarEventParticipantMatchedEvent(payload: {
workspaceId: string;
userId: string;
calendarEventParticipants: ObjectRecord<CalendarEventParticipantWorkspaceEntity>[];
}): Promise<void> {
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,
);
}
}

View File

@ -51,6 +51,23 @@ export class CalendarEventParticipantRepository {
);
}
public async updateParticipantsPersonIdAndReturn(
participantIds: string[],
personId: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<ObjectRecord<CalendarEventParticipantWorkspaceEntity>[]> {
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,

View File

@ -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<PersonWorkspaceEntity>[],
workspaceId: string,
transactionManager?: EntityManager,
): Promise<void> {
): Promise<ObjectRecord<CalendarEventParticipantWorkspaceEntity>[]> {
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<void> {
): Promise<ObjectRecord<CalendarEventParticipantWorkspaceEntity>[]> {
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(

View File

@ -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<ConnectedAccountWorkspaceEntity>,
calendarChannel: CalendarChannelWorkspaceEntity,
workspaceId: string,
): Promise<void> {
@ -409,8 +412,11 @@ export class GoogleCalendarSyncService {
let startTime: number;
let endTime: number;
const savedCalendarEventParticipantsToEmit: ObjectRecord<CalendarEventParticipantWorkspaceEntity>[] =
[];
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<CreateCompanyAndContactJobData>(
CreateCompanyAndContactJob.name,
{
workspaceId,
connectedAccountHandle: connectedAccount.handle,
connectedAccount,
contactsToCreate: participantsToSave,
},
);

View File

@ -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<ConnectedAccountWorkspaceEntity>;
contactsToCreate: {
displayName: string;
handle: string;
@ -22,10 +24,10 @@ export class CreateCompanyAndContactJob
) {}
async handle(data: CreateCompanyAndContactJobData): Promise<void> {
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,

View File

@ -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<ConnectedAccountWorkspaceEntity>,
contactsToCreate: Contacts,
workspaceId: string,
) {
@ -134,27 +139,46 @@ export class CreateCompanyAndContactService {
workspaceId,
);
let updatedMessageParticipants: ObjectRecord<MessageParticipantWorkspaceEntity>[] =
[];
let updatedCalendarEventParticipants: ObjectRecord<CalendarEventParticipantWorkspaceEntity>[] =
[];
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,
});
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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<void> {
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,
);
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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<PersonWorkspaceEntity>[],
workspaceId: string,
transactionManager?: EntityManager,
): Promise<void> {
): Promise<ObjectRecord<MessageParticipantWorkspaceEntity>[]> {
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<void> {
if (!participants) return;
): Promise<ObjectRecord<MessageParticipantWorkspaceEntity>[]> {
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(

View File

@ -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<FeatureFlagEntity>,
private readonly eventEmitter: EventEmitter2,
) {}
async saveMessagesAndEnqueueContactCreationJob(
@ -57,6 +61,9 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
const isContactCreationForSentAndReceivedEmailsEnabled =
isContactCreationForSentAndReceivedEmailsEnabledFeatureFlag?.value;
let savedMessageParticipants: ObjectRecord<MessageParticipantWorkspaceEntity>[] =
[];
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,
},
);

View File

@ -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<FeatureFlagEntity>,
@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,
);

View File

@ -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<ObjectMetadataEntity>,
) {}
@OnEvent('messageParticipant.matched')
public async handleMessageParticipantMatched(payload: {
workspaceId: string;
userId: string;
messageParticipants: ObjectRecord<MessageParticipantWorkspaceEntity>[];
}): Promise<void> {
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,
);
}
}

View File

@ -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 {}

View File

@ -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<string, any> | 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,
);
}
}