add recurring calendar events for google cal (#19748)
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+1
@@ -47,6 +47,7 @@ export class GoogleCalendarGetEventsService {
|
||||
.list({
|
||||
calendarId: 'primary',
|
||||
maxResults: 500,
|
||||
singleEvents: true,
|
||||
syncToken: syncCursor,
|
||||
pageToken: nextPageToken,
|
||||
showDeleted: true,
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { CalendarSaveEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-save-events.service';
|
||||
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
const RECURRING_ICAL_UID = 'recurring-ical-uid@google.com';
|
||||
const RECURRING_MASTER_ID = 'master_abc';
|
||||
|
||||
const mockCalendarEventRepository = {
|
||||
insert: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAssociationRepository = {
|
||||
find: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCalendarEventParticipantService = {
|
||||
upsertAndDeleteCalendarEventParticipants: jest.fn(),
|
||||
};
|
||||
|
||||
const mockGlobalWorkspaceOrmManager = {
|
||||
executeInWorkspaceContext: jest.fn(async (callback: () => Promise<void>) => {
|
||||
await callback();
|
||||
}),
|
||||
getRepository: jest.fn(async (_workspaceId: string, entityName: string) => {
|
||||
if (entityName === 'calendarEvent') return mockCalendarEventRepository;
|
||||
if (entityName === 'calendarChannelEventAssociation')
|
||||
return mockAssociationRepository;
|
||||
|
||||
return {};
|
||||
}),
|
||||
getGlobalWorkspaceDataSource: jest.fn(async () => ({
|
||||
transaction: async (callback: (manager: unknown) => Promise<void>) => {
|
||||
await callback({} as any);
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
const calendarChannel = {
|
||||
id: 'channel-123',
|
||||
} as unknown as CalendarChannelEntity;
|
||||
|
||||
const connectedAccount = {
|
||||
id: 'account-123',
|
||||
} as unknown as ConnectedAccountEntity;
|
||||
|
||||
const createFetchedEvent = (
|
||||
overrides: Partial<FetchedCalendarEvent> = {},
|
||||
): FetchedCalendarEvent => ({
|
||||
id: 'event-1',
|
||||
iCalUid: 'single-event-uid@google.com',
|
||||
title: 'Team Meeting',
|
||||
startsAt: '2026-04-10T16:30:00+03:00',
|
||||
endsAt: '2026-04-10T17:00:00+03:00',
|
||||
description: '',
|
||||
location: '',
|
||||
isFullDay: false,
|
||||
isCanceled: false,
|
||||
conferenceLinkLabel: '',
|
||||
conferenceLinkUrl: '',
|
||||
externalCreatedAt: '',
|
||||
externalUpdatedAt: '',
|
||||
conferenceSolution: '',
|
||||
participants: [],
|
||||
status: 'confirmed',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('CalendarSaveEventsService', () => {
|
||||
let service: CalendarSaveEventsService;
|
||||
|
||||
const save = (events: FetchedCalendarEvent[]) =>
|
||||
service.saveCalendarEventsAndEnqueueContactCreationJob(
|
||||
events,
|
||||
calendarChannel,
|
||||
connectedAccount,
|
||||
'workspace-123',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockAssociationRepository.find.mockResolvedValue([]);
|
||||
|
||||
service = new CalendarSaveEventsService(
|
||||
mockGlobalWorkspaceOrmManager as any,
|
||||
mockCalendarEventParticipantService as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('should insert each recurring instance as a separate event', async () => {
|
||||
await save([
|
||||
createFetchedEvent({
|
||||
id: `${RECURRING_MASTER_ID}_20260403`,
|
||||
iCalUid: RECURRING_ICAL_UID,
|
||||
recurringEventExternalId: RECURRING_MASTER_ID,
|
||||
startsAt: '2026-04-03T16:30:00+03:00',
|
||||
}),
|
||||
createFetchedEvent({
|
||||
id: `${RECURRING_MASTER_ID}_20260410`,
|
||||
iCalUid: RECURRING_ICAL_UID,
|
||||
recurringEventExternalId: RECURRING_MASTER_ID,
|
||||
startsAt: '2026-04-10T16:30:00+03:00',
|
||||
}),
|
||||
createFetchedEvent({
|
||||
id: `${RECURRING_MASTER_ID}_20260417`,
|
||||
iCalUid: RECURRING_ICAL_UID,
|
||||
recurringEventExternalId: RECURRING_MASTER_ID,
|
||||
startsAt: '2026-04-17T16:30:00+03:00',
|
||||
}),
|
||||
]);
|
||||
|
||||
const insertedEvents = mockCalendarEventRepository.insert.mock.calls[0][0];
|
||||
|
||||
expect(insertedEvents).toHaveLength(3);
|
||||
expect(new Set(insertedEvents.map((e: any) => e.startsAt))).toEqual(
|
||||
new Set([
|
||||
'2026-04-03T16:30:00+03:00',
|
||||
'2026-04-10T16:30:00+03:00',
|
||||
'2026-04-17T16:30:00+03:00',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update existing events and only insert new ones on incremental sync', async () => {
|
||||
mockAssociationRepository.find.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'assoc-403',
|
||||
eventExternalId: `${RECURRING_MASTER_ID}_20260403`,
|
||||
calendarEventId: 'existing-db-id-403',
|
||||
calendarChannelId: calendarChannel.id,
|
||||
},
|
||||
]);
|
||||
|
||||
await save([
|
||||
createFetchedEvent({
|
||||
id: `${RECURRING_MASTER_ID}_20260403`,
|
||||
iCalUid: RECURRING_ICAL_UID,
|
||||
recurringEventExternalId: RECURRING_MASTER_ID,
|
||||
title: 'Weekly Sync (Renamed)',
|
||||
startsAt: '2026-04-03T16:30:00+03:00',
|
||||
}),
|
||||
createFetchedEvent({
|
||||
id: `${RECURRING_MASTER_ID}_20260410`,
|
||||
iCalUid: RECURRING_ICAL_UID,
|
||||
startsAt: '2026-04-10T16:30:00+03:00',
|
||||
}),
|
||||
]);
|
||||
|
||||
const insertedEvents = mockCalendarEventRepository.insert.mock.calls[0][0];
|
||||
|
||||
expect(insertedEvents).toHaveLength(1);
|
||||
expect(insertedEvents[0].startsAt).toBe('2026-04-10T16:30:00+03:00');
|
||||
|
||||
const updatedEvents =
|
||||
mockCalendarEventRepository.updateMany.mock.calls[0][0];
|
||||
|
||||
expect(updatedEvents).toHaveLength(1);
|
||||
expect(updatedEvents[0].criteria).toBe('existing-db-id-403');
|
||||
expect(updatedEvents[0].partialEntity.title).toBe('Weekly Sync (Renamed)');
|
||||
|
||||
const insertedAssociations =
|
||||
mockAssociationRepository.insert.mock.calls[0][0];
|
||||
|
||||
expect(insertedAssociations).toHaveLength(1);
|
||||
expect(insertedAssociations[0].eventExternalId).toBe(
|
||||
`${RECURRING_MASTER_ID}_20260410`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should only update without inserting when all events already exist', async () => {
|
||||
mockAssociationRepository.find.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'assoc-1',
|
||||
eventExternalId: 'event-1',
|
||||
calendarEventId: 'db-id-1',
|
||||
calendarChannelId: calendarChannel.id,
|
||||
},
|
||||
{
|
||||
id: 'assoc-2',
|
||||
eventExternalId: 'event-2',
|
||||
calendarEventId: 'db-id-2',
|
||||
calendarChannelId: calendarChannel.id,
|
||||
},
|
||||
]);
|
||||
|
||||
await save([
|
||||
createFetchedEvent({ id: 'event-1' }),
|
||||
createFetchedEvent({ id: 'event-2' }),
|
||||
]);
|
||||
|
||||
expect(mockCalendarEventRepository.insert).not.toHaveBeenCalled();
|
||||
expect(mockCalendarEventRepository.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(mockAssociationRepository.insert).not.toHaveBeenCalled();
|
||||
expect(mockAssociationRepository.updateMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+97
-52
@@ -52,56 +52,85 @@ export class CalendarSaveEventsService {
|
||||
|
||||
await workspaceDataSource.transaction(
|
||||
async (transactionManager: WorkspaceEntityManager) => {
|
||||
const existingCalendarEvents = await calendarEventRepository.find(
|
||||
{
|
||||
where: {
|
||||
iCalUid: Any(
|
||||
fetchedCalendarEvents.map((event) => event.iCalUid as string),
|
||||
),
|
||||
const existingAssociations =
|
||||
await calendarChannelEventAssociationRepository.find(
|
||||
{
|
||||
where: {
|
||||
eventExternalId: Any(
|
||||
fetchedCalendarEvents.map((event) => event.id),
|
||||
),
|
||||
calendarChannelId: calendarChannel.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
transactionManager,
|
||||
transactionManager,
|
||||
);
|
||||
|
||||
const existingCalendarEventIdByExternalId = new Map(
|
||||
existingAssociations.map((association) => [
|
||||
association.eventExternalId,
|
||||
association.calendarEventId,
|
||||
]),
|
||||
);
|
||||
|
||||
const existingAssociationIdByExternalId = new Map(
|
||||
existingAssociations.map((association) => [
|
||||
association.eventExternalId,
|
||||
association.id,
|
||||
]),
|
||||
);
|
||||
|
||||
const fetchedCalendarEventsWithDBEvents: FetchedCalendarEventWithDBEvent[] =
|
||||
fetchedCalendarEvents.map(
|
||||
(event): FetchedCalendarEventWithDBEvent => {
|
||||
const existingEventWithSameiCalUid =
|
||||
existingCalendarEvents.find(
|
||||
(existingEvent) => existingEvent.iCalUid === event.iCalUid,
|
||||
);
|
||||
const existingCalendarEventId =
|
||||
existingCalendarEventIdByExternalId.get(event.id);
|
||||
|
||||
return {
|
||||
fetchedCalendarEvent: event,
|
||||
existingCalendarEvent: existingEventWithSameiCalUid ?? null,
|
||||
existingCalendarEvent: existingCalendarEventId
|
||||
? ({
|
||||
id: existingCalendarEventId,
|
||||
} as CalendarEventWorkspaceEntity)
|
||||
: null,
|
||||
newlyCreatedCalendarEvent: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const newCalendarEventIdByExternalId = new Map<string, string>();
|
||||
|
||||
const newCalendarEventsToInsert = fetchedCalendarEventsWithDBEvents
|
||||
.filter(
|
||||
({ existingCalendarEvent }) => existingCalendarEvent === null,
|
||||
)
|
||||
.map(({ fetchedCalendarEvent }) => ({
|
||||
id: uuid(),
|
||||
iCalUid: fetchedCalendarEvent.iCalUid,
|
||||
title: fetchedCalendarEvent.title,
|
||||
description: fetchedCalendarEvent.description,
|
||||
startsAt: fetchedCalendarEvent.startsAt,
|
||||
endsAt: fetchedCalendarEvent.endsAt,
|
||||
location: fetchedCalendarEvent.location,
|
||||
isFullDay: fetchedCalendarEvent.isFullDay,
|
||||
isCanceled: fetchedCalendarEvent.isCanceled,
|
||||
conferenceSolution: fetchedCalendarEvent.conferenceSolution,
|
||||
conferenceLink: {
|
||||
primaryLinkLabel: fetchedCalendarEvent.conferenceLinkLabel,
|
||||
primaryLinkUrl: fetchedCalendarEvent.conferenceLinkUrl,
|
||||
secondaryLinks: [],
|
||||
},
|
||||
externalCreatedAt: fetchedCalendarEvent.externalCreatedAt,
|
||||
externalUpdatedAt: fetchedCalendarEvent.externalUpdatedAt,
|
||||
}));
|
||||
.map(({ fetchedCalendarEvent }) => {
|
||||
const calendarEventId = uuid();
|
||||
|
||||
newCalendarEventIdByExternalId.set(
|
||||
fetchedCalendarEvent.id,
|
||||
calendarEventId,
|
||||
);
|
||||
|
||||
return {
|
||||
id: calendarEventId,
|
||||
iCalUid: fetchedCalendarEvent.iCalUid,
|
||||
title: fetchedCalendarEvent.title,
|
||||
description: fetchedCalendarEvent.description,
|
||||
startsAt: fetchedCalendarEvent.startsAt,
|
||||
endsAt: fetchedCalendarEvent.endsAt,
|
||||
location: fetchedCalendarEvent.location,
|
||||
isFullDay: fetchedCalendarEvent.isFullDay,
|
||||
isCanceled: fetchedCalendarEvent.isCanceled,
|
||||
conferenceSolution: fetchedCalendarEvent.conferenceSolution,
|
||||
conferenceLink: {
|
||||
primaryLinkLabel: fetchedCalendarEvent.conferenceLinkLabel,
|
||||
primaryLinkUrl: fetchedCalendarEvent.conferenceLinkUrl,
|
||||
secondaryLinks: [],
|
||||
},
|
||||
externalCreatedAt: fetchedCalendarEvent.externalCreatedAt,
|
||||
externalUpdatedAt: fetchedCalendarEvent.externalUpdatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
if (newCalendarEventsToInsert.length > 0) {
|
||||
await calendarEventRepository.insert(
|
||||
@@ -113,18 +142,16 @@ export class CalendarSaveEventsService {
|
||||
const fetchedCalendarEventsWithDBEventsEnrichedWithSavedEvents: FetchedCalendarEventWithDBEvent[] =
|
||||
fetchedCalendarEventsWithDBEvents.map(
|
||||
({ fetchedCalendarEvent, existingCalendarEvent }) => {
|
||||
const savedCalendarEvent = newCalendarEventsToInsert.find(
|
||||
(inserted) =>
|
||||
inserted.iCalUid === fetchedCalendarEvent.iCalUid,
|
||||
const savedCalendarEventId = newCalendarEventIdByExternalId.get(
|
||||
fetchedCalendarEvent.id,
|
||||
);
|
||||
|
||||
return {
|
||||
fetchedCalendarEvent,
|
||||
existingCalendarEvent: existingCalendarEvent,
|
||||
newlyCreatedCalendarEvent: savedCalendarEvent
|
||||
existingCalendarEvent,
|
||||
newlyCreatedCalendarEvent: savedCalendarEventId
|
||||
? ({
|
||||
id: savedCalendarEvent.id,
|
||||
iCalUid: savedCalendarEvent.iCalUid,
|
||||
id: savedCalendarEventId,
|
||||
} as CalendarEventWorkspaceEntity)
|
||||
: null,
|
||||
};
|
||||
@@ -180,30 +207,26 @@ export class CalendarSaveEventsService {
|
||||
| 'eventExternalId'
|
||||
| 'calendarChannelId'
|
||||
| 'recurringEventExternalId'
|
||||
>[] = fetchedCalendarEventsWithDBEventsEnrichedWithSavedEvents.map(
|
||||
({
|
||||
fetchedCalendarEvent,
|
||||
existingCalendarEvent,
|
||||
newlyCreatedCalendarEvent,
|
||||
}) => {
|
||||
const calendarEventId =
|
||||
existingCalendarEvent?.id ?? newlyCreatedCalendarEvent?.id;
|
||||
|
||||
if (!calendarEventId) {
|
||||
>[] = fetchedCalendarEventsWithDBEventsEnrichedWithSavedEvents
|
||||
.filter(
|
||||
({ newlyCreatedCalendarEvent }) =>
|
||||
newlyCreatedCalendarEvent !== null,
|
||||
)
|
||||
.map(({ fetchedCalendarEvent, newlyCreatedCalendarEvent }) => {
|
||||
if (!newlyCreatedCalendarEvent?.id) {
|
||||
throw new Error(
|
||||
`Calendar event id not found for event with iCalUid ${fetchedCalendarEvent.iCalUid} - should never happen`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
calendarEventId,
|
||||
calendarEventId: newlyCreatedCalendarEvent.id,
|
||||
eventExternalId: fetchedCalendarEvent.id,
|
||||
calendarChannelId: calendarChannel.id,
|
||||
recurringEventExternalId:
|
||||
fetchedCalendarEvent.recurringEventExternalId ?? '',
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (calendarChannelEventAssociationsToSave.length > 0) {
|
||||
await calendarChannelEventAssociationRepository.insert(
|
||||
@@ -212,6 +235,28 @@ export class CalendarSaveEventsService {
|
||||
);
|
||||
}
|
||||
|
||||
const existingAssociationsToUpdate =
|
||||
fetchedCalendarEventsWithDBEventsEnrichedWithSavedEvents
|
||||
.filter(
|
||||
({ existingCalendarEvent }) => existingCalendarEvent !== null,
|
||||
)
|
||||
.map(({ fetchedCalendarEvent }) => ({
|
||||
criteria: existingAssociationIdByExternalId.get(
|
||||
fetchedCalendarEvent.id,
|
||||
)!,
|
||||
partialEntity: {
|
||||
recurringEventExternalId:
|
||||
fetchedCalendarEvent.recurringEventExternalId ?? '',
|
||||
},
|
||||
}));
|
||||
|
||||
if (existingAssociationsToUpdate.length > 0) {
|
||||
await calendarChannelEventAssociationRepository.updateMany(
|
||||
existingAssociationsToUpdate,
|
||||
transactionManager,
|
||||
);
|
||||
}
|
||||
|
||||
const participantsToCreate =
|
||||
fetchedCalendarEventsWithDBEventsEnrichedWithSavedEvents
|
||||
.filter(
|
||||
|
||||
Reference in New Issue
Block a user