Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5717752be | ||
|
|
24796e9355 |
+1
-1
@@ -12,7 +12,7 @@ import {
|
||||
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
|
||||
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
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 { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav-client.provider';
|
||||
import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service';
|
||||
|
||||
@Module({
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const CALDAV_FUTURE_DAYS_WINDOW = 365;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const CALDAV_PAST_DAYS_WINDOW = 365 * 5;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export class CalDavHttpException extends Error {
|
||||
public readonly status: number;
|
||||
|
||||
constructor(status: number, statusText: string) {
|
||||
super(`CalDAV HTTP error ${status}: ${statusText}`);
|
||||
this.name = 'CalDavHttpException';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
-528
@@ -1,528 +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,
|
||||
getBasicAuthHeaders,
|
||||
syncCollection,
|
||||
} from 'tsdav';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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 headers: Record<string, string>;
|
||||
|
||||
constructor(credentials: CalendarCredentials) {
|
||||
this.credentials = credentials;
|
||||
this.logger = new Logger(CalDAVClient.name);
|
||||
this.headers = getBasicAuthHeaders({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
headers: this.headers,
|
||||
});
|
||||
}
|
||||
|
||||
async listCalendars(): Promise<SimpleCalendar[]> {
|
||||
try {
|
||||
const account = await this.getAccount();
|
||||
|
||||
const calendars = (await fetchCalendars({
|
||||
account,
|
||||
headers: this.headers,
|
||||
})) 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,
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
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 } : {}),
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
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',
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
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,
|
||||
headers: this.headers,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -2,17 +2,17 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
|
||||
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
export class CalDavClientProvider {
|
||||
public async getCalDavCalendarClient(
|
||||
public getCalDavCalendarClient(
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountWorkspaceEntity,
|
||||
'id' | 'provider' | 'connectionParameters' | 'handle'
|
||||
>,
|
||||
): Promise<CalDAVClient> {
|
||||
): CalDAVClient {
|
||||
if (
|
||||
!connectedAccount.connectionParameters?.CALDAV?.password ||
|
||||
!connectedAccount.connectionParameters?.CALDAV?.host ||
|
||||
@@ -20,14 +20,13 @@ export class CalDavClientProvider {
|
||||
) {
|
||||
throw new Error('Missing required CalDAV connection parameters');
|
||||
}
|
||||
const caldavClient = new CalDAVClient({
|
||||
|
||||
return new CalDAVClient({
|
||||
username:
|
||||
connectedAccount.connectionParameters.CALDAV.username ??
|
||||
connectedAccount.handle,
|
||||
password: connectedAccount.connectionParameters.CALDAV.password,
|
||||
serverUrl: connectedAccount.connectionParameters.CALDAV.host,
|
||||
});
|
||||
|
||||
return caldavClient;
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
calendarMultiGet,
|
||||
createAccount,
|
||||
type DAVAccount,
|
||||
type DAVCalendar,
|
||||
DAVNamespaceShort,
|
||||
type DAVResponse,
|
||||
fetchCalendars,
|
||||
getBasicAuthHeaders,
|
||||
syncCollection,
|
||||
} from 'tsdav';
|
||||
|
||||
import { CalDavHttpException } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/exceptions/caldav-http.exception';
|
||||
import { type CalDavCalendar } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-calendar.type';
|
||||
import { type CalDavCredentials } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-credentials.type';
|
||||
|
||||
const DEFAULT_CALENDAR_TYPE = 'caldav';
|
||||
const ALLOWED_EXTENSIONS = ['eml', 'ics'];
|
||||
|
||||
export class CalDAVClient {
|
||||
private readonly credentials: CalDavCredentials;
|
||||
private readonly logger = new Logger(CalDAVClient.name);
|
||||
private readonly headers: Record<string, string>;
|
||||
|
||||
constructor(credentials: CalDavCredentials) {
|
||||
this.credentials = credentials;
|
||||
this.headers = getBasicAuthHeaders({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
headers: this.headers,
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
isValidCalendarObjectUrl(url: string): boolean {
|
||||
return ALLOWED_EXTENSIONS.includes(this.getFileExtension(url));
|
||||
}
|
||||
|
||||
private throwOnHttpError(responses: DAVResponse[]): void {
|
||||
const failedResponse = responses.find((response) => !response.ok);
|
||||
|
||||
if (failedResponse) {
|
||||
throw new CalDavHttpException(
|
||||
failedResponse.status,
|
||||
failedResponse.statusText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listCalendars(): Promise<CalDavCalendar[]> {
|
||||
const account = await this.getAccount();
|
||||
|
||||
const calendars = (await fetchCalendars({
|
||||
account,
|
||||
headers: this.headers,
|
||||
})) as (Omit<DAVCalendar, 'displayName'> & {
|
||||
displayName?: string | Record<string, unknown>;
|
||||
})[];
|
||||
|
||||
return calendars.reduce<CalDavCalendar[]>((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;
|
||||
}, []);
|
||||
}
|
||||
|
||||
async validateSyncCollectionSupport(): Promise<void> {
|
||||
const account = await this.getAccount();
|
||||
|
||||
const calendars = await fetchCalendars({
|
||||
account,
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
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)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async syncCalendarCollection(
|
||||
calendarUrl: string,
|
||||
syncToken?: string,
|
||||
): Promise<{ href: string }[]> {
|
||||
const syncResult = await syncCollection({
|
||||
url: calendarUrl,
|
||||
props: {
|
||||
[`${DAVNamespaceShort.DAV}:getetag`]: {},
|
||||
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
|
||||
},
|
||||
syncLevel: 1,
|
||||
...(syncToken ? { syncToken } : {}),
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
this.throwOnHttpError(syncResult);
|
||||
|
||||
return syncResult
|
||||
.map((item) => ({ href: item.href || '' }))
|
||||
.filter((item) => item.href && this.isValidCalendarObjectUrl(item.href));
|
||||
}
|
||||
|
||||
async fetchCalendarObjects(
|
||||
calendarUrl: string,
|
||||
objectUrls: string[],
|
||||
): Promise<
|
||||
{
|
||||
href: string;
|
||||
calendarData: string | Record<string, unknown>;
|
||||
etag: string;
|
||||
}[]
|
||||
> {
|
||||
const calendarObjects = await calendarMultiGet({
|
||||
url: calendarUrl,
|
||||
props: {
|
||||
[`${DAVNamespaceShort.DAV}:getetag`]: {},
|
||||
[`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
|
||||
},
|
||||
objectUrls,
|
||||
depth: '1',
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
this.throwOnHttpError(calendarObjects);
|
||||
|
||||
return calendarObjects
|
||||
.filter((obj) => obj.props?.calendarData)
|
||||
.map((obj) => ({
|
||||
href: obj.href || '',
|
||||
calendarData: obj.props!.calendarData,
|
||||
etag: obj.props!.getetag || '',
|
||||
}));
|
||||
}
|
||||
|
||||
async fetchAllCalendarSyncTokens(): Promise<Record<string, string>> {
|
||||
try {
|
||||
const account = await this.getAccount();
|
||||
const calendars = await fetchCalendars({
|
||||
account,
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
const syncTokens: Record<string, string> = {};
|
||||
|
||||
for (const calendar of calendars) {
|
||||
if (calendar.syncToken) {
|
||||
syncTokens[calendar.url] = calendar.syncToken.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return syncTokens;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to fetch calendar sync tokens', error);
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
-31
@@ -1,20 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider';
|
||||
import { CALDAV_FUTURE_DAYS_WINDOW } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/constants/caldav-future-days-window.constant';
|
||||
import { CALDAV_PAST_DAYS_WINDOW } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/constants/caldav-past-days-window.constant';
|
||||
import { CalDavHttpException } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/exceptions/caldav-http.exception';
|
||||
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav-client.provider';
|
||||
import { type CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client';
|
||||
import { type CalDavCalendar } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-calendar.type';
|
||||
import { type CalDavSyncCursor } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-sync-cursor.type';
|
||||
import { extractICalData } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-data.util';
|
||||
import { formatCalDavCalendarEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/format-caldav-calendar-event.util';
|
||||
import { isEventInTimeRange } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-event-in-time-range.util';
|
||||
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 FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
export class CalDavGetEventsService {
|
||||
private readonly logger = new Logger(CalDavGetEventsService.name);
|
||||
|
||||
private static readonly PAST_DAYS_WINDOW = 365 * 5;
|
||||
private static readonly FUTURE_DAYS_WINDOW = 365;
|
||||
|
||||
constructor(
|
||||
private readonly caldavCalendarClientProvider: CalDavClientProvider,
|
||||
) {}
|
||||
constructor(private readonly caldavClientProvider: CalDavClientProvider) {}
|
||||
|
||||
public async getCalendarEvents(
|
||||
connectedAccount: Pick<
|
||||
@@ -26,47 +31,175 @@ export class CalDavGetEventsService {
|
||||
this.logger.debug(`Getting calendar events for ${connectedAccount.handle}`);
|
||||
|
||||
try {
|
||||
const caldavCalendarClient =
|
||||
await this.caldavCalendarClientProvider.getCalDavCalendarClient(
|
||||
connectedAccount,
|
||||
);
|
||||
const client =
|
||||
this.caldavClientProvider.getCalDavCalendarClient(connectedAccount);
|
||||
|
||||
const parsedSyncCursor: CalDavSyncCursor | undefined = syncCursor
|
||||
? JSON.parse(syncCursor)
|
||||
: undefined;
|
||||
|
||||
const startDate = new Date(
|
||||
Date.now() -
|
||||
CalDavGetEventsService.PAST_DAYS_WINDOW * 24 * 60 * 60 * 1000,
|
||||
Date.now() - CALDAV_PAST_DAYS_WINDOW * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const endDate = new Date(
|
||||
Date.now() +
|
||||
CalDavGetEventsService.FUTURE_DAYS_WINDOW * 24 * 60 * 60 * 1000,
|
||||
Date.now() + CALDAV_FUTURE_DAYS_WINDOW * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const result = await caldavCalendarClient.getEvents({
|
||||
startDate,
|
||||
endDate,
|
||||
syncCursor: syncCursor ? JSON.parse(syncCursor) : undefined,
|
||||
});
|
||||
const calendars = await client.listCalendars();
|
||||
|
||||
const syncResults = await Promise.all(
|
||||
calendars.map((calendar) =>
|
||||
this.syncCalendar(
|
||||
client,
|
||||
calendar,
|
||||
startDate,
|
||||
endDate,
|
||||
parsedSyncCursor,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const allEvents = syncResults.flatMap((result) => result.events);
|
||||
|
||||
const updatedSyncTokens = await client.fetchAllCalendarSyncTokens();
|
||||
|
||||
const syncTokens: Record<string, string> = {};
|
||||
|
||||
for (const result of syncResults) {
|
||||
const updatedToken = updatedSyncTokens[result.calendarUrl];
|
||||
|
||||
if (updatedToken) {
|
||||
syncTokens[result.calendarUrl] = updatedToken;
|
||||
} else if (result.fallbackSyncToken) {
|
||||
syncTokens[result.calendarUrl] = result.fallbackSyncToken;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Found ${result.events.length} calendar events for ${connectedAccount.handle}`,
|
||||
`Found ${allEvents.length} calendar events for ${connectedAccount.handle}`,
|
||||
);
|
||||
|
||||
return {
|
||||
fullEvents: true,
|
||||
calendarEvents: result.events,
|
||||
nextSyncCursor: JSON.stringify(result.syncCursor),
|
||||
calendarEvents: allEvents,
|
||||
nextSyncCursor: JSON.stringify({ syncTokens }),
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleError(error as Error);
|
||||
throw error;
|
||||
this.logger.error(
|
||||
`Failed to get calendar events for ${connectedAccount.handle}`,
|
||||
error,
|
||||
);
|
||||
|
||||
throw parseCalDAVError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: Error) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - getCalendarEvents`,
|
||||
error,
|
||||
);
|
||||
private async syncCalendar(
|
||||
client: CalDAVClient,
|
||||
calendar: CalDavCalendar,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
parsedSyncCursor?: CalDavSyncCursor,
|
||||
): Promise<{
|
||||
calendarUrl: string;
|
||||
events: FetchedCalendarEvent[];
|
||||
fallbackSyncToken?: string;
|
||||
}> {
|
||||
const existingSyncToken =
|
||||
parsedSyncCursor?.syncTokens[calendar.url] ||
|
||||
calendar.syncToken?.toString();
|
||||
|
||||
throw parseCalDAVError(error);
|
||||
try {
|
||||
const changedObjects = await client.syncCalendarCollection(
|
||||
calendar.url,
|
||||
existingSyncToken,
|
||||
);
|
||||
|
||||
const objectUrls = changedObjects.map((obj) => obj.href);
|
||||
|
||||
const events =
|
||||
objectUrls.length > 0
|
||||
? await this.fetchEventsFromChangedObjects(
|
||||
client,
|
||||
calendar.url,
|
||||
objectUrls,
|
||||
startDate,
|
||||
endDate,
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
calendarUrl: calendar.url,
|
||||
events,
|
||||
fallbackSyncToken: existingSyncToken,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof CalDavHttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(`Failed to sync calendar ${calendar.url}`, error);
|
||||
|
||||
return {
|
||||
calendarUrl: calendar.url,
|
||||
events: [],
|
||||
fallbackSyncToken: parsedSyncCursor?.syncTokens[calendar.url],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchEventsFromChangedObjects(
|
||||
client: CalDAVClient,
|
||||
calendarUrl: string,
|
||||
objectUrls: string[],
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<FetchedCalendarEvent[]> {
|
||||
try {
|
||||
const calendarObjects = await client.fetchCalendarObjects(
|
||||
calendarUrl,
|
||||
objectUrls,
|
||||
);
|
||||
|
||||
return calendarObjects
|
||||
.map((calendarObject) => {
|
||||
const iCalData = extractICalData(calendarObject.calendarData);
|
||||
|
||||
if (!iCalData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = formatCalDavCalendarEvent(
|
||||
iCalData,
|
||||
calendarObject.href,
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
this.logger.debug(
|
||||
`Could not parse calendar object ${calendarObject.href}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isEventInTimeRange(event, startDate, endDate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return event;
|
||||
})
|
||||
.filter((event): event is FetchedCalendarEvent => event !== null);
|
||||
} catch (error) {
|
||||
if (error instanceof CalDavHttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`Failed to fetch calendar objects for ${calendarUrl}`,
|
||||
error,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type CalDavCalendar = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
isPrimary?: boolean;
|
||||
syncToken?: string | number;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type CalDavCredentials = {
|
||||
username: string;
|
||||
password: string;
|
||||
serverUrl: string;
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export type CalDavSyncCursor = {
|
||||
syncTokens: Record<string, string>;
|
||||
};
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { extractICalData } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-data.util';
|
||||
|
||||
describe('extractICalData', () => {
|
||||
it('should return a plain string containing VCALENDAR as-is', () => {
|
||||
const data =
|
||||
'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nEND:VCALENDAR';
|
||||
|
||||
expect(extractICalData(data)).toBe(data);
|
||||
});
|
||||
|
||||
it('should extract iCal data nested under _cdata', () => {
|
||||
const nested = {
|
||||
_cdata: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR',
|
||||
};
|
||||
|
||||
expect(extractICalData(nested)).toBe('BEGIN:VCALENDAR\r\nEND:VCALENDAR');
|
||||
});
|
||||
|
||||
it('should extract from deeply nested objects', () => {
|
||||
const deeplyNested = {
|
||||
wrapper: {
|
||||
inner: {
|
||||
content: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(extractICalData(deeplyNested)).toBe(
|
||||
'BEGIN:VCALENDAR\r\nEND:VCALENDAR',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for a string without VCALENDAR', () => {
|
||||
expect(extractICalData('just some text')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty input', () => {
|
||||
expect(extractICalData('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when depth limit is exceeded', () => {
|
||||
const nested = {
|
||||
a: { b: { c: { d: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR' } } },
|
||||
};
|
||||
|
||||
expect(extractICalData(nested, 4)).toBeNull();
|
||||
expect(extractICalData(nested, 5)).toBe('BEGIN:VCALENDAR\r\nEND:VCALENDAR');
|
||||
});
|
||||
});
|
||||
+11
-11
@@ -1,6 +1,6 @@
|
||||
import { icalDataExtractPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/utils/icalDataExtractPropertyValue';
|
||||
import { extractICalPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-property-value.util';
|
||||
|
||||
describe('icalDataExtractPropertyValue', () => {
|
||||
describe('extractICalPropertyValue', () => {
|
||||
describe('properties with parameters (RFC 5545 Section 3.2)', () => {
|
||||
it('should extract value from property object with val and params', () => {
|
||||
const property = {
|
||||
@@ -8,7 +8,7 @@ describe('icalDataExtractPropertyValue', () => {
|
||||
params: { LANGUAGE: 'en-US' },
|
||||
};
|
||||
|
||||
const result = icalDataExtractPropertyValue(property);
|
||||
const result = extractICalPropertyValue(property);
|
||||
|
||||
expect(result).toBe('Meeting Title');
|
||||
});
|
||||
@@ -18,7 +18,7 @@ describe('icalDataExtractPropertyValue', () => {
|
||||
val: 'Conference Room A',
|
||||
};
|
||||
|
||||
const result = icalDataExtractPropertyValue(property);
|
||||
const result = extractICalPropertyValue(property);
|
||||
|
||||
expect(result).toBe('Conference Room A');
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe('icalDataExtractPropertyValue', () => {
|
||||
params: { TYPE: 'INTEGER' },
|
||||
} as any;
|
||||
|
||||
const result = icalDataExtractPropertyValue(property);
|
||||
const result = extractICalPropertyValue(property);
|
||||
|
||||
expect(result).toBe('12345');
|
||||
});
|
||||
@@ -40,7 +40,7 @@ describe('icalDataExtractPropertyValue', () => {
|
||||
params: { LANGUAGE: 'de-DE' },
|
||||
};
|
||||
|
||||
const result = icalDataExtractPropertyValue(property, 'default');
|
||||
const result = extractICalPropertyValue(property, 'default');
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
@@ -50,7 +50,7 @@ describe('icalDataExtractPropertyValue', () => {
|
||||
it('should join multiple string values with comma and space', () => {
|
||||
const property = ['Value 1', 'Value 2', 'Value 3'] as any;
|
||||
|
||||
const result = icalDataExtractPropertyValue(property);
|
||||
const result = extractICalPropertyValue(property);
|
||||
|
||||
expect(result).toBe('Value 1, Value 2, Value 3');
|
||||
});
|
||||
@@ -61,7 +61,7 @@ describe('icalDataExtractPropertyValue', () => {
|
||||
{ val: 'Second Value', params: { LANGUAGE: 'fr' } },
|
||||
] as any;
|
||||
|
||||
const result = icalDataExtractPropertyValue(property);
|
||||
const result = extractICalPropertyValue(property);
|
||||
|
||||
expect(result).toBe('First Value, Second Value');
|
||||
});
|
||||
@@ -69,7 +69,7 @@ describe('icalDataExtractPropertyValue', () => {
|
||||
it('should filter out empty values from array', () => {
|
||||
const property = ['Value 1', '', 'Value 3', { val: '' }] as any;
|
||||
|
||||
const result = icalDataExtractPropertyValue(property);
|
||||
const result = extractICalPropertyValue(property);
|
||||
|
||||
expect(result).toBe('Value 1, Value 3');
|
||||
});
|
||||
@@ -77,7 +77,7 @@ describe('icalDataExtractPropertyValue', () => {
|
||||
it('should return default value when array contains only empty values', () => {
|
||||
const property = ['', { val: '' }, null] as any;
|
||||
|
||||
const result = icalDataExtractPropertyValue(property, 'No values');
|
||||
const result = extractICalPropertyValue(property, 'No values');
|
||||
|
||||
expect(result).toBe('No values');
|
||||
});
|
||||
@@ -89,7 +89,7 @@ describe('icalDataExtractPropertyValue', () => {
|
||||
'Another String',
|
||||
] as any;
|
||||
|
||||
const result = icalDataExtractPropertyValue(property);
|
||||
const result = extractICalPropertyValue(property);
|
||||
|
||||
expect(result).toBe('Plain String, Object Value, Another String');
|
||||
});
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import * as ical from 'node-ical';
|
||||
|
||||
import { extractParticipantsFromEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-participants-from-event.util';
|
||||
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
|
||||
|
||||
const parseFirstEvent = (icsData: string): ical.VEvent => {
|
||||
const parsed = ical.parseICS(icsData);
|
||||
const events = Object.values(parsed).filter((item) => item.type === 'VEVENT');
|
||||
|
||||
return events[0] as ical.VEvent;
|
||||
};
|
||||
|
||||
describe('extractParticipantsFromEvent', () => {
|
||||
it('should extract organizer and attendees', () => {
|
||||
const event = parseFirstEvent(
|
||||
[
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'UID:test@example.com',
|
||||
'DTSTART:20230615T090000Z',
|
||||
'DTEND:20230615T100000Z',
|
||||
'ORGANIZER;CN=Alice:mailto:alice@example.com',
|
||||
'ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED:mailto:bob@example.com',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n'),
|
||||
);
|
||||
|
||||
const participants = extractParticipantsFromEvent(event);
|
||||
|
||||
expect(participants).toHaveLength(2);
|
||||
|
||||
const organizer = participants.find((p) => p.isOrganizer);
|
||||
|
||||
expect(organizer).toBeDefined();
|
||||
expect(organizer!.handle).toBe('alice@example.com');
|
||||
expect(organizer!.responseStatus).toBe(
|
||||
CalendarEventParticipantResponseStatus.ACCEPTED,
|
||||
);
|
||||
|
||||
const attendee = participants.find((p) => !p.isOrganizer);
|
||||
|
||||
expect(attendee).toBeDefined();
|
||||
expect(attendee!.handle).toBe('bob@example.com');
|
||||
});
|
||||
|
||||
it('should return empty array when no organizer and no attendees', () => {
|
||||
const event = parseFirstEvent(
|
||||
[
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'UID:solo@example.com',
|
||||
'DTSTART:20230615T090000Z',
|
||||
'DTEND:20230615T100000Z',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n'),
|
||||
);
|
||||
|
||||
const participants = extractParticipantsFromEvent(event);
|
||||
|
||||
expect(participants).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should strip mailto: prefix from attendee handles', () => {
|
||||
const event = parseFirstEvent(
|
||||
[
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'UID:mailto-test@example.com',
|
||||
'DTSTART:20230615T090000Z',
|
||||
'DTEND:20230615T100000Z',
|
||||
'ATTENDEE;PARTSTAT=ACCEPTED:mailto:charlie@example.com',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n'),
|
||||
);
|
||||
|
||||
const participants = extractParticipantsFromEvent(event);
|
||||
|
||||
expect(participants[0].handle).toBe('charlie@example.com');
|
||||
});
|
||||
|
||||
it('should map each PARTSTAT value to the correct response status', () => {
|
||||
const event = parseFirstEvent(
|
||||
[
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'UID:partstat@example.com',
|
||||
'DTSTART:20230615T090000Z',
|
||||
'DTEND:20230615T100000Z',
|
||||
'ATTENDEE;PARTSTAT=ACCEPTED:mailto:a@test.com',
|
||||
'ATTENDEE;PARTSTAT=DECLINED:mailto:d@test.com',
|
||||
'ATTENDEE;PARTSTAT=TENTATIVE:mailto:t@test.com',
|
||||
'ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:n@test.com',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n'),
|
||||
);
|
||||
|
||||
const participants = extractParticipantsFromEvent(event);
|
||||
const byHandle = new Map(participants.map((p) => [p.handle, p]));
|
||||
|
||||
expect(byHandle.get('a@test.com')!.responseStatus).toBe(
|
||||
CalendarEventParticipantResponseStatus.ACCEPTED,
|
||||
);
|
||||
expect(byHandle.get('d@test.com')!.responseStatus).toBe(
|
||||
CalendarEventParticipantResponseStatus.DECLINED,
|
||||
);
|
||||
expect(byHandle.get('t@test.com')!.responseStatus).toBe(
|
||||
CalendarEventParticipantResponseStatus.TENTATIVE,
|
||||
);
|
||||
expect(byHandle.get('n@test.com')!.responseStatus).toBe(
|
||||
CalendarEventParticipantResponseStatus.NEEDS_ACTION,
|
||||
);
|
||||
});
|
||||
});
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import { formatCalDavCalendarEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/format-caldav-calendar-event.util';
|
||||
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
|
||||
|
||||
const buildICalEvent = (overrides: Record<string, string> = {}): string => {
|
||||
const defaults: Record<string, string> = {
|
||||
UID: 'test-uid-123@example.com',
|
||||
SUMMARY: 'Team Standup',
|
||||
DESCRIPTION: 'Daily sync meeting',
|
||||
LOCATION: 'Conference Room B',
|
||||
DTSTART: '20230615T090000Z',
|
||||
DTEND: '20230615T093000Z',
|
||||
STATUS: 'CONFIRMED',
|
||||
CREATED: '20230601T120000Z',
|
||||
'LAST-MODIFIED': '20230610T120000Z',
|
||||
ORGANIZER: 'ORGANIZER;CN=Alice:mailto:alice@example.com',
|
||||
ATTENDEE: 'ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED:mailto:bob@example.com',
|
||||
};
|
||||
|
||||
const merged = { ...defaults, ...overrides };
|
||||
|
||||
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT'];
|
||||
|
||||
for (const [key, value] of Object.entries(merged)) {
|
||||
if (key === 'ORGANIZER' || key === 'ATTENDEE') {
|
||||
lines.push(value);
|
||||
} else {
|
||||
lines.push(`${key}:${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('END:VEVENT', 'END:VCALENDAR');
|
||||
|
||||
return lines.join('\r\n');
|
||||
};
|
||||
|
||||
describe('formatCalDavCalendarEvent', () => {
|
||||
it('should format a standard event with all fields populated', () => {
|
||||
const rawData = buildICalEvent();
|
||||
const result = formatCalDavCalendarEvent(rawData, '/cal/event1.ics');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('/cal/event1.ics');
|
||||
expect(result!.title).toBe('Team Standup');
|
||||
expect(result!.description).toBe('Daily sync meeting');
|
||||
expect(result!.location).toBe('Conference Room B');
|
||||
expect(result!.iCalUid).toBe('test-uid-123@example.com');
|
||||
expect(result!.isCanceled).toBe(false);
|
||||
expect(result!.isFullDay).toBe(false);
|
||||
expect(result!.status).toBe('CONFIRMED');
|
||||
expect(result!.participants).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle an event with only required fields', () => {
|
||||
const rawData = buildICalEvent({
|
||||
SUMMARY: '',
|
||||
DESCRIPTION: '',
|
||||
LOCATION: '',
|
||||
ORGANIZER: '',
|
||||
ATTENDEE: '',
|
||||
});
|
||||
|
||||
const result = formatCalDavCalendarEvent(rawData, '/cal/minimal.ics');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.title).toBe('Untitled Event');
|
||||
expect(result!.description).toBe('');
|
||||
expect(result!.location).toBe('');
|
||||
expect(result!.participants).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should map cancelled status to isCanceled', () => {
|
||||
const rawData = buildICalEvent({ STATUS: 'CANCELLED' });
|
||||
const result = formatCalDavCalendarEvent(rawData, '/cal/cancelled.ics');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isCanceled).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect full-day events using VALUE=DATE', () => {
|
||||
const rawData = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'BEGIN:VEVENT',
|
||||
'UID:fullday@example.com',
|
||||
'SUMMARY:All Day Off',
|
||||
'DTSTART;VALUE=DATE:20230615',
|
||||
'DTEND;VALUE=DATE:20230616',
|
||||
'STATUS:CONFIRMED',
|
||||
'CREATED:20230601T120000Z',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const result = formatCalDavCalendarEvent(rawData, '/cal/fullday.ics');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isFullDay).toBe(true);
|
||||
});
|
||||
|
||||
it('should sanitize null bytes in string fields', () => {
|
||||
const rawData = buildICalEvent({
|
||||
SUMMARY: 'Meeting\u0000Title',
|
||||
});
|
||||
const result = formatCalDavCalendarEvent(rawData, '/cal/dirty.ics');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.title).not.toContain('\u0000');
|
||||
});
|
||||
|
||||
it('should return null for non-VEVENT data', () => {
|
||||
const rawData = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'BEGIN:VTODO',
|
||||
'UID:todo@example.com',
|
||||
'SUMMARY:Buy groceries',
|
||||
'END:VTODO',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const result = formatCalDavCalendarEvent(rawData, '/cal/todo.ics');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should map attendee PARTSTAT to correct response status', () => {
|
||||
const rawData = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'BEGIN:VEVENT',
|
||||
'UID:partstat@example.com',
|
||||
'SUMMARY:Status Test',
|
||||
'DTSTART:20230615T090000Z',
|
||||
'DTEND:20230615T100000Z',
|
||||
'STATUS:CONFIRMED',
|
||||
'CREATED:20230601T120000Z',
|
||||
'ATTENDEE;CN=Accepted;PARTSTAT=ACCEPTED:mailto:a@example.com',
|
||||
'ATTENDEE;CN=Declined;PARTSTAT=DECLINED:mailto:d@example.com',
|
||||
'ATTENDEE;CN=Tentative;PARTSTAT=TENTATIVE:mailto:t@example.com',
|
||||
'ATTENDEE;CN=NeedsAction;PARTSTAT=NEEDS-ACTION:mailto:n@example.com',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const result = formatCalDavCalendarEvent(rawData, '/cal/partstat.ics');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.participants).toHaveLength(4);
|
||||
|
||||
const statusMap = new Map(
|
||||
result!.participants.map((p) => [p.handle, p.responseStatus]),
|
||||
);
|
||||
|
||||
expect(statusMap.get('a@example.com')).toBe(
|
||||
CalendarEventParticipantResponseStatus.ACCEPTED,
|
||||
);
|
||||
expect(statusMap.get('d@example.com')).toBe(
|
||||
CalendarEventParticipantResponseStatus.DECLINED,
|
||||
);
|
||||
expect(statusMap.get('t@example.com')).toBe(
|
||||
CalendarEventParticipantResponseStatus.TENTATIVE,
|
||||
);
|
||||
expect(statusMap.get('n@example.com')).toBe(
|
||||
CalendarEventParticipantResponseStatus.NEEDS_ACTION,
|
||||
);
|
||||
});
|
||||
});
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { isEventInTimeRange } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-event-in-time-range.util';
|
||||
|
||||
describe('isEventInTimeRange', () => {
|
||||
const rangeStart = new Date('2023-06-01T00:00:00Z');
|
||||
const rangeEnd = new Date('2023-06-30T23:59:59Z');
|
||||
|
||||
it('should return true when event is fully within range', () => {
|
||||
const event = {
|
||||
startsAt: '2023-06-10T09:00:00.000Z',
|
||||
endsAt: '2023-06-10T10:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when event ends before range starts', () => {
|
||||
const event = {
|
||||
startsAt: '2023-05-01T09:00:00.000Z',
|
||||
endsAt: '2023-05-01T10:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when event starts after range ends', () => {
|
||||
const event = {
|
||||
startsAt: '2023-07-15T09:00:00.000Z',
|
||||
endsAt: '2023-07-15T10:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when event overlaps the start of the range', () => {
|
||||
const event = {
|
||||
startsAt: '2023-05-30T09:00:00.000Z',
|
||||
endsAt: '2023-06-02T10:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when event overlaps the end of the range', () => {
|
||||
const event = {
|
||||
startsAt: '2023-06-29T09:00:00.000Z',
|
||||
endsAt: '2023-07-05T10:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when event spans the entire range', () => {
|
||||
const event = {
|
||||
startsAt: '2023-05-01T00:00:00.000Z',
|
||||
endsAt: '2023-07-31T23:59:59.000Z',
|
||||
};
|
||||
|
||||
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when event ends exactly at range start', () => {
|
||||
const event = {
|
||||
startsAt: '2023-05-31T09:00:00.000Z',
|
||||
endsAt: '2023-06-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(isEventInTimeRange(event, rangeStart, rangeEnd)).toBe(false);
|
||||
});
|
||||
});
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { isFullDayEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-full-day-event.util';
|
||||
|
||||
describe('isFullDayEvent', () => {
|
||||
it('should return true for VALUE=DATE in DTSTART', () => {
|
||||
const rawData = [
|
||||
'BEGIN:VEVENT',
|
||||
'DTSTART;VALUE=DATE:20230615',
|
||||
'DTEND;VALUE=DATE:20230616',
|
||||
'END:VEVENT',
|
||||
].join('\r\n');
|
||||
|
||||
expect(isFullDayEvent(rawData)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for datetime DTSTART', () => {
|
||||
const rawData = [
|
||||
'BEGIN:VEVENT',
|
||||
'DTSTART:20230615T090000Z',
|
||||
'DTEND:20230615T100000Z',
|
||||
'END:VEVENT',
|
||||
].join('\r\n');
|
||||
|
||||
expect(isFullDayEvent(rawData)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for DTSTART with TZID', () => {
|
||||
const rawData = [
|
||||
'BEGIN:VEVENT',
|
||||
'DTSTART;TZID=America/New_York:20230615T090000',
|
||||
'DTEND;TZID=America/New_York:20230615T100000',
|
||||
'END:VEVENT',
|
||||
].join('\r\n');
|
||||
|
||||
expect(isFullDayEvent(rawData)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle LF-only line endings', () => {
|
||||
const rawData = [
|
||||
'BEGIN:VEVENT',
|
||||
'DTSTART;VALUE=DATE:20230615',
|
||||
'END:VEVENT',
|
||||
].join('\n');
|
||||
|
||||
expect(isFullDayEvent(rawData)).toBe(true);
|
||||
});
|
||||
});
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { CalDavHttpException } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/exceptions/caldav-http.exception';
|
||||
import { parseCalDAVError } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-caldav-error.util';
|
||||
import { CalendarEventImportDriverExceptionCode } from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
|
||||
|
||||
describe('parseCalDAVError', () => {
|
||||
it('should map "Invalid credentials" to INSUFFICIENT_PERMISSIONS', () => {
|
||||
const error = new Error('Invalid credentials');
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map "Collection does not exist on server" to NOT_FOUND', () => {
|
||||
const error = new Error('Collection does not exist on server');
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.NOT_FOUND,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map "no account for fetchCalendars" to INSUFFICIENT_PERMISSIONS', () => {
|
||||
const error = new Error('no account for fetchCalendars');
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map network errors to TEMPORARY_ERROR', () => {
|
||||
const error = Object.assign(new Error('Connection reset'), {
|
||||
code: 'ECONNRESET',
|
||||
});
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map ETIMEDOUT to TEMPORARY_ERROR', () => {
|
||||
const error = Object.assign(new Error('Timed out'), {
|
||||
code: 'ETIMEDOUT',
|
||||
});
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map HTTP 401 to INSUFFICIENT_PERMISSIONS', () => {
|
||||
const error = new CalDavHttpException(401, 'Unauthorized');
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map HTTP 403 to INSUFFICIENT_PERMISSIONS', () => {
|
||||
const error = new CalDavHttpException(403, 'Forbidden');
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map HTTP 404 to NOT_FOUND', () => {
|
||||
const error = new CalDavHttpException(404, 'Not Found');
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.NOT_FOUND,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map HTTP 429 to TEMPORARY_ERROR', () => {
|
||||
const error = new CalDavHttpException(429, 'Too Many Requests');
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map HTTP 500 to TEMPORARY_ERROR', () => {
|
||||
const error = new CalDavHttpException(500, 'Internal Server Error');
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map unhandled HTTP status to UNKNOWN', () => {
|
||||
const error = new CalDavHttpException(418, "I'm a Teapot");
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.UNKNOWN,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map CALDAV_SYNC_COLLECTION_NOT_SUPPORTED to CHANNEL_MISCONFIGURED', () => {
|
||||
const error = new Error(
|
||||
'CALDAV_SYNC_COLLECTION_NOT_SUPPORTED: Your CalDAV server does not support incremental sync (RFC 6578)',
|
||||
);
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.CHANNEL_MISCONFIGURED,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map unknown errors to UNKNOWN', () => {
|
||||
const error = new Error('Something unexpected happened');
|
||||
|
||||
expect(parseCalDAVError(error).code).toBe(
|
||||
CalendarEventImportDriverExceptionCode.UNKNOWN,
|
||||
);
|
||||
});
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { type VEvent } from 'node-ical';
|
||||
|
||||
import { parseAttendee } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-attendee.util';
|
||||
import { type FetchedCalendarEventParticipant } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
export const extractAttendeesFromEvent = (
|
||||
event: VEvent,
|
||||
): FetchedCalendarEventParticipant[] => {
|
||||
if (!event.attendee) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attendees = Array.isArray(event.attendee)
|
||||
? event.attendee
|
||||
: [event.attendee];
|
||||
|
||||
return attendees.map(parseAttendee);
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
const MAX_EXTRACTION_DEPTH = 10;
|
||||
|
||||
export const extractICalData = (
|
||||
calendarData: string | Record<string, unknown>,
|
||||
depth: number = MAX_EXTRACTION_DEPTH,
|
||||
): string | null => {
|
||||
if (!calendarData || depth <= 0) return null;
|
||||
|
||||
if (typeof calendarData === 'string' && calendarData.includes('VCALENDAR')) {
|
||||
return calendarData;
|
||||
}
|
||||
|
||||
if (typeof calendarData === 'object' && calendarData !== null) {
|
||||
for (const key in calendarData) {
|
||||
const result = extractICalData(
|
||||
calendarData[key] as string | Record<string, unknown>,
|
||||
depth - 1,
|
||||
);
|
||||
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { isNonEmptyString, isString } from '@sniptt/guards';
|
||||
import { isDefined } from 'class-validator';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
/**
|
||||
* Extracts the string value from an iCal property that may have parameters.
|
||||
@@ -11,7 +11,7 @@ import { isDefined } from 'class-validator';
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2 (Property Parameters)
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.1.2 (Multiple Values)
|
||||
*/
|
||||
export const icalDataExtractPropertyValue = (
|
||||
export const extractICalPropertyValue = (
|
||||
property:
|
||||
| string
|
||||
| { val?: string; params?: Record<string, unknown> }
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { type VEvent } from 'node-ical';
|
||||
|
||||
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: VEvent,
|
||||
): FetchedCalendarEventParticipant | null => {
|
||||
if (!event.organizer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof event.organizer === 'string') {
|
||||
const email = event.organizer.replace(/^mailto:/i, '');
|
||||
|
||||
return {
|
||||
displayName: email || 'Unknown',
|
||||
responseStatus: CalendarEventParticipantResponseStatus.ACCEPTED,
|
||||
handle: email,
|
||||
isOrganizer: true,
|
||||
};
|
||||
}
|
||||
|
||||
const organizerEmail = event.organizer.val?.replace(/^mailto:/i, '') || '';
|
||||
|
||||
return {
|
||||
displayName: event.organizer.params?.CN || organizerEmail || 'Unknown',
|
||||
responseStatus: CalendarEventParticipantResponseStatus.ACCEPTED,
|
||||
handle: organizerEmail,
|
||||
isOrganizer: true,
|
||||
};
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type VEvent } from 'node-ical';
|
||||
|
||||
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 FetchedCalendarEventParticipant } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
export const extractParticipantsFromEvent = (
|
||||
event: VEvent,
|
||||
): FetchedCalendarEventParticipant[] => {
|
||||
const participants: FetchedCalendarEventParticipant[] = [];
|
||||
|
||||
const organizer = extractOrganizerFromEvent(event);
|
||||
|
||||
if (organizer) {
|
||||
participants.push(organizer);
|
||||
}
|
||||
|
||||
const attendees = extractAttendeesFromEvent(event);
|
||||
|
||||
participants.push(...attendees);
|
||||
|
||||
return participants;
|
||||
};
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import * as ical from 'node-ical';
|
||||
|
||||
import { extractICalPropertyValue } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-ical-property-value.util';
|
||||
import { extractParticipantsFromEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/extract-participants-from-event.util';
|
||||
import { isFullDayEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/is-full-day-event.util';
|
||||
import { sanitizeCalendarEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/utils/sanitizeCalendarEvent';
|
||||
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
|
||||
|
||||
const PROPERTIES_TO_SANITIZE: (keyof FetchedCalendarEvent)[] = [
|
||||
'title',
|
||||
'startsAt',
|
||||
'endsAt',
|
||||
'id',
|
||||
'externalCreatedAt',
|
||||
'externalUpdatedAt',
|
||||
'description',
|
||||
'location',
|
||||
'iCalUid',
|
||||
'conferenceSolution',
|
||||
'conferenceLinkLabel',
|
||||
'conferenceLinkUrl',
|
||||
'recurringEventExternalId',
|
||||
'status',
|
||||
];
|
||||
|
||||
export const formatCalDavCalendarEvent = (
|
||||
rawData: string,
|
||||
objectUrl: string,
|
||||
): FetchedCalendarEvent | null => {
|
||||
try {
|
||||
const parsed = ical.parseICS(rawData);
|
||||
const vevents = Object.values(parsed).filter(
|
||||
(item) => item.type === 'VEVENT',
|
||||
);
|
||||
|
||||
if (vevents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = vevents[0] as ical.VEvent;
|
||||
const participants = extractParticipantsFromEvent(event);
|
||||
|
||||
const calendarEvent: FetchedCalendarEvent = {
|
||||
id: objectUrl,
|
||||
title: extractICalPropertyValue(event.summary, 'Untitled Event'),
|
||||
iCalUid: event.uid || '',
|
||||
description: extractICalPropertyValue(event.description),
|
||||
startsAt: event.start.toISOString(),
|
||||
endsAt: event.end.toISOString(),
|
||||
location: extractICalPropertyValue(event.location),
|
||||
isFullDay: isFullDayEvent(rawData),
|
||||
isCanceled: event.status === 'CANCELLED',
|
||||
conferenceLinkLabel: '',
|
||||
conferenceLinkUrl: extractICalPropertyValue(event.url),
|
||||
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',
|
||||
};
|
||||
|
||||
return sanitizeCalendarEvent(calendarEvent, PROPERTIES_TO_SANITIZE);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export const isEventInTimeRange = (
|
||||
event: { startsAt: string; endsAt: string },
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): boolean => {
|
||||
const eventStart = new Date(event.startsAt);
|
||||
const eventEnd = new Date(event.endsAt);
|
||||
|
||||
return eventStart < endDate && eventEnd > startDate;
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// node-ical converts all dates to JavaScript Date objects, losing the VALUE=DATE
|
||||
// distinction. We inspect the raw iCal text to detect full-day events.
|
||||
export const 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;
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type AttendeePartStat } from 'node-ical';
|
||||
|
||||
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
|
||||
|
||||
export const mapPartStatToResponseStatus = (
|
||||
partStat: 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;
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { type Attendee } from 'node-ical';
|
||||
|
||||
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 parseAttendee = (
|
||||
attendee: Attendee,
|
||||
): FetchedCalendarEventParticipant => {
|
||||
if (typeof attendee === 'string') {
|
||||
const handle = attendee.replace(/^mailto:/i, '');
|
||||
|
||||
return {
|
||||
displayName: handle || 'Unknown',
|
||||
responseStatus: mapPartStatToResponseStatus('NEEDS-ACTION'),
|
||||
handle,
|
||||
isOrganizer: false,
|
||||
};
|
||||
}
|
||||
|
||||
const handle = attendee.val?.replace(/^mailto:/i, '') || '';
|
||||
const displayName = attendee.params?.CN || handle || 'Unknown';
|
||||
const partStat = attendee.params?.PARTSTAT || 'NEEDS-ACTION';
|
||||
|
||||
return {
|
||||
displayName,
|
||||
responseStatus: mapPartStatToResponseStatus(partStat),
|
||||
handle,
|
||||
isOrganizer: false,
|
||||
};
|
||||
};
|
||||
+33
-14
@@ -1,13 +1,41 @@
|
||||
import { CalDavHttpException } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/exceptions/caldav-http.exception';
|
||||
import {
|
||||
CalendarEventImportDriverException,
|
||||
CalendarEventImportDriverExceptionCode,
|
||||
} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
|
||||
import { parseCalDAVHttpStatusError } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-caldav-http-status-error.util';
|
||||
|
||||
const NETWORK_ERROR_CODES = [
|
||||
'ECONNRESET',
|
||||
'ENOTFOUND',
|
||||
'ECONNABORTED',
|
||||
'ETIMEDOUT',
|
||||
'ERR_NETWORK',
|
||||
];
|
||||
|
||||
export const parseCalDAVError = (
|
||||
error: Error,
|
||||
error: Error & { code?: string },
|
||||
): CalendarEventImportDriverException => {
|
||||
const { message } = error;
|
||||
|
||||
if (error.code && NETWORK_ERROR_CODES.includes(error.code)) {
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof CalDavHttpException) {
|
||||
return parseCalDAVHttpStatusError(error.status, message);
|
||||
}
|
||||
|
||||
if (message.includes('CALDAV_SYNC_COLLECTION_NOT_SUPPORTED')) {
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.CHANNEL_MISCONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
switch (message) {
|
||||
case 'Collection does not exist on server':
|
||||
return new CalendarEventImportDriverException(
|
||||
@@ -19,6 +47,8 @@ export const parseCalDAVError = (
|
||||
case 'no account for fetchAddressBooks':
|
||||
case 'no account for fetchCalendars':
|
||||
case 'Must have account before syncCalendars':
|
||||
case 'Invalid credentials':
|
||||
case 'Invalid auth method':
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
@@ -33,21 +63,10 @@ export const parseCalDAVError = (
|
||||
CalendarEventImportDriverExceptionCode.NOT_FOUND,
|
||||
);
|
||||
|
||||
case 'Invalid credentials':
|
||||
default:
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
);
|
||||
|
||||
case 'Invalid auth method':
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
CalendarEventImportDriverExceptionCode.UNKNOWN,
|
||||
);
|
||||
}
|
||||
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.UNKNOWN,
|
||||
);
|
||||
};
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
CalendarEventImportDriverException,
|
||||
CalendarEventImportDriverExceptionCode,
|
||||
} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
|
||||
|
||||
export const parseCalDAVHttpStatusError = (
|
||||
statusCode: number,
|
||||
message: string,
|
||||
): CalendarEventImportDriverException => {
|
||||
switch (statusCode) {
|
||||
case 401:
|
||||
case 403:
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
);
|
||||
case 404:
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.NOT_FOUND,
|
||||
);
|
||||
case 429:
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
);
|
||||
default:
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.UNKNOWN,
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user