feat: CalDav Driver (#13170)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@ -14,6 +14,9 @@ export class ConnectionParameters {
|
||||
@Field(() => Number)
|
||||
port: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
username?: string;
|
||||
|
||||
/**
|
||||
* Note: This field is stored in plain text in the database.
|
||||
* While encrypting it could provide an extra layer of defense, we have decided not to,
|
||||
@ -26,6 +29,18 @@ export class ConnectionParameters {
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class EmailAccountConnectionParameters {
|
||||
@Field(() => ConnectionParameters, { nullable: true })
|
||||
IMAP?: ConnectionParameters;
|
||||
|
||||
@Field(() => ConnectionParameters, { nullable: true })
|
||||
SMTP?: ConnectionParameters;
|
||||
|
||||
@Field(() => ConnectionParameters, { nullable: true })
|
||||
CALDAV?: ConnectionParameters;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ConnectionParametersOutput {
|
||||
@Field(() => String)
|
||||
@ -34,6 +49,9 @@ export class ConnectionParametersOutput {
|
||||
@Field(() => Number)
|
||||
port: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
username?: string;
|
||||
|
||||
@Field(() => String)
|
||||
password: string;
|
||||
|
||||
|
||||
@ -16,10 +16,7 @@ import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/re
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { ConnectedImapSmtpCaldavAccount } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connected-account.dto';
|
||||
import { ImapSmtpCaldavConnectionSuccess } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection-success.dto';
|
||||
import {
|
||||
AccountType,
|
||||
ConnectionParameters,
|
||||
} from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
|
||||
import { EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
|
||||
import { ImapSmtpCaldavValidatorService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service';
|
||||
import { ImapSmtpCaldavService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@ -78,12 +75,11 @@ export class ImapSmtpCaldavResolver {
|
||||
|
||||
@Mutation(() => ImapSmtpCaldavConnectionSuccess)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async saveImapSmtpCaldav(
|
||||
async saveImapSmtpCaldavAccount(
|
||||
@Args('accountOwnerId') accountOwnerId: string,
|
||||
@Args('handle') handle: string,
|
||||
@Args('accountType') accountType: AccountType,
|
||||
@Args('connectionParameters')
|
||||
connectionParameters: ConnectionParameters,
|
||||
connectionParameters: EmailAccountConnectionParameters,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args('id', { nullable: true }) id?: string,
|
||||
): Promise<ImapSmtpCaldavConnectionSuccess> {
|
||||
@ -100,23 +96,16 @@ export class ImapSmtpCaldavResolver {
|
||||
);
|
||||
}
|
||||
|
||||
const validatedParams =
|
||||
this.mailConnectionValidatorService.validateProtocolConnectionParams(
|
||||
connectionParameters,
|
||||
);
|
||||
|
||||
await this.ImapSmtpCaldavConnectionService.testImapSmtpCaldav(
|
||||
const validatedParams = await this.validateAndTestConnectionParameters(
|
||||
connectionParameters,
|
||||
handle,
|
||||
validatedParams,
|
||||
accountType.type,
|
||||
);
|
||||
|
||||
await this.imapSmtpCaldavApisService.setupConnectedAccount({
|
||||
await this.imapSmtpCaldavApisService.setupCompleteAccount({
|
||||
handle,
|
||||
workspaceMemberId: accountOwnerId,
|
||||
workspaceId: workspace.id,
|
||||
connectionParams: validatedParams,
|
||||
accountType: accountType.type,
|
||||
connectionParameters: validatedParams,
|
||||
connectedAccountId: id,
|
||||
});
|
||||
|
||||
@ -124,4 +113,34 @@ export class ImapSmtpCaldavResolver {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async validateAndTestConnectionParameters(
|
||||
connectionParameters: EmailAccountConnectionParameters,
|
||||
handle: string,
|
||||
): Promise<EmailAccountConnectionParameters> {
|
||||
const validatedParams: EmailAccountConnectionParameters = {};
|
||||
const protocols = ['IMAP', 'SMTP', 'CALDAV'] as const;
|
||||
|
||||
for (const protocol of protocols) {
|
||||
const params = connectionParameters[protocol];
|
||||
|
||||
if (params) {
|
||||
validatedParams[protocol] =
|
||||
this.mailConnectionValidatorService.validateProtocolConnectionParams(
|
||||
params,
|
||||
);
|
||||
const validatedProtocolParams = validatedParams[protocol];
|
||||
|
||||
if (validatedProtocolParams) {
|
||||
await this.ImapSmtpCaldavConnectionService.testImapSmtpCaldav(
|
||||
handle,
|
||||
validatedProtocolParams,
|
||||
protocol,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validatedParams;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ export class ImapSmtpCaldavValidatorService {
|
||||
private readonly protocolConnectionSchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.number().int().positive('Port must be a positive number'),
|
||||
username: z.string().optional(),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
secure: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
ConnectionParameters,
|
||||
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
|
||||
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
@ -121,7 +122,31 @@ export class ImapSmtpCaldavService {
|
||||
handle: string,
|
||||
params: ConnectionParameters,
|
||||
): Promise<boolean> {
|
||||
this.logger.log('CALDAV connection testing not yet implemented', params);
|
||||
const client = new CalDAVClient({
|
||||
serverUrl: params.host,
|
||||
username: params.username ?? handle,
|
||||
password: params.password,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.listCalendars();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`CALDAV connection failed: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
if (error.code === 'FailedToOpenSocket') {
|
||||
throw new UserInputError(`CALDAV connection failed: ${error.message}`, {
|
||||
userFriendlyMessage:
|
||||
"We couldn't connect to your CalDAV server. Please check your server settings and try again.",
|
||||
});
|
||||
}
|
||||
|
||||
throw new UserInputError(`CALDAV connection failed: ${error.message}`, {
|
||||
userFriendlyMessage:
|
||||
'Invalid credentials. Please check your username and password.',
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export type ConnectionParameters = {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password: string;
|
||||
secure?: boolean;
|
||||
};
|
||||
|
||||
@ -16,6 +16,7 @@ import { CalendarOngoingStaleCronCommand } from 'src/modules/calendar/calendar-e
|
||||
import { CalendarEventListFetchCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job';
|
||||
import { CalendarEventsImportCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-events-import.cron.job';
|
||||
import { CalendarOngoingStaleCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-ongoing-stale.cron.job';
|
||||
import { CalDavDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module';
|
||||
import { GoogleCalendarDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/google-calendar-driver.module';
|
||||
import { MicrosoftCalendarDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/microsoft-calendar-driver.module';
|
||||
import { CalendarEventListFetchJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
|
||||
@ -43,6 +44,7 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
|
||||
WorkspaceDataSourceModule,
|
||||
CalendarEventCleanerModule,
|
||||
GoogleCalendarDriverModule,
|
||||
CalDavDriverModule,
|
||||
MicrosoftCalendarDriverModule,
|
||||
BillingModule,
|
||||
RefreshTokensManagerModule,
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
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 { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service';
|
||||
|
||||
@Module({
|
||||
imports: [TwentyConfigModule],
|
||||
providers: [CalDavClientProvider, CalDavGetEventsService],
|
||||
exports: [CalDavGetEventsService],
|
||||
})
|
||||
export class CalDavDriverModule {}
|
||||
@ -0,0 +1,493 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import * as ical from 'node-ical';
|
||||
import {
|
||||
calendarMultiGet,
|
||||
createAccount,
|
||||
DAVAccount,
|
||||
DAVCalendar,
|
||||
DAVNamespaceShort,
|
||||
DAVObject,
|
||||
fetchCalendars,
|
||||
getBasicAuthHeaders,
|
||||
syncCollection,
|
||||
} from 'tsdav';
|
||||
|
||||
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 {
|
||||
FetchedCalendarEvent,
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
return {
|
||||
id: objectUrl,
|
||||
title: event.summary || 'Untitled Event',
|
||||
iCalUID: event.uid || '',
|
||||
description: event.description || '',
|
||||
startsAt: event.start.toISOString(),
|
||||
endsAt: event.end.toISOString(),
|
||||
location: event.location || '',
|
||||
isFullDay: this.isFullDayEvent(rawData),
|
||||
isCanceled: event.status === 'CANCELLED',
|
||||
conferenceLinkLabel: '',
|
||||
conferenceLinkUrl: 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',
|
||||
};
|
||||
} 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 (error) {
|
||||
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 (error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
|
||||
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
export class CalDavClientProvider {
|
||||
public async getCalDavCalendarClient(
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountWorkspaceEntity,
|
||||
'id' | 'provider' | 'connectionParameters' | 'handle'
|
||||
>,
|
||||
): Promise<CalDAVClient> {
|
||||
if (
|
||||
!connectedAccount.connectionParameters?.CALDAV?.password ||
|
||||
!connectedAccount.connectionParameters?.CALDAV?.host
|
||||
) {
|
||||
throw new Error('Missing required CalDAV connection parameters');
|
||||
}
|
||||
const caldavClient = new CalDAVClient({
|
||||
username:
|
||||
connectedAccount.connectionParameters.CALDAV.username ??
|
||||
connectedAccount.handle,
|
||||
password: connectedAccount.connectionParameters.CALDAV.password,
|
||||
serverUrl: connectedAccount.connectionParameters.CALDAV.host,
|
||||
});
|
||||
|
||||
return caldavClient;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { CalDavClientProvider } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/providers/caldav.provider';
|
||||
import { parseCalDAVError } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/utils/parse-caldav-error.util';
|
||||
import { GetCalendarEventsResponse } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-get-events.service';
|
||||
import { 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,
|
||||
) {}
|
||||
|
||||
public async getCalendarEvents(
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountWorkspaceEntity,
|
||||
'provider' | 'id' | 'connectionParameters' | 'handle'
|
||||
>,
|
||||
syncCursor?: string,
|
||||
): Promise<GetCalendarEventsResponse> {
|
||||
this.logger.log(`Getting calendar events for ${connectedAccount.handle}`);
|
||||
|
||||
try {
|
||||
const caldavCalendarClient =
|
||||
await this.caldavCalendarClientProvider.getCalDavCalendarClient(
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
const startDate = new Date(
|
||||
Date.now() -
|
||||
CalDavGetEventsService.PAST_DAYS_WINDOW * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const endDate = new Date(
|
||||
Date.now() +
|
||||
CalDavGetEventsService.FUTURE_DAYS_WINDOW * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const result = await caldavCalendarClient.getEvents({
|
||||
startDate,
|
||||
endDate,
|
||||
syncCursor: syncCursor ? JSON.parse(syncCursor) : undefined,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Found ${result.events.length} calendar events for ${connectedAccount.handle}`,
|
||||
);
|
||||
|
||||
return {
|
||||
fullEvents: true,
|
||||
calendarEvents: result.events,
|
||||
nextSyncCursor: JSON.stringify(result.syncCursor),
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleError(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: Error) {
|
||||
this.logger.error(
|
||||
`Error in ${CalDavGetEventsService.name} - getCalendarEvents`,
|
||||
error,
|
||||
);
|
||||
|
||||
throw parseCalDAVError(error);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import {
|
||||
CalendarEventImportDriverException,
|
||||
CalendarEventImportDriverExceptionCode,
|
||||
} from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception';
|
||||
|
||||
export const parseCalDAVError = (
|
||||
error: Error,
|
||||
): CalendarEventImportDriverException => {
|
||||
const { message } = error;
|
||||
|
||||
switch (message) {
|
||||
case 'Collection does not exist on server':
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.NOT_FOUND,
|
||||
);
|
||||
|
||||
case 'no account for smartCollectionSync':
|
||||
case 'no account for fetchAddressBooks':
|
||||
case 'no account for fetchCalendars':
|
||||
case 'Must have account before syncCalendars':
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
);
|
||||
|
||||
case 'cannot fetchVCards for undefined addressBook':
|
||||
case 'cannot find calendarUserAddresses':
|
||||
case 'cannot fetchCalendarObjects for undefined calendar':
|
||||
case 'cannot find homeUrl':
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.NOT_FOUND,
|
||||
);
|
||||
|
||||
case 'Invalid credentials':
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
);
|
||||
|
||||
case 'Invalid auth method':
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
|
||||
);
|
||||
}
|
||||
|
||||
return new CalendarEventImportDriverException(
|
||||
message,
|
||||
CalendarEventImportDriverExceptionCode.UNKNOWN,
|
||||
);
|
||||
};
|
||||
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
|
||||
import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service';
|
||||
import { GoogleCalendarGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service';
|
||||
import { MicrosoftCalendarGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/microsoft-calendar/services/microsoft-calendar-get-events.service';
|
||||
import {
|
||||
@ -23,12 +24,13 @@ export class CalendarGetCalendarEventsService {
|
||||
constructor(
|
||||
private readonly googleCalendarGetEventsService: GoogleCalendarGetEventsService,
|
||||
private readonly microsoftCalendarGetEventsService: MicrosoftCalendarGetEventsService,
|
||||
private readonly caldavCalendarGetEventsService: CalDavGetEventsService,
|
||||
) {}
|
||||
|
||||
public async getCalendarEvents(
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountWorkspaceEntity,
|
||||
'provider' | 'refreshToken' | 'id'
|
||||
'provider' | 'refreshToken' | 'id' | 'connectionParameters' | 'handle'
|
||||
>,
|
||||
syncCursor?: string,
|
||||
): Promise<GetCalendarEventsResponse> {
|
||||
@ -43,6 +45,11 @@ export class CalendarGetCalendarEventsService {
|
||||
connectedAccount,
|
||||
syncCursor,
|
||||
);
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||
return this.caldavCalendarGetEventsService.getCalendarEvents(
|
||||
connectedAccount,
|
||||
syncCursor,
|
||||
);
|
||||
default:
|
||||
throw new CalendarEventImportException(
|
||||
`Provider ${connectedAccount.provider} is not supported`,
|
||||
|
||||
@ -2,22 +2,26 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import {
|
||||
AccountType,
|
||||
ConnectionParameters,
|
||||
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||
import { EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import {
|
||||
CalendarEventListFetchJob,
|
||||
CalendarEventListFetchJobData,
|
||||
} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
|
||||
import {
|
||||
CalendarChannelSyncStage,
|
||||
CalendarChannelSyncStatus,
|
||||
CalendarChannelWorkspaceEntity,
|
||||
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
|
||||
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import {
|
||||
MessageChannelSyncStage,
|
||||
@ -36,28 +40,22 @@ export class ImapSmtpCalDavAPIService {
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
@InjectMessageQueue(MessageQueue.messagingQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectMessageQueue(MessageQueue.calendarQueue)
|
||||
private readonly calendarQueueService: MessageQueueService,
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
@InjectRepository(ObjectMetadataEntity, 'core')
|
||||
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly objectMetadataRepository: WorkspaceRepository<ObjectMetadataEntity>,
|
||||
) {}
|
||||
|
||||
async setupConnectedAccount(input: {
|
||||
async setupCompleteAccount(input: {
|
||||
handle: string;
|
||||
workspaceMemberId: string;
|
||||
workspaceId: string;
|
||||
accountType: AccountType;
|
||||
connectionParams: ConnectionParameters;
|
||||
connectionParameters: EmailAccountConnectionParameters;
|
||||
connectedAccountId?: string;
|
||||
}) {
|
||||
const {
|
||||
handle,
|
||||
workspaceId,
|
||||
workspaceMemberId,
|
||||
connectionParams,
|
||||
connectedAccountId,
|
||||
} = input;
|
||||
const { handle, workspaceId, workspaceMemberId, connectedAccountId } =
|
||||
input;
|
||||
|
||||
const connectedAccountRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ConnectedAccountWorkspaceEntity>(
|
||||
@ -65,7 +63,19 @@ export class ImapSmtpCalDavAPIService {
|
||||
'connectedAccount',
|
||||
);
|
||||
|
||||
const connectedAccount = connectedAccountId
|
||||
const messageChannelRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageChannelWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'messageChannel',
|
||||
);
|
||||
|
||||
const calendarChannelRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<CalendarChannelWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'calendarChannel',
|
||||
);
|
||||
|
||||
const existingAccount = connectedAccountId
|
||||
? await connectedAccountRepository.findOne({
|
||||
where: { id: connectedAccountId },
|
||||
})
|
||||
@ -73,191 +83,251 @@ export class ImapSmtpCalDavAPIService {
|
||||
where: { handle, accountOwnerId: workspaceMemberId },
|
||||
});
|
||||
|
||||
const existingAccountId = connectedAccount?.id;
|
||||
const newOrExistingConnectedAccountId =
|
||||
existingAccountId ?? connectedAccountId ?? v4();
|
||||
|
||||
const messageChannelRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageChannelWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'messageChannel',
|
||||
);
|
||||
const accountId = existingAccount?.id ?? connectedAccountId ?? v4();
|
||||
|
||||
const workspaceDataSource =
|
||||
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
let shouldEnableSync = false;
|
||||
|
||||
if (connectedAccount) {
|
||||
const hadOnlySmtp =
|
||||
connectedAccount.connectionParameters?.SMTP &&
|
||||
!connectedAccount.connectionParameters?.IMAP &&
|
||||
!connectedAccount.connectionParameters?.CALDAV;
|
||||
|
||||
const isAddingImapOrCaldav =
|
||||
input.accountType === 'IMAP' || input.accountType === 'CALDAV';
|
||||
|
||||
shouldEnableSync = Boolean(hadOnlySmtp && isAddingImapOrCaldav);
|
||||
}
|
||||
let createdMessageChannel: MessageChannelWorkspaceEntity | null = null;
|
||||
let createdCalendarChannel: CalendarChannelWorkspaceEntity | null = null;
|
||||
|
||||
await workspaceDataSource.transaction(async () => {
|
||||
if (!existingAccountId) {
|
||||
const newConnectedAccount = await connectedAccountRepository.save(
|
||||
{
|
||||
id: newOrExistingConnectedAccountId,
|
||||
handle,
|
||||
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
|
||||
connectionParameters: {
|
||||
[input.accountType]: connectionParams,
|
||||
},
|
||||
accountOwnerId: workspaceMemberId,
|
||||
},
|
||||
{},
|
||||
);
|
||||
await this.upsertConnectedAccount(
|
||||
input,
|
||||
accountId,
|
||||
existingAccount,
|
||||
connectedAccountRepository,
|
||||
);
|
||||
|
||||
const connectedAccountMetadata =
|
||||
await this.objectMetadataRepository.findOneOrFail({
|
||||
where: { nameSingular: 'connectedAccount', workspaceId },
|
||||
});
|
||||
createdMessageChannel = await this.setupMessageChannels(
|
||||
input,
|
||||
accountId,
|
||||
messageChannelRepository,
|
||||
);
|
||||
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: 'connectedAccount',
|
||||
action: DatabaseEventAction.CREATED,
|
||||
events: [
|
||||
{
|
||||
recordId: newConnectedAccount.id,
|
||||
objectMetadata: connectedAccountMetadata,
|
||||
properties: {
|
||||
after: newConnectedAccount,
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const newMessageChannel = await messageChannelRepository.save(
|
||||
{
|
||||
id: v4(),
|
||||
connectedAccountId: newOrExistingConnectedAccountId,
|
||||
type: MessageChannelType.EMAIL,
|
||||
handle,
|
||||
isSyncEnabled: shouldEnableSync,
|
||||
syncStatus: shouldEnableSync
|
||||
? MessageChannelSyncStatus.ONGOING
|
||||
: MessageChannelSyncStatus.NOT_SYNCED,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const messageChannelMetadata =
|
||||
await this.objectMetadataRepository.findOneOrFail({
|
||||
where: { nameSingular: 'messageChannel', workspaceId },
|
||||
});
|
||||
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: 'messageChannel',
|
||||
action: DatabaseEventAction.CREATED,
|
||||
events: [
|
||||
{
|
||||
recordId: newMessageChannel.id,
|
||||
objectMetadata: messageChannelMetadata,
|
||||
properties: {
|
||||
after: newMessageChannel,
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
});
|
||||
} else {
|
||||
const updatedConnectedAccount = await connectedAccountRepository.update(
|
||||
{
|
||||
id: newOrExistingConnectedAccountId,
|
||||
},
|
||||
{
|
||||
connectionParameters: {
|
||||
...connectedAccount.connectionParameters,
|
||||
[input.accountType]: connectionParams,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const connectedAccountMetadata =
|
||||
await this.objectMetadataRepository.findOneOrFail({
|
||||
where: { nameSingular: 'connectedAccount', workspaceId },
|
||||
});
|
||||
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: 'connectedAccount',
|
||||
action: DatabaseEventAction.UPDATED,
|
||||
events: [
|
||||
{
|
||||
recordId: newOrExistingConnectedAccountId,
|
||||
objectMetadata: connectedAccountMetadata,
|
||||
properties: {
|
||||
before: connectedAccount,
|
||||
after: {
|
||||
...connectedAccount,
|
||||
...updatedConnectedAccount.raw[0],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const messageChannels = await messageChannelRepository.find({
|
||||
where: { connectedAccountId: newOrExistingConnectedAccountId },
|
||||
});
|
||||
|
||||
const messageChannelUpdates = await messageChannelRepository.update(
|
||||
{
|
||||
connectedAccountId: newOrExistingConnectedAccountId,
|
||||
},
|
||||
{
|
||||
syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING,
|
||||
syncStatus: shouldEnableSync
|
||||
? MessageChannelSyncStatus.ONGOING
|
||||
: MessageChannelSyncStatus.NOT_SYNCED,
|
||||
syncCursor: '',
|
||||
syncStageStartedAt: null,
|
||||
isSyncEnabled: shouldEnableSync,
|
||||
},
|
||||
);
|
||||
|
||||
const messageChannelMetadata =
|
||||
await this.objectMetadataRepository.findOneOrFail({
|
||||
where: { nameSingular: 'messageChannel', workspaceId },
|
||||
});
|
||||
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: 'messageChannel',
|
||||
action: DatabaseEventAction.UPDATED,
|
||||
events: messageChannels.map((messageChannel) => ({
|
||||
recordId: messageChannel.id,
|
||||
objectMetadata: messageChannelMetadata,
|
||||
properties: {
|
||||
before: messageChannel,
|
||||
after: { ...messageChannel, ...messageChannelUpdates.raw[0] },
|
||||
},
|
||||
})),
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
createdCalendarChannel = await this.setupCalendarChannels(
|
||||
input,
|
||||
accountId,
|
||||
calendarChannelRepository,
|
||||
);
|
||||
});
|
||||
|
||||
if (!shouldEnableSync) {
|
||||
return;
|
||||
await this.enqueueSyncJobs(
|
||||
input,
|
||||
accountId,
|
||||
workspaceId,
|
||||
createdMessageChannel,
|
||||
createdCalendarChannel,
|
||||
);
|
||||
}
|
||||
|
||||
private async upsertConnectedAccount(
|
||||
input: {
|
||||
handle: string;
|
||||
workspaceMemberId: string;
|
||||
workspaceId: string;
|
||||
connectionParameters: EmailAccountConnectionParameters;
|
||||
},
|
||||
accountId: string,
|
||||
existingAccount: ConnectedAccountWorkspaceEntity | null,
|
||||
connectedAccountRepository: WorkspaceRepository<ConnectedAccountWorkspaceEntity>,
|
||||
) {
|
||||
const accountData = {
|
||||
id: accountId,
|
||||
handle: input.handle,
|
||||
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
|
||||
connectionParameters: input.connectionParameters,
|
||||
accountOwnerId: input.workspaceMemberId,
|
||||
};
|
||||
|
||||
const savedAccount = await connectedAccountRepository.save(accountData, {});
|
||||
|
||||
const connectedAccountMetadata =
|
||||
await this.objectMetadataRepository.findOneOrFail({
|
||||
where: {
|
||||
nameSingular: 'connectedAccount',
|
||||
workspaceId: input.workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAccount) {
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: 'connectedAccount',
|
||||
action: DatabaseEventAction.UPDATED,
|
||||
events: [
|
||||
{
|
||||
recordId: savedAccount.id,
|
||||
objectMetadata: connectedAccountMetadata,
|
||||
properties: {
|
||||
before: existingAccount,
|
||||
after: savedAccount,
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
} else {
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: 'connectedAccount',
|
||||
action: DatabaseEventAction.CREATED,
|
||||
events: [
|
||||
{
|
||||
recordId: savedAccount.id,
|
||||
objectMetadata: connectedAccountMetadata,
|
||||
properties: {
|
||||
after: savedAccount,
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async setupMessageChannels(
|
||||
input: {
|
||||
handle: string;
|
||||
workspaceId: string;
|
||||
connectionParameters: EmailAccountConnectionParameters;
|
||||
},
|
||||
accountId: string,
|
||||
messageChannelRepository: WorkspaceRepository<MessageChannelWorkspaceEntity>,
|
||||
): Promise<MessageChannelWorkspaceEntity | null> {
|
||||
const existingChannels = await messageChannelRepository.find({
|
||||
where: { connectedAccountId: accountId },
|
||||
});
|
||||
|
||||
if (existingChannels.length > 0) {
|
||||
await messageChannelRepository.delete({
|
||||
connectedAccountId: accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const messageChannels = await messageChannelRepository.find({
|
||||
where: {
|
||||
connectedAccountId: newOrExistingConnectedAccountId,
|
||||
const shouldEnableSync = Boolean(input.connectionParameters.IMAP);
|
||||
|
||||
const newMessageChannel = await messageChannelRepository.save(
|
||||
{
|
||||
id: v4(),
|
||||
connectedAccountId: accountId,
|
||||
type: MessageChannelType.EMAIL,
|
||||
handle: input.handle,
|
||||
isSyncEnabled: shouldEnableSync,
|
||||
syncStatus: shouldEnableSync
|
||||
? MessageChannelSyncStatus.ONGOING
|
||||
: MessageChannelSyncStatus.NOT_SYNCED,
|
||||
syncStage: shouldEnableSync
|
||||
? MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING
|
||||
: undefined,
|
||||
syncCursor: '',
|
||||
syncStageStartedAt: null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const messageChannelMetadata =
|
||||
await this.objectMetadataRepository.findOneOrFail({
|
||||
where: {
|
||||
nameSingular: 'messageChannel',
|
||||
workspaceId: input.workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: 'messageChannel',
|
||||
action: DatabaseEventAction.CREATED,
|
||||
events: [
|
||||
{
|
||||
recordId: newMessageChannel.id,
|
||||
objectMetadata: messageChannelMetadata,
|
||||
properties: {
|
||||
after: newMessageChannel,
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
|
||||
for (const messageChannel of messageChannels) {
|
||||
return shouldEnableSync ? newMessageChannel : null;
|
||||
}
|
||||
|
||||
private async setupCalendarChannels(
|
||||
input: {
|
||||
handle: string;
|
||||
workspaceId: string;
|
||||
connectionParameters: EmailAccountConnectionParameters;
|
||||
},
|
||||
accountId: string,
|
||||
calendarChannelRepository: WorkspaceRepository<CalendarChannelWorkspaceEntity>,
|
||||
): Promise<CalendarChannelWorkspaceEntity | null> {
|
||||
const existingChannels = await calendarChannelRepository.find({
|
||||
where: { connectedAccountId: accountId },
|
||||
});
|
||||
|
||||
if (existingChannels.length > 0) {
|
||||
await calendarChannelRepository.delete({
|
||||
connectedAccountId: accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const shouldEnableSync = Boolean(input.connectionParameters.CALDAV);
|
||||
|
||||
if (shouldEnableSync) {
|
||||
const newCalendarChannel = await calendarChannelRepository.save(
|
||||
{
|
||||
id: v4(),
|
||||
connectedAccountId: accountId,
|
||||
handle: input.handle,
|
||||
isSyncEnabled: shouldEnableSync,
|
||||
syncStatus: CalendarChannelSyncStatus.ONGOING,
|
||||
syncStage:
|
||||
CalendarChannelSyncStage.FULL_CALENDAR_EVENT_LIST_FETCH_PENDING,
|
||||
syncCursor: '',
|
||||
syncStageStartedAt: null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const calendarChannelMetadata =
|
||||
await this.objectMetadataRepository.findOneOrFail({
|
||||
where: {
|
||||
nameSingular: 'calendarChannel',
|
||||
workspaceId: input.workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: 'calendarChannel',
|
||||
action: DatabaseEventAction.CREATED,
|
||||
events: [
|
||||
{
|
||||
recordId: newCalendarChannel.id,
|
||||
objectMetadata: calendarChannelMetadata,
|
||||
properties: {
|
||||
after: newCalendarChannel,
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
|
||||
return newCalendarChannel;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async enqueueSyncJobs(
|
||||
input: {
|
||||
connectionParameters: EmailAccountConnectionParameters;
|
||||
},
|
||||
accountId: string,
|
||||
workspaceId: string,
|
||||
messageChannel: MessageChannelWorkspaceEntity | null,
|
||||
calendarChannel: CalendarChannelWorkspaceEntity | null,
|
||||
) {
|
||||
if (input.connectionParameters.IMAP && messageChannel) {
|
||||
await this.messageQueueService.add<MessagingMessageListFetchJobData>(
|
||||
MessagingMessageListFetchJob.name,
|
||||
{
|
||||
@ -266,5 +336,15 @@ export class ImapSmtpCalDavAPIService {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (input.connectionParameters.CALDAV && calendarChannel) {
|
||||
await this.calendarQueueService.add<CalendarEventListFetchJobData>(
|
||||
CalendarEventListFetchJob.name,
|
||||
{
|
||||
workspaceId,
|
||||
calendarChannelId: calendarChannel.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user