CalDAV refactor (#20180)
Original CalDAV driver was written almost a year ago and code quality, patterns were not up to the mark including having no test coverage, this PR does the following: - Splits the monolithic driver into isolated utilities with test coverage - Adds support for syncing legacy servers by checking if server supports `syncCollection` and branches into two sync methods `fetchEventsViaSyncCollection` or `fetchEventsViaCtagEtag` with this I believe our driver is feature complete Real testing report | Provider | Server | Sync method | Auth | | --------- | ----------------- | -------------------- | ------ | | iCloud | Apple's CalDAV | sync-collection | Basic | | Nextcloud | sabre/dav | sync-collection | Basic | | all-inkl | sabre/dav (older) | ctag + etag fallback | Digest |
This commit is contained in:
+12
-3
@@ -2,12 +2,21 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider';
|
||||
import { CalDavClientService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service';
|
||||
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
|
||||
import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service';
|
||||
|
||||
@Module({
|
||||
imports: [SecureHttpClientModule, TwentyConfigModule],
|
||||
providers: [CalDavClientProvider, CalDavGetEventsService],
|
||||
exports: [CalDavGetEventsService],
|
||||
providers: [
|
||||
CalDavClientService,
|
||||
CalDavFetchEventsService,
|
||||
CalDavGetEventsService,
|
||||
],
|
||||
exports: [
|
||||
CalDavClientService,
|
||||
CalDavFetchEventsService,
|
||||
CalDavGetEventsService,
|
||||
],
|
||||
})
|
||||
export class CalDavDriverModule {}
|
||||
|
||||
-530
@@ -1,530 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import * as ical from 'node-ical';
|
||||
import {
|
||||
calendarMultiGet,
|
||||
createAccount,
|
||||
type DAVAccount,
|
||||
type DAVCalendar,
|
||||
DAVNamespaceShort,
|
||||
type DAVObject,
|
||||
fetchCalendars,
|
||||
syncCollection,
|
||||
} from 'tsdav';
|
||||
|
||||
import { createBasicDigestAuthFetch } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch';
|
||||
import { icalDataExtractPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue';
|
||||
import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service';
|
||||
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
|
||||
import {
|
||||
type FetchedCalendarEvent,
|
||||
type FetchedCalendarEventParticipant,
|
||||
} from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
const DEFAULT_CALENDAR_TYPE = 'caldav';
|
||||
|
||||
type CalendarCredentials = {
|
||||
username: string;
|
||||
password: string;
|
||||
serverUrl: string;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
};
|
||||
|
||||
type SimpleCalendar = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
isPrimary?: boolean;
|
||||
syncToken?: string | number;
|
||||
};
|
||||
|
||||
type FetchEventsOptions = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
syncCursor?: CalDAVSyncCursor;
|
||||
};
|
||||
|
||||
type CalDAVSyncResult = {
|
||||
events: FetchedCalendarEvent[];
|
||||
newSyncToken?: string;
|
||||
};
|
||||
|
||||
type CalDAVSyncCursor = {
|
||||
syncTokens: Record<string, string>;
|
||||
};
|
||||
|
||||
type CalDAVGetEventsResponse = {
|
||||
events: FetchedCalendarEvent[];
|
||||
syncCursor: CalDAVSyncCursor;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CalDAVClient {
|
||||
private credentials: CalendarCredentials;
|
||||
private logger: Logger;
|
||||
private fetchOverride: typeof fetch;
|
||||
|
||||
constructor(credentials: CalendarCredentials) {
|
||||
this.credentials = credentials;
|
||||
this.logger = new Logger(CalDAVClient.name);
|
||||
this.fetchOverride = createBasicDigestAuthFetch(
|
||||
credentials.username,
|
||||
credentials.password,
|
||||
credentials.fetch ?? globalThis.fetch,
|
||||
);
|
||||
}
|
||||
|
||||
private hasFileExtension(url: string): boolean {
|
||||
const fileName = url.substring(url.lastIndexOf('/') + 1);
|
||||
|
||||
return (
|
||||
fileName.includes('.') &&
|
||||
!fileName.substring(fileName.lastIndexOf('.')).includes('/')
|
||||
);
|
||||
}
|
||||
|
||||
private getFileExtension(url: string): string {
|
||||
if (!this.hasFileExtension(url)) return 'ics';
|
||||
const fileName = url.substring(url.lastIndexOf('/') + 1);
|
||||
|
||||
return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
|
||||
}
|
||||
|
||||
private isValidFormat(url: string): boolean {
|
||||
const allowedExtensions = ['eml', 'ics'];
|
||||
|
||||
return allowedExtensions.includes(this.getFileExtension(url));
|
||||
}
|
||||
|
||||
private async getAccount(): Promise<DAVAccount> {
|
||||
return createAccount({
|
||||
account: {
|
||||
serverUrl: this.credentials.serverUrl,
|
||||
accountType: DEFAULT_CALENDAR_TYPE,
|
||||
credentials: {
|
||||
username: this.credentials.username,
|
||||
password: this.credentials.password,
|
||||
},
|
||||
},
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
}
|
||||
|
||||
async listCalendars(): Promise<SimpleCalendar[]> {
|
||||
try {
|
||||
const account = await this.getAccount();
|
||||
|
||||
const calendars = (await fetchCalendars({
|
||||
account,
|
||||
fetch: this.fetchOverride,
|
||||
})) as (Omit<DAVCalendar, 'displayName'> & {
|
||||
displayName?: string | Record<string, unknown>;
|
||||
})[];
|
||||
|
||||
return calendars.reduce<SimpleCalendar[]>((result, calendar) => {
|
||||
if (!calendar.components?.includes('VEVENT')) return result;
|
||||
|
||||
result.push({
|
||||
id: calendar.url,
|
||||
url: calendar.url,
|
||||
name:
|
||||
typeof calendar.displayName === 'string'
|
||||
? calendar.displayName
|
||||
: 'Unnamed Calendar',
|
||||
isPrimary: false,
|
||||
});
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - getCalendarEvents`,
|
||||
error.code,
|
||||
error,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async validateSyncCollectionSupport(): Promise<void> {
|
||||
const account = await this.getAccount();
|
||||
|
||||
const calendars = await fetchCalendars({
|
||||
account,
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
|
||||
const eventCalendar = calendars.find((calendar) =>
|
||||
calendar.components?.includes('VEVENT'),
|
||||
);
|
||||
|
||||
if (!eventCalendar) {
|
||||
throw new Error('No calendar with event support found');
|
||||
}
|
||||
|
||||
const supportsSyncCollection =
|
||||
eventCalendar.reports?.includes('syncCollection') ?? false;
|
||||
|
||||
if (!supportsSyncCollection) {
|
||||
throw new Error(
|
||||
'CALDAV_SYNC_COLLECTION_NOT_SUPPORTED: Your CalDAV server does not support incremental sync (RFC 6578)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an event is a full-day event by checking the raw iCal data.
|
||||
* Full-day events use VALUE=DATE parameter in DTSTART/DTEND properties.
|
||||
* Since node-ical converts all dates to JavaScript Date objects, we must check the raw data.
|
||||
* @see https://tools.ietf.org/html/rfc5545#section-3.3.4 (DATE Value Type)
|
||||
* @see https://tools.ietf.org/html/rfc5545#section-3.3.5 (DATE-TIME Value Type)
|
||||
* @see https://tools.ietf.org/html/rfc5545#section-3.2.20 (VALUE Parameter)
|
||||
*/
|
||||
private isFullDayEvent(rawICalData: string): boolean {
|
||||
const lines = rawICalData.split(/\r?\n/);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith('DTSTART') &&
|
||||
trimmedLine.includes('VALUE=DATE')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private extractOrganizerFromEvent(
|
||||
event: ical.VEvent,
|
||||
): FetchedCalendarEventParticipant | null {
|
||||
if (!event.organizer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const organizerEmail =
|
||||
// @ts-expect-error - limitation of node-ical typing
|
||||
event.organizer.val?.replace(/^mailto:/i, '') || '';
|
||||
|
||||
return {
|
||||
displayName:
|
||||
// @ts-expect-error - limitation of node-ical typing
|
||||
event.organizer.params?.CN || organizerEmail || 'Unknown',
|
||||
responseStatus: CalendarEventParticipantResponseStatus.ACCEPTED,
|
||||
handle: organizerEmail,
|
||||
isOrganizer: true,
|
||||
};
|
||||
}
|
||||
|
||||
private mapPartStatToResponseStatus(
|
||||
partStat: ical.AttendeePartStat,
|
||||
): CalendarEventParticipantResponseStatus {
|
||||
switch (partStat) {
|
||||
case 'ACCEPTED':
|
||||
return CalendarEventParticipantResponseStatus.ACCEPTED;
|
||||
case 'DECLINED':
|
||||
return CalendarEventParticipantResponseStatus.DECLINED;
|
||||
case 'TENTATIVE':
|
||||
return CalendarEventParticipantResponseStatus.TENTATIVE;
|
||||
case 'NEEDS-ACTION':
|
||||
default:
|
||||
return CalendarEventParticipantResponseStatus.NEEDS_ACTION;
|
||||
}
|
||||
}
|
||||
|
||||
private extractAttendeesFromEvent(
|
||||
event: ical.VEvent,
|
||||
): FetchedCalendarEventParticipant[] {
|
||||
if (!event.attendee) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attendees = Array.isArray(event.attendee)
|
||||
? event.attendee
|
||||
: [event.attendee];
|
||||
|
||||
return attendees.map((attendee: ical.Attendee) => {
|
||||
// @ts-expect-error - limitation of node-ical typing
|
||||
const handle = attendee.val?.replace(/^mailto:/i, '') || '';
|
||||
// @ts-expect-error - limitation of node-ical typing
|
||||
const displayName = attendee.params?.CN || handle || 'Unknown';
|
||||
// @ts-expect-error - limitation of node-ical typing
|
||||
const partStat = attendee.params?.PARTSTAT || 'NEEDS_ACTION';
|
||||
|
||||
return {
|
||||
displayName,
|
||||
responseStatus: this.mapPartStatToResponseStatus(partStat),
|
||||
handle,
|
||||
isOrganizer: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private extractParticipantsFromEvent(
|
||||
event: ical.VEvent,
|
||||
): FetchedCalendarEventParticipant[] {
|
||||
const participants: FetchedCalendarEventParticipant[] = [];
|
||||
|
||||
const organizer = this.extractOrganizerFromEvent(event);
|
||||
|
||||
if (organizer) {
|
||||
participants.push(organizer);
|
||||
}
|
||||
|
||||
const attendees = this.extractAttendeesFromEvent(event);
|
||||
|
||||
participants.push(...attendees);
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
||||
private parseICalData(
|
||||
rawData: string,
|
||||
objectUrl: string,
|
||||
): FetchedCalendarEvent | null {
|
||||
try {
|
||||
const parsed = ical.parseICS(rawData);
|
||||
const events = Object.values(parsed).filter(
|
||||
(item) => item.type === 'VEVENT',
|
||||
);
|
||||
|
||||
if (events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = events[0] as ical.VEvent;
|
||||
const participants = this.extractParticipantsFromEvent(event);
|
||||
|
||||
const title = icalDataExtractPropertyValue(
|
||||
event.summary,
|
||||
'Untitled Event',
|
||||
);
|
||||
const description = icalDataExtractPropertyValue(event.description);
|
||||
const location = icalDataExtractPropertyValue(event.location);
|
||||
const conferenceLinkUrl = icalDataExtractPropertyValue(event.url);
|
||||
|
||||
return {
|
||||
id: objectUrl,
|
||||
title,
|
||||
iCalUid: event.uid || '',
|
||||
description,
|
||||
startsAt: event.start.toISOString(),
|
||||
endsAt: event.end.toISOString(),
|
||||
location,
|
||||
isFullDay: this.isFullDayEvent(rawData),
|
||||
isCanceled: event.status === 'CANCELLED',
|
||||
conferenceLinkLabel: '',
|
||||
conferenceLinkUrl,
|
||||
externalCreatedAt:
|
||||
event.created?.toISOString() || new Date().toISOString(),
|
||||
externalUpdatedAt:
|
||||
event.lastmodified?.toISOString() ||
|
||||
event.created?.toISOString() ||
|
||||
new Date().toISOString(),
|
||||
conferenceSolution: '',
|
||||
recurringEventExternalId: event.recurrenceid
|
||||
? String(event.recurrenceid)
|
||||
: undefined,
|
||||
participants,
|
||||
status: event.status || 'CONFIRMED',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - parseICalData`,
|
||||
error,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getEvents(
|
||||
options: FetchEventsOptions,
|
||||
): Promise<CalDAVGetEventsResponse> {
|
||||
const calendars = await this.listCalendars();
|
||||
const results = new Map<string, CalDAVSyncResult>();
|
||||
|
||||
const syncPromises = calendars.map(async (calendar) => {
|
||||
try {
|
||||
const syncToken =
|
||||
options.syncCursor?.syncTokens[calendar.url] ||
|
||||
calendar.syncToken?.toString();
|
||||
|
||||
const syncResult = await syncCollection({
|
||||
url: calendar.url,
|
||||
props: {
|
||||
[`${DAVNamespaceShort.DAV}:getetag`]: {},
|
||||
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
|
||||
},
|
||||
syncLevel: 1,
|
||||
...(syncToken ? { syncToken } : {}),
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
|
||||
const allEvents: FetchedCalendarEvent[] = [];
|
||||
|
||||
const objectUrls = syncResult
|
||||
.map((event) => event.href)
|
||||
.filter((href): href is string => !!href && this.isValidFormat(href));
|
||||
|
||||
if (objectUrls.length > 0) {
|
||||
try {
|
||||
const calendarObjects = await calendarMultiGet({
|
||||
url: calendar.url,
|
||||
props: {
|
||||
[`${DAVNamespaceShort.DAV}:getetag`]: {},
|
||||
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
|
||||
},
|
||||
objectUrls: objectUrls,
|
||||
depth: '1',
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
|
||||
for (const calendarObject of calendarObjects) {
|
||||
if (calendarObject.props?.calendarData) {
|
||||
const iCalData = this.extractICalData(
|
||||
calendarObject.props?.calendarData,
|
||||
);
|
||||
|
||||
if (!iCalData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = this.parseICalData(
|
||||
iCalData,
|
||||
calendarObject.href || '',
|
||||
);
|
||||
|
||||
if (
|
||||
event &&
|
||||
this.isEventInTimeRange(
|
||||
{
|
||||
url: calendarObject.href || '',
|
||||
data: calendarObject.props.calendarData,
|
||||
etag: calendarObject.props.getetag,
|
||||
},
|
||||
options.startDate,
|
||||
options.endDate,
|
||||
)
|
||||
) {
|
||||
allEvents.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (fetchError) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - getEvents`,
|
||||
fetchError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let newSyncToken = syncToken;
|
||||
|
||||
try {
|
||||
const account = await this.getAccount();
|
||||
const updatedCalendars = await fetchCalendars({
|
||||
account,
|
||||
fetch: this.fetchOverride,
|
||||
});
|
||||
const updatedCalendar = updatedCalendars.find(
|
||||
(cal) => cal.url === calendar.url,
|
||||
);
|
||||
|
||||
if (updatedCalendar?.syncToken) {
|
||||
newSyncToken = updatedCalendar.syncToken.toString();
|
||||
}
|
||||
} catch (syncTokenError) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - getEvents`,
|
||||
syncTokenError,
|
||||
);
|
||||
}
|
||||
|
||||
results.set(calendar.url, {
|
||||
events: allEvents,
|
||||
newSyncToken,
|
||||
});
|
||||
} catch {
|
||||
results.set(calendar.url, {
|
||||
events: [],
|
||||
newSyncToken: options.syncCursor?.syncTokens[calendar.url],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(syncPromises);
|
||||
|
||||
const allEvents = Array.from(results.values())
|
||||
.map((result) => result.events)
|
||||
.flat();
|
||||
|
||||
const syncTokens: Record<string, string> = {};
|
||||
|
||||
for (const [calendarUrl, result] of results) {
|
||||
if (result.newSyncToken) {
|
||||
syncTokens[calendarUrl] = result.newSyncToken;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events: allEvents,
|
||||
syncCursor: { syncTokens },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts iCal data from various CalDAV server response formats.
|
||||
* Some servers return data directly as a string, others nest it under _cdata or some other properties.
|
||||
*/
|
||||
private extractICalData(
|
||||
calendarData: string | Record<string, unknown>,
|
||||
): string | null {
|
||||
if (!calendarData) return null;
|
||||
|
||||
if (
|
||||
typeof calendarData === 'string' &&
|
||||
calendarData.includes('VCALENDAR')
|
||||
) {
|
||||
return calendarData;
|
||||
}
|
||||
|
||||
if (typeof calendarData === 'object' && calendarData !== null) {
|
||||
for (const key in calendarData) {
|
||||
const result = this.extractICalData(
|
||||
calendarData[key] as string | Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private isEventInTimeRange(
|
||||
davObject: DAVObject,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): boolean {
|
||||
try {
|
||||
if (!davObject.data) return false;
|
||||
|
||||
const parsed = ical.parseICS(davObject.data);
|
||||
const events = Object.values(parsed).filter(
|
||||
(item) => item.type === 'VEVENT',
|
||||
);
|
||||
|
||||
if (events.length === 0) return false;
|
||||
|
||||
const event = events[0] as ical.VEvent;
|
||||
|
||||
return event.start < endDate && event.end > startDate;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider';
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
|
||||
jest.mock(
|
||||
'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client',
|
||||
() => ({
|
||||
CalDAVClient: jest.fn().mockImplementation((creds) => creds),
|
||||
}),
|
||||
);
|
||||
|
||||
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
|
||||
|
||||
const MockCalDAVClient = jest.mocked(CalDAVClient);
|
||||
|
||||
describe('CalDavClientProvider', () => {
|
||||
describe('getCalDavCalendarClient', () => {
|
||||
it('should pass SSRF-safe fetch to CalDAVClient', async () => {
|
||||
const fakeFetch = jest.fn();
|
||||
const mockSecureHttpClientService = {
|
||||
createSsrfSafeFetch: jest.fn().mockReturnValue(fakeFetch),
|
||||
} as unknown as SecureHttpClientService;
|
||||
|
||||
const provider = new CalDavClientProvider(mockSecureHttpClientService);
|
||||
|
||||
const connectedAccount = {
|
||||
id: 'account-1',
|
||||
provider: 'IMAP_SMTP_CALDAV',
|
||||
handle: 'user@example.com',
|
||||
connectionParameters: {
|
||||
CALDAV: {
|
||||
host: 'https://caldav.example.com',
|
||||
password: 'secret',
|
||||
username: 'caldav-user',
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<
|
||||
ConnectedAccountEntity,
|
||||
'id' | 'provider' | 'connectionParameters' | 'handle'
|
||||
>;
|
||||
|
||||
await provider.getCalDavCalendarClient(connectedAccount);
|
||||
|
||||
expect(
|
||||
mockSecureHttpClientService.createSsrfSafeFetch,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(MockCalDAVClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fetch: fakeFetch }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CalDavClientProvider {
|
||||
constructor(
|
||||
private readonly secureHttpClientService: SecureHttpClientService,
|
||||
) {}
|
||||
|
||||
public async getCalDavCalendarClient(
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountEntity,
|
||||
'id' | 'provider' | 'connectionParameters' | 'handle'
|
||||
>,
|
||||
): Promise<CalDAVClient> {
|
||||
if (
|
||||
!connectedAccount.connectionParameters?.CALDAV?.password ||
|
||||
!connectedAccount.connectionParameters?.CALDAV?.host ||
|
||||
!isDefined(connectedAccount.handle)
|
||||
) {
|
||||
throw new Error('Missing required CalDAV connection parameters');
|
||||
}
|
||||
|
||||
const serverUrl = connectedAccount.connectionParameters.CALDAV.host;
|
||||
const ssrfSafeFetch = this.secureHttpClientService.createSsrfSafeFetch();
|
||||
|
||||
return new CalDAVClient({
|
||||
username:
|
||||
connectedAccount.connectionParameters.CALDAV.username ??
|
||||
connectedAccount.handle,
|
||||
password: connectedAccount.connectionParameters.CALDAV.password,
|
||||
serverUrl,
|
||||
fetch: ssrfSafeFetch,
|
||||
});
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { DAVClient } from 'tsdav';
|
||||
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { createBasicDigestAuthFetch } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/auth/create-basic-digest-auth-fetch';
|
||||
|
||||
type CalDavConnectionParams = {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CalDavClientService {
|
||||
constructor(
|
||||
private readonly secureHttpClientService: SecureHttpClientService,
|
||||
) {}
|
||||
|
||||
async getClient(input: CalDavConnectionParams): Promise<DAVClient> {
|
||||
const ssrfSafeFetch = this.secureHttpClientService.createSsrfSafeFetch();
|
||||
const fetch = createBasicDigestAuthFetch(
|
||||
input.username,
|
||||
input.password,
|
||||
ssrfSafeFetch,
|
||||
);
|
||||
|
||||
const client = new DAVClient({
|
||||
serverUrl: input.serverUrl,
|
||||
credentials: { username: input.username, password: input.password },
|
||||
authMethod: 'Custom',
|
||||
// our fetch handles Basic+Digest itself; no-op authFunction so tsdav doesn't add its own header on top
|
||||
authFunction: async () => ({}),
|
||||
defaultAccountType: 'caldav',
|
||||
fetch,
|
||||
});
|
||||
|
||||
await client.login();
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
import { type DAVClient } from 'tsdav';
|
||||
|
||||
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
|
||||
|
||||
const PRIMARY_URL = 'https://caldav.example.com/calendars/user/primary/';
|
||||
const PERSONAL_URL = 'https://caldav.example.com/calendars/user/personal/';
|
||||
const HREF_A = `${PRIMARY_URL}event-a.ics`;
|
||||
const HREF_B = `${PRIMARY_URL}event-b.ics`;
|
||||
|
||||
const buildICal = (uid: string) =>
|
||||
[
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid}`,
|
||||
`SUMMARY:${uid}`,
|
||||
'DTSTART:20260601T100000Z',
|
||||
'DTEND:20260601T110000Z',
|
||||
'STATUS:CONFIRMED',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const inWindow = {
|
||||
startDate: new Date('2026-01-01'),
|
||||
endDate: new Date('2027-01-01'),
|
||||
};
|
||||
|
||||
const buildClient = () => {
|
||||
const fetchCalendars = jest.fn();
|
||||
const syncCollection = jest.fn();
|
||||
const calendarMultiGet = jest.fn();
|
||||
const propfind = jest.fn();
|
||||
|
||||
return {
|
||||
client: {
|
||||
fetchCalendars,
|
||||
syncCollection,
|
||||
calendarMultiGet,
|
||||
propfind,
|
||||
} as unknown as DAVClient,
|
||||
fetchCalendars,
|
||||
syncCollection,
|
||||
calendarMultiGet,
|
||||
propfind,
|
||||
};
|
||||
};
|
||||
|
||||
describe('CalDavFetchEventsService', () => {
|
||||
let service: CalDavFetchEventsService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CalDavFetchEventsService();
|
||||
});
|
||||
|
||||
describe('per-calendar tier dispatch', () => {
|
||||
it('runs Tier-1 on calendars advertising sync-collection and Tier-2/3 on the rest, in parallel', async () => {
|
||||
const c = buildClient();
|
||||
|
||||
c.fetchCalendars.mockResolvedValue([
|
||||
{
|
||||
url: PRIMARY_URL,
|
||||
components: ['VEVENT'],
|
||||
reports: ['syncCollection'],
|
||||
},
|
||||
{ url: PERSONAL_URL, components: ['VEVENT'], reports: [], ctag: 'c-1' },
|
||||
]);
|
||||
|
||||
c.syncCollection.mockResolvedValue([
|
||||
{ href: HREF_A, status: 207, statusText: 'OK', ok: true, props: {} },
|
||||
]);
|
||||
|
||||
c.propfind.mockResolvedValue([
|
||||
{
|
||||
href: HREF_B,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: { getetag: '"etag-b"' },
|
||||
},
|
||||
]);
|
||||
|
||||
c.calendarMultiGet
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
href: HREF_A,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: { calendarData: buildICal('uid-a') },
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
href: HREF_B,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: { calendarData: buildICal('uid-b') },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.fetchEvents(c.client, inWindow);
|
||||
|
||||
expect(result.events.map((event) => event.iCalUid).sort()).toEqual([
|
||||
'uid-a',
|
||||
'uid-b',
|
||||
]);
|
||||
expect(c.syncCollection).toHaveBeenCalledTimes(1);
|
||||
expect(c.propfind).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tier-2/3 ctag short-circuit', () => {
|
||||
it('skips network entirely when the server CTag matches the stored CTag', async () => {
|
||||
const c = buildClient();
|
||||
|
||||
c.fetchCalendars.mockResolvedValue([
|
||||
{
|
||||
url: PRIMARY_URL,
|
||||
components: ['VEVENT'],
|
||||
reports: [],
|
||||
ctag: 'unchanged',
|
||||
},
|
||||
]);
|
||||
|
||||
const storedEtags = { [HREF_A]: '"etag-a"' };
|
||||
|
||||
const result = await service.fetchEvents(c.client, {
|
||||
...inWindow,
|
||||
syncCursor: {
|
||||
syncTokens: {},
|
||||
ctags: { [PRIMARY_URL]: 'unchanged' },
|
||||
etags: { [PRIMARY_URL]: storedEtags },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(c.propfind).not.toHaveBeenCalled();
|
||||
expect(c.calendarMultiGet).not.toHaveBeenCalled();
|
||||
expect(result.syncCursor.etags).toEqual({ [PRIMARY_URL]: storedEtags });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tier-2/3 etag diff', () => {
|
||||
it('fetches only changed hrefs and emits cancelled stubs for hrefs vanished from the server', async () => {
|
||||
const c = buildClient();
|
||||
|
||||
c.fetchCalendars.mockResolvedValue([
|
||||
{
|
||||
url: PRIMARY_URL,
|
||||
components: ['VEVENT'],
|
||||
reports: [],
|
||||
ctag: 'new-ctag',
|
||||
},
|
||||
]);
|
||||
|
||||
c.propfind.mockResolvedValue([
|
||||
{
|
||||
href: HREF_A,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: { getetag: '"etag-a-updated"' },
|
||||
},
|
||||
]);
|
||||
|
||||
c.calendarMultiGet.mockResolvedValue([
|
||||
{
|
||||
href: HREF_A,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: { calendarData: buildICal('uid-a') },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.fetchEvents(c.client, {
|
||||
...inWindow,
|
||||
syncCursor: {
|
||||
syncTokens: {},
|
||||
ctags: { [PRIMARY_URL]: 'old-ctag' },
|
||||
etags: {
|
||||
[PRIMARY_URL]: { [HREF_A]: '"etag-a"', [HREF_B]: '"etag-b"' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(c.calendarMultiGet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ objectUrls: [HREF_A] }),
|
||||
);
|
||||
|
||||
const live = result.events.filter((event) => !event.isCanceled);
|
||||
const cancelled = result.events.filter((event) => event.isCanceled);
|
||||
|
||||
expect(live.map((event) => event.iCalUid)).toEqual(['uid-a']);
|
||||
expect(cancelled.map((event) => event.id)).toEqual([HREF_B]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-calendar error isolation', () => {
|
||||
it('preserves the prior cursor entry (token + ctag + etags) for the failing calendar without aborting siblings', async () => {
|
||||
const c = buildClient();
|
||||
|
||||
c.fetchCalendars.mockResolvedValue([
|
||||
{
|
||||
url: PRIMARY_URL,
|
||||
components: ['VEVENT'],
|
||||
reports: ['syncCollection'],
|
||||
},
|
||||
{
|
||||
url: PERSONAL_URL,
|
||||
components: ['VEVENT'],
|
||||
reports: [],
|
||||
ctag: 'c-new',
|
||||
},
|
||||
]);
|
||||
|
||||
c.syncCollection.mockRejectedValue(new Error('network blip'));
|
||||
c.propfind.mockRejectedValue(new Error('propfind blip'));
|
||||
|
||||
const priorEtags = { [HREF_A]: '"etag-a"' };
|
||||
|
||||
const result = await service.fetchEvents(c.client, {
|
||||
...inWindow,
|
||||
syncCursor: {
|
||||
syncTokens: { [PRIMARY_URL]: 'token-prior' },
|
||||
ctags: { [PERSONAL_URL]: 'c-prior' },
|
||||
etags: { [PERSONAL_URL]: priorEtags },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.syncCursor.syncTokens[PRIMARY_URL]).toBe('token-prior');
|
||||
expect(result.syncCursor.ctags?.[PERSONAL_URL]).toBe('c-prior');
|
||||
expect(result.syncCursor.etags?.[PERSONAL_URL]).toEqual(priorEtags);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursor shape', () => {
|
||||
it('omits ctags and etags entirely when only sync-collection calendars exist', async () => {
|
||||
const c = buildClient();
|
||||
|
||||
c.fetchCalendars.mockResolvedValue([
|
||||
{
|
||||
url: PRIMARY_URL,
|
||||
components: ['VEVENT'],
|
||||
reports: ['syncCollection'],
|
||||
},
|
||||
]);
|
||||
c.syncCollection.mockResolvedValue([
|
||||
{
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
raw: { multistatus: { syncToken: 'token-fresh' } },
|
||||
},
|
||||
]);
|
||||
c.calendarMultiGet.mockResolvedValue([]);
|
||||
|
||||
const result = await service.fetchEvents(c.client, inWindow);
|
||||
|
||||
expect(result.syncCursor).toEqual({
|
||||
syncTokens: { [PRIMARY_URL]: 'token-fresh' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial sync (no stored cursor)', () => {
|
||||
it('omits the sync-token on the first run so the server returns a full listing (RFC 6578 §3.4)', async () => {
|
||||
const c = buildClient();
|
||||
|
||||
c.fetchCalendars.mockResolvedValue([
|
||||
{
|
||||
url: PRIMARY_URL,
|
||||
components: ['VEVENT'],
|
||||
reports: ['syncCollection'],
|
||||
syncToken: 'server-current-token',
|
||||
},
|
||||
]);
|
||||
c.syncCollection.mockResolvedValue([
|
||||
{ href: HREF_A, status: 207, statusText: 'OK', ok: true, props: {} },
|
||||
]);
|
||||
c.calendarMultiGet.mockResolvedValue([
|
||||
{
|
||||
href: HREF_A,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: { calendarData: buildICal('uid-a') },
|
||||
},
|
||||
]);
|
||||
|
||||
await service.fetchEvents(c.client, inWindow);
|
||||
|
||||
expect(c.syncCollection).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ syncToken: expect.anything() }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time-window filtering', () => {
|
||||
it('drops events that fall outside the requested [startDate, endDate] window', async () => {
|
||||
const c = buildClient();
|
||||
|
||||
c.fetchCalendars.mockResolvedValue([
|
||||
{
|
||||
url: PRIMARY_URL,
|
||||
components: ['VEVENT'],
|
||||
reports: ['syncCollection'],
|
||||
},
|
||||
]);
|
||||
|
||||
c.syncCollection.mockResolvedValue([
|
||||
{ href: HREF_A, status: 207, statusText: 'OK', ok: true, props: {} },
|
||||
]);
|
||||
|
||||
c.calendarMultiGet.mockResolvedValue([
|
||||
{
|
||||
href: HREF_A,
|
||||
status: 207,
|
||||
statusText: 'OK',
|
||||
ok: true,
|
||||
props: { calendarData: buildICal('uid-a') },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.fetchEvents(c.client, {
|
||||
startDate: new Date('2030-01-01'),
|
||||
endDate: new Date('2030-12-31'),
|
||||
});
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import {
|
||||
type DAVCalendar,
|
||||
type DAVClient,
|
||||
type DAVResponse,
|
||||
DAVNamespaceShort,
|
||||
} from 'tsdav';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type CalDavSyncCursor } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-sync-cursor';
|
||||
import { buildCancelledCalDavEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/build-cancelled-event.util';
|
||||
import { extractICalData } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-data.util';
|
||||
import { isEventInTimeRange } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-event-in-time-range.util';
|
||||
import { isInvalidSyncTokenResponse } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-invalid-sync-token-response.util';
|
||||
import { isValidCalDavHref } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-valid-caldav-href.util';
|
||||
import { parseICalEvents } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-ical-event.util';
|
||||
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
type CalendarSyncResult = {
|
||||
calendarUrl: string;
|
||||
events: FetchedCalendarEvent[];
|
||||
newSyncToken?: string;
|
||||
newCtag?: string;
|
||||
newEtags?: Record<string, string>;
|
||||
};
|
||||
|
||||
type FetchEventsOptions = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
syncCursor?: CalDavSyncCursor;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CalDavFetchEventsService {
|
||||
private readonly logger = new Logger(CalDavFetchEventsService.name);
|
||||
|
||||
async listEventCalendars(client: DAVClient): Promise<DAVCalendar[]> {
|
||||
const calendars = await client.fetchCalendars();
|
||||
|
||||
return calendars.filter((calendar) =>
|
||||
calendar.components?.includes('VEVENT'),
|
||||
);
|
||||
}
|
||||
|
||||
async fetchEvents(
|
||||
client: DAVClient,
|
||||
options: FetchEventsOptions,
|
||||
): Promise<{ events: FetchedCalendarEvent[]; syncCursor: CalDavSyncCursor }> {
|
||||
const calendars = await this.listEventCalendars(client);
|
||||
|
||||
const results = await Promise.all(
|
||||
calendars.map((calendar) => this.syncCalendar(client, calendar, options)),
|
||||
);
|
||||
|
||||
return {
|
||||
events: results.flatMap((result) => result.events),
|
||||
syncCursor: this.mergeSyncCursor(results),
|
||||
};
|
||||
}
|
||||
|
||||
private async syncCalendar(
|
||||
client: DAVClient,
|
||||
calendar: DAVCalendar,
|
||||
options: FetchEventsOptions,
|
||||
): Promise<CalendarSyncResult> {
|
||||
const supportsSyncCollection =
|
||||
calendar.reports?.includes('syncCollection') ?? false;
|
||||
|
||||
try {
|
||||
return supportsSyncCollection
|
||||
? await this.fetchEventsViaSyncCollection(client, calendar, options)
|
||||
: await this.fetchEventsViaCtagEtag(client, calendar, options);
|
||||
} catch (error) {
|
||||
this.logger.error(`Per-calendar sync failed for ${calendar.url}`, error);
|
||||
|
||||
return {
|
||||
calendarUrl: calendar.url,
|
||||
events: [],
|
||||
newSyncToken: options.syncCursor?.syncTokens[calendar.url],
|
||||
newCtag: options.syncCursor?.ctags?.[calendar.url],
|
||||
newEtags: options.syncCursor?.etags?.[calendar.url],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchEventsViaSyncCollection(
|
||||
client: DAVClient,
|
||||
calendar: DAVCalendar,
|
||||
options: FetchEventsOptions,
|
||||
): Promise<CalendarSyncResult> {
|
||||
const previousSyncToken = options.syncCursor?.syncTokens[calendar.url];
|
||||
|
||||
const syncResult = await this.runSyncCollection(
|
||||
client,
|
||||
calendar.url,
|
||||
previousSyncToken,
|
||||
);
|
||||
|
||||
const memberResponses = syncResult.filter(
|
||||
(entry): entry is DAVResponse & { href: string } =>
|
||||
isNonEmptyString(entry.href) && isValidCalDavHref(entry.href),
|
||||
);
|
||||
|
||||
const changedHrefs = memberResponses
|
||||
.filter((entry) => entry.status !== 404)
|
||||
.map((entry) => entry.href);
|
||||
const cancelledHrefs = memberResponses
|
||||
.filter((entry) => entry.status === 404)
|
||||
.map((entry) => entry.href);
|
||||
|
||||
const fetchedEvents = await this.fetchAndParseEvents(
|
||||
client,
|
||||
calendar.url,
|
||||
changedHrefs,
|
||||
options,
|
||||
);
|
||||
|
||||
const rawSyncToken = syncResult[0]?.raw?.multistatus?.syncToken;
|
||||
const newSyncToken = isNonEmptyString(rawSyncToken)
|
||||
? rawSyncToken
|
||||
: previousSyncToken;
|
||||
|
||||
return {
|
||||
calendarUrl: calendar.url,
|
||||
events: [
|
||||
...fetchedEvents,
|
||||
...cancelledHrefs.map(buildCancelledCalDavEvent),
|
||||
],
|
||||
newSyncToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async runSyncCollection(
|
||||
client: DAVClient,
|
||||
url: string,
|
||||
previousSyncToken: string | undefined,
|
||||
): Promise<DAVResponse[]> {
|
||||
const send = (token: string | undefined) =>
|
||||
client.syncCollection({
|
||||
url,
|
||||
props: {
|
||||
[`${DAVNamespaceShort.DAV}:getetag`]: {},
|
||||
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
|
||||
},
|
||||
syncLevel: 1,
|
||||
...(isNonEmptyString(token) ? { syncToken: token } : {}),
|
||||
});
|
||||
|
||||
const result = await send(previousSyncToken);
|
||||
|
||||
if (
|
||||
isNonEmptyString(previousSyncToken) &&
|
||||
isInvalidSyncTokenResponse(result)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Sync-token invalidated for ${url}; falling back to full re-sync`,
|
||||
);
|
||||
|
||||
return send(undefined);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fetchEventsViaCtagEtag(
|
||||
client: DAVClient,
|
||||
calendar: DAVCalendar,
|
||||
options: FetchEventsOptions,
|
||||
): Promise<CalendarSyncResult> {
|
||||
const storedEtags = options.syncCursor?.etags?.[calendar.url] ?? {};
|
||||
const newCtag = isDefined(calendar.ctag)
|
||||
? String(calendar.ctag)
|
||||
: undefined;
|
||||
const storedCtag = options.syncCursor?.ctags?.[calendar.url];
|
||||
|
||||
if (isDefined(newCtag) && isDefined(storedCtag) && newCtag === storedCtag) {
|
||||
return {
|
||||
calendarUrl: calendar.url,
|
||||
events: [],
|
||||
newCtag,
|
||||
newEtags: storedEtags,
|
||||
};
|
||||
}
|
||||
|
||||
const currentEtags = await this.fetchEtagsByHref(client, calendar.url);
|
||||
|
||||
const changedHrefs = Object.keys(currentEtags).filter(
|
||||
(href) => storedEtags[href] !== currentEtags[href],
|
||||
);
|
||||
const cancelledHrefs = Object.keys(storedEtags).filter(
|
||||
(href) => !(href in currentEtags),
|
||||
);
|
||||
|
||||
const fetchedEvents = await this.fetchAndParseEvents(
|
||||
client,
|
||||
calendar.url,
|
||||
changedHrefs,
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
calendarUrl: calendar.url,
|
||||
events: [
|
||||
...fetchedEvents,
|
||||
...cancelledHrefs.map(buildCancelledCalDavEvent),
|
||||
],
|
||||
newCtag,
|
||||
newEtags: currentEtags,
|
||||
};
|
||||
}
|
||||
|
||||
private mergeSyncCursor(results: CalendarSyncResult[]): CalDavSyncCursor {
|
||||
const syncTokens: Record<string, string> = {};
|
||||
const ctags: Record<string, string> = {};
|
||||
const etags: Record<string, Record<string, string>> = {};
|
||||
|
||||
for (const result of results) {
|
||||
if (result.newSyncToken)
|
||||
syncTokens[result.calendarUrl] = result.newSyncToken;
|
||||
if (result.newCtag) ctags[result.calendarUrl] = result.newCtag;
|
||||
if (result.newEtags) etags[result.calendarUrl] = result.newEtags;
|
||||
}
|
||||
|
||||
return {
|
||||
syncTokens,
|
||||
ctags: Object.keys(ctags).length > 0 ? ctags : undefined,
|
||||
etags: Object.keys(etags).length > 0 ? etags : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchEtagsByHref(
|
||||
client: DAVClient,
|
||||
calendarUrl: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const responses = await client.propfind({
|
||||
url: calendarUrl,
|
||||
props: { [`${DAVNamespaceShort.DAV}:getetag`]: {} },
|
||||
depth: '1',
|
||||
});
|
||||
|
||||
return responses.reduce<Record<string, string>>((map, response) => {
|
||||
const href = response.href;
|
||||
const etag = response.props?.getetag;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(href) ||
|
||||
!isNonEmptyString(etag) ||
|
||||
!isValidCalDavHref(href)
|
||||
) {
|
||||
return map;
|
||||
}
|
||||
|
||||
map[href] = etag;
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private async fetchAndParseEvents(
|
||||
client: DAVClient,
|
||||
calendarUrl: string,
|
||||
objectUrls: string[],
|
||||
options: { startDate: Date; endDate: Date },
|
||||
): Promise<FetchedCalendarEvent[]> {
|
||||
if (objectUrls.length === 0) return [];
|
||||
|
||||
const calendarObjects = await client.calendarMultiGet({
|
||||
url: calendarUrl,
|
||||
props: {
|
||||
[`${DAVNamespaceShort.DAV}:getetag`]: {},
|
||||
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
|
||||
},
|
||||
objectUrls,
|
||||
depth: '1',
|
||||
});
|
||||
|
||||
return calendarObjects.flatMap((calendarObject) => {
|
||||
const iCalData = extractICalData(calendarObject.props?.calendarData);
|
||||
|
||||
if (!iCalData) return [];
|
||||
|
||||
return parseICalEvents(iCalData, calendarObject.href || '').filter(
|
||||
(event) =>
|
||||
isEventInTimeRange(event, options.startDate, options.endDate),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+35
-21
@@ -1,9 +1,14 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { CalDavClientService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service';
|
||||
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
|
||||
import { type CalDavSyncCursor } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-sync-cursor';
|
||||
import { parseCalDAVError } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-caldav-error.util';
|
||||
import { type GetCalendarEventsResponse } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service';
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CalDavGetEventsService {
|
||||
@@ -13,10 +18,11 @@ export class CalDavGetEventsService {
|
||||
private static readonly FUTURE_DAYS_WINDOW = 365;
|
||||
|
||||
constructor(
|
||||
private readonly caldavCalendarClientProvider: CalDavClientProvider,
|
||||
private readonly clientService: CalDavClientService,
|
||||
private readonly fetchEventsService: CalDavFetchEventsService,
|
||||
) {}
|
||||
|
||||
public async getCalendarEvents(
|
||||
async getCalendarEvents(
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountEntity,
|
||||
'provider' | 'id' | 'connectionParameters' | 'handle'
|
||||
@@ -26,10 +32,21 @@ export class CalDavGetEventsService {
|
||||
this.logger.debug(`Getting calendar events for ${connectedAccount.handle}`);
|
||||
|
||||
try {
|
||||
const caldavCalendarClient =
|
||||
await this.caldavCalendarClientProvider.getCalDavCalendarClient(
|
||||
connectedAccount,
|
||||
);
|
||||
const params = connectedAccount.connectionParameters?.CALDAV;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(params?.host) ||
|
||||
!isNonEmptyString(params?.password) ||
|
||||
!isDefined(connectedAccount.handle)
|
||||
) {
|
||||
throw new Error('Missing required CalDAV connection parameters');
|
||||
}
|
||||
|
||||
const client = await this.clientService.getClient({
|
||||
serverUrl: params.host,
|
||||
username: params.username ?? connectedAccount.handle,
|
||||
password: params.password,
|
||||
});
|
||||
|
||||
const startDate = new Date(
|
||||
Date.now() -
|
||||
@@ -40,10 +57,12 @@ export class CalDavGetEventsService {
|
||||
CalDavGetEventsService.FUTURE_DAYS_WINDOW * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const result = await caldavCalendarClient.getEvents({
|
||||
const result = await this.fetchEventsService.fetchEvents(client, {
|
||||
startDate,
|
||||
endDate,
|
||||
syncCursor: syncCursor ? JSON.parse(syncCursor) : undefined,
|
||||
syncCursor: syncCursor
|
||||
? (JSON.parse(syncCursor) as CalDavSyncCursor)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
@@ -56,17 +75,12 @@ export class CalDavGetEventsService {
|
||||
nextSyncCursor: JSON.stringify(result.syncCursor),
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleError(error as Error);
|
||||
throw error;
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - getCalendarEvents`,
|
||||
error,
|
||||
);
|
||||
|
||||
throw parseCalDAVError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: Error) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - getCalendarEvents`,
|
||||
error,
|
||||
);
|
||||
|
||||
throw parseCalDAVError(error);
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type CalDavSyncCursor = {
|
||||
syncTokens: Record<string, string>;
|
||||
ctags?: Record<string, string>;
|
||||
etags?: Record<string, Record<string, string>>;
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { extractICalData } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-data.util';
|
||||
|
||||
const VCALENDAR_PAYLOAD = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR';
|
||||
|
||||
describe('extractICalData', () => {
|
||||
it('returns the string as-is when it already contains VCALENDAR', () => {
|
||||
expect(extractICalData(VCALENDAR_PAYLOAD)).toBe(VCALENDAR_PAYLOAD);
|
||||
});
|
||||
|
||||
it('unwraps nested CDATA-style wrappers', () => {
|
||||
expect(extractICalData({ _cdata: VCALENDAR_PAYLOAD })).toBe(
|
||||
VCALENDAR_PAYLOAD,
|
||||
);
|
||||
});
|
||||
|
||||
it('recurses through arbitrarily nested objects', () => {
|
||||
expect(
|
||||
extractICalData({
|
||||
outer: { inner: { deeper: VCALENDAR_PAYLOAD } },
|
||||
}),
|
||||
).toBe(VCALENDAR_PAYLOAD);
|
||||
});
|
||||
|
||||
it('returns null when no VCALENDAR block is found', () => {
|
||||
expect(extractICalData('not a calendar')).toBeNull();
|
||||
expect(extractICalData({ random: 'payload' })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([null, undefined, ''])('returns null for empty input %s', (value) => {
|
||||
expect(
|
||||
extractICalData(value as unknown as string | Record<string, unknown>),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { isEventInTimeRange } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-event-in-time-range.util';
|
||||
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
const event = (startsAt: string, endsAt: string) =>
|
||||
({ startsAt, endsAt }) as unknown as FetchedCalendarEvent;
|
||||
|
||||
const WINDOW_START = new Date('2026-01-01');
|
||||
const WINDOW_END = new Date('2026-12-31');
|
||||
|
||||
describe('isEventInTimeRange', () => {
|
||||
it('returns false when the event lacks start or end', () => {
|
||||
expect(isEventInTimeRange(event('', ''), WINDOW_START, WINDOW_END)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('includes events fully inside the window', () => {
|
||||
expect(
|
||||
isEventInTimeRange(
|
||||
event('2026-06-01T10:00:00Z', '2026-06-01T11:00:00Z'),
|
||||
WINDOW_START,
|
||||
WINDOW_END,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes events entirely before the window', () => {
|
||||
expect(
|
||||
isEventInTimeRange(
|
||||
event('2025-06-01T10:00:00Z', '2025-06-01T11:00:00Z'),
|
||||
WINDOW_START,
|
||||
WINDOW_END,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes events entirely after the window', () => {
|
||||
expect(
|
||||
isEventInTimeRange(
|
||||
event('2027-06-01T10:00:00Z', '2027-06-01T11:00:00Z'),
|
||||
WINDOW_START,
|
||||
WINDOW_END,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('includes events that straddle the start boundary', () => {
|
||||
expect(
|
||||
isEventInTimeRange(
|
||||
event('2025-12-31T22:00:00Z', '2026-01-01T02:00:00Z'),
|
||||
WINDOW_START,
|
||||
WINDOW_END,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('includes events that straddle the end boundary', () => {
|
||||
expect(
|
||||
isEventInTimeRange(
|
||||
event('2026-12-30T22:00:00Z', '2027-01-01T02:00:00Z'),
|
||||
WINDOW_START,
|
||||
WINDOW_END,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { isValidCalDavHref } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-valid-caldav-href.util';
|
||||
|
||||
describe('isValidCalDavHref', () => {
|
||||
it.each([
|
||||
'https://caldav.example.com/calendars/user/event.ics',
|
||||
'https://caldav.example.com/calendars/user/event.ICS',
|
||||
'https://caldav.example.com/calendars/user/message.eml',
|
||||
])('accepts CalDAV-format href %s', (href) => {
|
||||
expect(isValidCalDavHref(href)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://caldav.example.com/calendars/user/notes.txt',
|
||||
'https://caldav.example.com/calendars/user/data.json',
|
||||
])('rejects non-CalDAV extension %s', (href) => {
|
||||
expect(isValidCalDavHref(href)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats hrefs without an extension as ics (some servers omit it)', () => {
|
||||
expect(
|
||||
isValidCalDavHref('https://caldav.example.com/calendars/user/abc123'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('treats trailing slash as no-extension', () => {
|
||||
expect(
|
||||
isValidCalDavHref('https://caldav.example.com/calendars/user/'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
CalendarEventImportDriverException,
|
||||
CalendarEventImportDriverExceptionCode,
|
||||
} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
|
||||
import { parseCalDAVError } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-caldav-error.util';
|
||||
|
||||
describe('parseCalDAVError', () => {
|
||||
it('maps tsdav auth-failure messages to INSUFFICIENT_PERMISSIONS', () => {
|
||||
for (const message of [
|
||||
'no account for fetchCalendars',
|
||||
'Must have account before syncCalendars',
|
||||
'Invalid credentials',
|
||||
'Invalid auth method',
|
||||
]) {
|
||||
const result = parseCalDAVError(new Error(message));
|
||||
|
||||
expect(result).toBeInstanceOf(CalendarEventImportDriverException);
|
||||
expect(result.code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps tsdav not-found messages to NOT_FOUND', () => {
|
||||
for (const message of [
|
||||
'Collection does not exist on server',
|
||||
'cannot find homeUrl',
|
||||
'cannot fetchCalendarObjects for undefined calendar',
|
||||
]) {
|
||||
expect(parseCalDAVError(new Error(message)).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to UNKNOWN for unrecognised errors', () => {
|
||||
expect(parseCalDAVError(new Error('TLS handshake failed')).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.UNKNOWN,
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the original error message untouched', () => {
|
||||
const result = parseCalDAVError(new Error('Invalid credentials'));
|
||||
|
||||
expect(result.message).toBe('Invalid credentials');
|
||||
});
|
||||
});
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
|
||||
import { parseICalEvents } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-ical-event.util';
|
||||
|
||||
const HREF = 'https://caldav.example.com/calendars/user/event-1.ics';
|
||||
|
||||
const buildVCalendar = (vevents: string[][]) =>
|
||||
[
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
...vevents.flatMap((lines) => ['BEGIN:VEVENT', ...lines, 'END:VEVENT']),
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const buildVEvent = (lines: string[]) => buildVCalendar([lines]);
|
||||
|
||||
describe('parseICalEvents', () => {
|
||||
it('returns an empty array when the payload contains no VEVENT', () => {
|
||||
const ics = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'END:VCALENDAR'].join(
|
||||
'\r\n',
|
||||
);
|
||||
|
||||
expect(parseICalEvents(ics, HREF)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts the canonical VEVENT fields', () => {
|
||||
const ics = buildVEvent([
|
||||
'UID:abc123',
|
||||
'SUMMARY:Quarterly review',
|
||||
'DESCRIPTION:Discuss Q2 numbers',
|
||||
'LOCATION:Room 4',
|
||||
'DTSTART:20260601T100000Z',
|
||||
'DTEND:20260601T110000Z',
|
||||
]);
|
||||
|
||||
const [event] = parseICalEvents(ics, HREF);
|
||||
|
||||
expect(event).toMatchObject({
|
||||
id: HREF,
|
||||
iCalUid: 'abc123',
|
||||
title: 'Quarterly review',
|
||||
description: 'Discuss Q2 numbers',
|
||||
location: 'Room 4',
|
||||
startsAt: '2026-06-01T10:00:00.000Z',
|
||||
endsAt: '2026-06-01T11:00:00.000Z',
|
||||
isCanceled: false,
|
||||
status: 'CONFIRMED',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to "Untitled Event" when SUMMARY is missing', () => {
|
||||
const ics = buildVEvent([
|
||||
'UID:abc',
|
||||
'DTSTART:20260601T100000Z',
|
||||
'DTEND:20260601T110000Z',
|
||||
]);
|
||||
|
||||
expect(parseICalEvents(ics, HREF)[0].title).toBe('Untitled Event');
|
||||
});
|
||||
|
||||
it('flags isFullDay when DTSTART carries VALUE=DATE', () => {
|
||||
const ics = buildVEvent([
|
||||
'UID:abc',
|
||||
'SUMMARY:Holiday',
|
||||
'DTSTART;VALUE=DATE:20260704',
|
||||
'DTEND;VALUE=DATE:20260705',
|
||||
]);
|
||||
|
||||
expect(parseICalEvents(ics, HREF)[0].isFullDay).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies isFullDay per-event when a full-day master has a timed recurrence override', () => {
|
||||
const ics = buildVCalendar([
|
||||
[
|
||||
'UID:series-fullday',
|
||||
'SUMMARY:Daily standup',
|
||||
'DTSTART;VALUE=DATE:20260601',
|
||||
'DTEND;VALUE=DATE:20260602',
|
||||
'RRULE:FREQ=DAILY',
|
||||
],
|
||||
[
|
||||
'UID:series-fullday',
|
||||
'SUMMARY:Daily standup (rescheduled to a meeting)',
|
||||
'DTSTART:20260605T140000Z',
|
||||
'DTEND:20260605T143000Z',
|
||||
'RECURRENCE-ID;VALUE=DATE:20260605',
|
||||
],
|
||||
]);
|
||||
|
||||
const [master, override] = parseICalEvents(ics, HREF);
|
||||
|
||||
expect(master.isFullDay).toBe(true);
|
||||
expect(override.isFullDay).toBe(false);
|
||||
});
|
||||
|
||||
it('flags isCanceled when STATUS:CANCELLED is present', () => {
|
||||
const ics = buildVEvent([
|
||||
'UID:abc',
|
||||
'SUMMARY:Off',
|
||||
'DTSTART:20260601T100000Z',
|
||||
'DTEND:20260601T110000Z',
|
||||
'STATUS:CANCELLED',
|
||||
]);
|
||||
|
||||
const [event] = parseICalEvents(ics, HREF);
|
||||
|
||||
expect(event.isCanceled).toBe(true);
|
||||
expect(event.status).toBe('CANCELLED');
|
||||
});
|
||||
|
||||
it('attaches recurringEventExternalId in ISO format when RECURRENCE-ID is present', () => {
|
||||
const ics = buildVEvent([
|
||||
'UID:series-1',
|
||||
'SUMMARY:Standup',
|
||||
'DTSTART:20260601T100000Z',
|
||||
'DTEND:20260601T101500Z',
|
||||
'RECURRENCE-ID:20260601T100000Z',
|
||||
]);
|
||||
|
||||
expect(parseICalEvents(ics, HREF)[0].recurringEventExternalId).toBe(
|
||||
'2026-06-01T10:00:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns master + override events as separate entries with unique ids (RFC 5545 §3.8.4.4)', () => {
|
||||
const ics = buildVCalendar([
|
||||
[
|
||||
'UID:series-1',
|
||||
'SUMMARY:Standup',
|
||||
'DTSTART:20260601T100000Z',
|
||||
'DTEND:20260601T101500Z',
|
||||
'RRULE:FREQ=WEEKLY',
|
||||
],
|
||||
[
|
||||
'UID:series-1',
|
||||
'SUMMARY:Standup (rescheduled)',
|
||||
'DTSTART:20260609T110000Z',
|
||||
'DTEND:20260609T111500Z',
|
||||
'RECURRENCE-ID:20260608T100000Z',
|
||||
],
|
||||
]);
|
||||
|
||||
const events = parseICalEvents(ics, HREF);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({
|
||||
id: HREF,
|
||||
iCalUid: 'series-1',
|
||||
recurringEventExternalId: undefined,
|
||||
});
|
||||
expect(events[1]).toMatchObject({
|
||||
id: `${HREF}#recurrence=2026-06-08T10:00:00.000Z`,
|
||||
iCalUid: 'series-1',
|
||||
title: 'Standup (rescheduled)',
|
||||
recurringEventExternalId: '2026-06-08T10:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles bare ORGANIZER (plain mailto, no CN) — node-ical returns a string', () => {
|
||||
const ics = buildVEvent([
|
||||
'UID:abc',
|
||||
'SUMMARY:Sync',
|
||||
'DTSTART:20260601T100000Z',
|
||||
'DTEND:20260601T110000Z',
|
||||
'ORGANIZER:mailto:bare@example.com',
|
||||
]);
|
||||
|
||||
expect(parseICalEvents(ics, HREF)[0].participants[0]).toMatchObject({
|
||||
handle: 'bare@example.com',
|
||||
displayName: 'bare@example.com',
|
||||
isOrganizer: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles bare ATTENDEE (plain mailto, no params) — node-ical returns a string', () => {
|
||||
const ics = buildVEvent([
|
||||
'UID:abc',
|
||||
'SUMMARY:Sync',
|
||||
'DTSTART:20260601T100000Z',
|
||||
'DTEND:20260601T110000Z',
|
||||
'ATTENDEE:mailto:bare@example.com',
|
||||
]);
|
||||
|
||||
const attendee = parseICalEvents(ics, HREF)[0].participants.find(
|
||||
(p) => !p.isOrganizer,
|
||||
);
|
||||
|
||||
expect(attendee).toMatchObject({
|
||||
handle: 'bare@example.com',
|
||||
displayName: 'bare@example.com',
|
||||
isOrganizer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts the organizer with mailto stripped and ACCEPTED status', () => {
|
||||
const ics = buildVEvent([
|
||||
'UID:abc',
|
||||
'SUMMARY:Sync',
|
||||
'DTSTART:20260601T100000Z',
|
||||
'DTEND:20260601T110000Z',
|
||||
'ORGANIZER;CN=Alice Org:mailto:alice@example.com',
|
||||
]);
|
||||
|
||||
expect(parseICalEvents(ics, HREF)[0].participants[0]).toMatchObject({
|
||||
handle: 'alice@example.com',
|
||||
displayName: 'Alice Org',
|
||||
isOrganizer: true,
|
||||
responseStatus: CalendarEventParticipantResponseStatus.ACCEPTED,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps each ATTENDEE PARTSTAT to the matching response status', () => {
|
||||
const ics = buildVEvent([
|
||||
'UID:abc',
|
||||
'SUMMARY:Sync',
|
||||
'DTSTART:20260601T100000Z',
|
||||
'DTEND:20260601T110000Z',
|
||||
'ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED:mailto:bob@example.com',
|
||||
'ATTENDEE;CN=Carol;PARTSTAT=DECLINED:mailto:carol@example.com',
|
||||
'ATTENDEE;CN=Dan;PARTSTAT=TENTATIVE:mailto:dan@example.com',
|
||||
'ATTENDEE;CN=Eve;PARTSTAT=NEEDS-ACTION:mailto:eve@example.com',
|
||||
'ATTENDEE;CN=Frank;PARTSTAT=DELEGATED:mailto:frank@example.com',
|
||||
]);
|
||||
|
||||
const [event] = parseICalEvents(ics, HREF);
|
||||
const byHandle = Object.fromEntries(
|
||||
event.participants.map((p) => [p.handle, p.responseStatus]),
|
||||
);
|
||||
|
||||
expect(byHandle).toEqual({
|
||||
'bob@example.com': CalendarEventParticipantResponseStatus.ACCEPTED,
|
||||
'carol@example.com': CalendarEventParticipantResponseStatus.DECLINED,
|
||||
'dan@example.com': CalendarEventParticipantResponseStatus.TENTATIVE,
|
||||
'eve@example.com': CalendarEventParticipantResponseStatus.NEEDS_ACTION,
|
||||
'frank@example.com': CalendarEventParticipantResponseStatus.NEEDS_ACTION,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty array and does not throw when iCal data is malformed', () => {
|
||||
expect(parseICalEvents('not a calendar', HREF)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a VEVENT missing DTSTART without dropping its siblings', () => {
|
||||
const ics = buildVCalendar([
|
||||
['UID:no-start', 'SUMMARY:Broken'],
|
||||
[
|
||||
'UID:healthy',
|
||||
'SUMMARY:Healthy',
|
||||
'DTSTART:20260601T100000Z',
|
||||
'DTEND:20260601T110000Z',
|
||||
],
|
||||
]);
|
||||
|
||||
const events = parseICalEvents(ics, HREF);
|
||||
|
||||
expect(events.map((event) => event.iCalUid)).toEqual(['healthy']);
|
||||
});
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
export const buildCancelledCalDavEvent = (
|
||||
href: string,
|
||||
): FetchedCalendarEvent => ({
|
||||
id: href,
|
||||
title: '',
|
||||
iCalUid: '',
|
||||
description: '',
|
||||
startsAt: '',
|
||||
endsAt: '',
|
||||
location: '',
|
||||
isFullDay: false,
|
||||
isCanceled: true,
|
||||
conferenceLinkLabel: '',
|
||||
conferenceLinkUrl: '',
|
||||
externalCreatedAt: '',
|
||||
externalUpdatedAt: '',
|
||||
conferenceSolution: '',
|
||||
participants: [],
|
||||
status: 'CANCELLED',
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { isString } from '@sniptt/guards';
|
||||
import type * as ical from 'node-ical';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { mapPartStatToResponseStatus } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/map-partstat-to-response-status.util';
|
||||
import { type FetchedCalendarEventParticipant } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
export const extractAttendeesFromEvent = (
|
||||
event: ical.VEvent,
|
||||
): FetchedCalendarEventParticipant[] => {
|
||||
if (!isDefined(event.attendee)) return [];
|
||||
|
||||
const attendees = Array.isArray(event.attendee)
|
||||
? event.attendee
|
||||
: [event.attendee];
|
||||
|
||||
return attendees.map((attendee) => {
|
||||
const rawValue = isString(attendee) ? attendee : attendee.val;
|
||||
const params = isString(attendee) ? undefined : attendee.params;
|
||||
const handle = rawValue.replace(/^mailto:/i, '');
|
||||
const partStat = params?.PARTSTAT ?? 'NEEDS_ACTION';
|
||||
|
||||
return {
|
||||
displayName: params?.CN || handle || 'Unknown',
|
||||
responseStatus: mapPartStatToResponseStatus(
|
||||
partStat as ical.AttendeePartStat,
|
||||
),
|
||||
handle,
|
||||
isOrganizer: false,
|
||||
};
|
||||
});
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { isString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
// Some CalDAV servers return calendar-data as a string, others nest it under
|
||||
// _cdata or other shapes. Recurse until a VCALENDAR block is found.
|
||||
export const extractICalData = (
|
||||
calendarData: string | Record<string, unknown> | null | undefined,
|
||||
): string | null => {
|
||||
if (!isDefined(calendarData)) return null;
|
||||
|
||||
if (isString(calendarData) && calendarData.includes('VCALENDAR')) {
|
||||
return calendarData;
|
||||
}
|
||||
|
||||
if (typeof calendarData === 'object') {
|
||||
for (const value of Object.values(calendarData)) {
|
||||
const result = extractICalData(value as string | Record<string, unknown>);
|
||||
|
||||
if (isDefined(result)) return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { isString } from '@sniptt/guards';
|
||||
import type * as ical from 'node-ical';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
|
||||
import { type FetchedCalendarEventParticipant } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
export const extractOrganizerFromEvent = (
|
||||
event: ical.VEvent,
|
||||
): FetchedCalendarEventParticipant | null => {
|
||||
const organizer = event.organizer;
|
||||
|
||||
if (!isDefined(organizer)) return null;
|
||||
|
||||
const rawValue = isString(organizer) ? organizer : organizer.val;
|
||||
const commonName = isString(organizer) ? undefined : organizer.params?.CN;
|
||||
const handle = rawValue.replace(/^mailto:/i, '');
|
||||
|
||||
return {
|
||||
displayName: commonName || handle || 'Unknown',
|
||||
responseStatus: CalendarEventParticipantResponseStatus.ACCEPTED,
|
||||
handle,
|
||||
isOrganizer: true,
|
||||
};
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
export const isEventInTimeRange = (
|
||||
event: FetchedCalendarEvent,
|
||||
windowStart: Date,
|
||||
windowEnd: Date,
|
||||
): boolean => {
|
||||
if (!isNonEmptyString(event.startsAt) || !isNonEmptyString(event.endsAt))
|
||||
return false;
|
||||
|
||||
const eventStart = new Date(event.startsAt);
|
||||
const eventEnd = new Date(event.endsAt);
|
||||
|
||||
return eventStart < windowEnd && eventEnd > windowStart;
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type DAVResponse } from 'tsdav';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const isInvalidSyncTokenResponse = (
|
||||
responses: DAVResponse[],
|
||||
): boolean => {
|
||||
const DAVResponse = responses[0];
|
||||
|
||||
if (!isDefined(DAVResponse) || DAVResponse.status !== 403) return false;
|
||||
|
||||
const body = isNonEmptyString(DAVResponse.raw)
|
||||
? DAVResponse.raw
|
||||
: JSON.stringify(DAVResponse.raw ?? {});
|
||||
|
||||
return body.includes('valid-sync-token') || body.includes('validSyncToken');
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { extname } from 'node:path';
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set(['', '.ics', '.eml']);
|
||||
|
||||
export const isValidCalDavHref = (url: string): boolean =>
|
||||
ALLOWED_EXTENSIONS.has(extname(url).toLowerCase());
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import type * as ical from 'node-ical';
|
||||
|
||||
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
|
||||
|
||||
export const mapPartStatToResponseStatus = (
|
||||
partStat: ical.AttendeePartStat,
|
||||
): CalendarEventParticipantResponseStatus => {
|
||||
switch (partStat) {
|
||||
case 'ACCEPTED':
|
||||
return CalendarEventParticipantResponseStatus.ACCEPTED;
|
||||
case 'DECLINED':
|
||||
return CalendarEventParticipantResponseStatus.DECLINED;
|
||||
case 'TENTATIVE':
|
||||
return CalendarEventParticipantResponseStatus.TENTATIVE;
|
||||
case 'DELEGATED':
|
||||
case 'NEEDS-ACTION':
|
||||
default:
|
||||
return CalendarEventParticipantResponseStatus.NEEDS_ACTION;
|
||||
}
|
||||
};
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import * as ical from 'node-ical';
|
||||
import { icalDataExtractPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue';
|
||||
import { extractAttendeesFromEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-attendees-from-event.util';
|
||||
import { extractOrganizerFromEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-organizer-from-event.util';
|
||||
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
export const parseICalEvents = (
|
||||
rawData: string,
|
||||
objectUrl: string,
|
||||
): FetchedCalendarEvent[] => {
|
||||
try {
|
||||
const events = Object.values(ical.parseICS(rawData))
|
||||
.filter(
|
||||
(calendarComponent): calendarComponent is ical.VEvent =>
|
||||
calendarComponent.type === 'VEVENT',
|
||||
)
|
||||
.flatMap((event) => [event, ...Object.values(event.recurrences ?? {})])
|
||||
.filter(
|
||||
(event) => event.start instanceof Date && event.end instanceof Date,
|
||||
);
|
||||
|
||||
return events.map((event) => {
|
||||
const organizer = extractOrganizerFromEvent(event);
|
||||
const attendees = extractAttendeesFromEvent(event);
|
||||
const recurrenceIso =
|
||||
event.recurrenceid instanceof Date
|
||||
? event.recurrenceid.toISOString()
|
||||
: undefined;
|
||||
const createdIso =
|
||||
event.created?.toISOString() ?? new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: recurrenceIso
|
||||
? `${objectUrl}#recurrence=${recurrenceIso}`
|
||||
: objectUrl,
|
||||
iCalUid: event.uid || '',
|
||||
title: icalDataExtractPropertyValue(event.summary, 'Untitled Event'),
|
||||
description: icalDataExtractPropertyValue(event.description),
|
||||
location: icalDataExtractPropertyValue(event.location),
|
||||
startsAt: event.start.toISOString(),
|
||||
endsAt: event.end.toISOString(),
|
||||
isFullDay: event.datetype === 'date',
|
||||
isCanceled: event.status === 'CANCELLED',
|
||||
status: event.status || 'CONFIRMED',
|
||||
recurringEventExternalId: recurrenceIso,
|
||||
conferenceLinkLabel: '',
|
||||
conferenceLinkUrl: icalDataExtractPropertyValue(event.url),
|
||||
conferenceSolution: '',
|
||||
externalCreatedAt: createdIso,
|
||||
externalUpdatedAt: event.lastmodified?.toISOString() ?? createdIso,
|
||||
participants: organizer ? [organizer, ...attendees] : attendees,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user