From 2e71b5f1d94c6d49c1fe33f3136c82f028b2806e Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:41:32 +0100 Subject: [PATCH] feat(calendar): add Google/Apple Calendar sync module Implement external calendar synchronization: - CalDAV sync for Apple Calendar and generic CalDAV servers - Google Calendar API integration with OAuth2 flow - iCal URL import for read-only calendar feeds - Bi-directional sync with configurable direction - Scheduled background sync (every 5 minutes) - Manual sync trigger via API New endpoints: - GET/POST /api/v1/sync/external - List/connect external calendars - GET/PUT/DELETE /api/v1/sync/external/:id - Manage external calendar - POST /api/v1/sync/external/:id/sync - Trigger manual sync - POST /api/v1/sync/caldav/discover - Discover CalDAV calendars - GET /api/v1/sync/google/auth-url - Get Google OAuth URL - GET /api/v1/sync/google/callback - Handle OAuth callback - GET /api/v1/calendars/:id/export.ics - Export calendar as iCal Co-Authored-By: Claude Opus 4.5 --- .../backend/src/__tests__/utils/mock-db.ts | 6 +- apps/calendar/apps/backend/src/app.module.ts | 2 + .../apps/backend/src/sync/caldav.service.ts | 287 ++++++++ .../apps/backend/src/sync/dto/index.ts | 90 +++ .../src/sync/google-calendar.service.ts | 418 ++++++++++++ .../apps/backend/src/sync/ical.service.ts | 263 ++++++++ apps/calendar/apps/backend/src/sync/index.ts | 7 + .../apps/backend/src/sync/sync.controller.ts | 141 ++++ .../apps/backend/src/sync/sync.module.ts | 15 + .../apps/backend/src/sync/sync.service.ts | 632 ++++++++++++++++++ 10 files changed, 1858 insertions(+), 3 deletions(-) create mode 100644 apps/calendar/apps/backend/src/sync/caldav.service.ts create mode 100644 apps/calendar/apps/backend/src/sync/dto/index.ts create mode 100644 apps/calendar/apps/backend/src/sync/google-calendar.service.ts create mode 100644 apps/calendar/apps/backend/src/sync/ical.service.ts create mode 100644 apps/calendar/apps/backend/src/sync/index.ts create mode 100644 apps/calendar/apps/backend/src/sync/sync.controller.ts create mode 100644 apps/calendar/apps/backend/src/sync/sync.module.ts create mode 100644 apps/calendar/apps/backend/src/sync/sync.service.ts diff --git a/apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts b/apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts index f388eca88..a290aca90 100644 --- a/apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts +++ b/apps/calendar/apps/backend/src/__tests__/utils/mock-db.ts @@ -38,21 +38,21 @@ export function createMockDb(): jest.Mocked { */ export function setupMockDbQuery(mockDb: jest.Mocked, data: T[]): void { // For SELECT queries - the final method in the chain resolves to data - mockDb.where.mockResolvedValueOnce(data); + (mockDb as any).where.mockResolvedValueOnce(data); } /** * Setup mock database for INSERT operations */ export function setupMockDbInsert(mockDb: jest.Mocked, data: T[]): void { - mockDb.returning.mockResolvedValueOnce(data); + (mockDb as any).returning.mockResolvedValueOnce(data); } /** * Setup mock database for UPDATE operations */ export function setupMockDbUpdate(mockDb: jest.Mocked, data: T[]): void { - mockDb.returning.mockResolvedValueOnce(data); + (mockDb as any).returning.mockResolvedValueOnce(data); } /** diff --git a/apps/calendar/apps/backend/src/app.module.ts b/apps/calendar/apps/backend/src/app.module.ts index 159c4cd91..a11c648e1 100644 --- a/apps/calendar/apps/backend/src/app.module.ts +++ b/apps/calendar/apps/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { EventTagModule } from './event-tag/event-tag.module'; import { EventTagGroupModule } from './event-tag-group/event-tag-group.module'; import { ReminderModule } from './reminder/reminder.module'; import { ShareModule } from './share/share.module'; +import { SyncModule } from './sync/sync.module'; import { NetworkModule } from './network/network.module'; import { MetricsModule } from './metrics'; import { EmailModule } from './email/email.module'; @@ -32,6 +33,7 @@ import { NotificationModule } from './notification/notification.module'; EventTagGroupModule, ReminderModule, ShareModule, + SyncModule, NetworkModule, ], }) diff --git a/apps/calendar/apps/backend/src/sync/caldav.service.ts b/apps/calendar/apps/backend/src/sync/caldav.service.ts new file mode 100644 index 000000000..a7a8425a0 --- /dev/null +++ b/apps/calendar/apps/backend/src/sync/caldav.service.ts @@ -0,0 +1,287 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DAVClient, DAVCalendar, DAVCalendarObject } from 'tsdav'; +import { ICalService, ParsedEvent } from './ical.service'; + +export interface CalDavCalendar { + url: string; + displayName: string; + description?: string; + color?: string; + ctag?: string; +} + +export interface CalDavSyncResult { + events: ParsedEvent[]; + ctag?: string; + etag?: string; +} + +@Injectable() +export class CalDavService { + private readonly logger = new Logger(CalDavService.name); + + constructor(private readonly icalService: ICalService) {} + + /** + * Create a DAVClient for CalDAV operations + */ + private async createClient( + serverUrl: string, + username: string, + password: string + ): Promise { + const client = new DAVClient({ + serverUrl, + credentials: { + username, + password, + }, + authMethod: 'Basic', + defaultAccountType: 'caldav', + }); + + await client.login(); + return client; + } + + /** + * Discover available calendars on a CalDAV server + */ + async discoverCalendars( + serverUrl: string, + username: string, + password: string + ): Promise { + try { + const client = await this.createClient(serverUrl, username, password); + const calendars = await client.fetchCalendars(); + + return calendars.map((cal: DAVCalendar) => ({ + url: cal.url, + displayName: String(cal.displayName || 'Unnamed Calendar'), + description: cal.description as string | undefined, + color: this.extractColor(cal), + ctag: cal.ctag, + })); + } catch (error) { + this.logger.error(`Failed to discover calendars: ${error}`); + throw new Error( + `CalDAV discovery failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + /** + * Fetch all events from a CalDAV calendar + */ + async fetchEvents( + serverUrl: string, + calendarUrl: string, + username: string, + password: string, + startDate?: Date, + endDate?: Date + ): Promise { + try { + const client = await this.createClient(serverUrl, username, password); + + // Get calendar metadata + const calendars = await client.fetchCalendars(); + const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl); + + if (!calendar) { + throw new Error('Calendar not found'); + } + + // Fetch calendar objects + const objects = await client.fetchCalendarObjects({ + calendar, + timeRange: + startDate && endDate + ? { + start: startDate.toISOString(), + end: endDate.toISOString(), + } + : undefined, + }); + + // Parse all events + const events: ParsedEvent[] = []; + for (const obj of objects) { + if (obj.data) { + try { + const parsed = this.icalService.parseICalData(obj.data); + events.push(...parsed); + } catch (error) { + this.logger.warn(`Failed to parse calendar object: ${error}`); + } + } + } + + return { + events, + ctag: calendar.ctag, + }; + } catch (error) { + this.logger.error(`Failed to fetch CalDAV events: ${error}`); + throw error; + } + } + + /** + * Create or update an event on a CalDAV server + */ + async upsertEvent( + serverUrl: string, + calendarUrl: string, + username: string, + password: string, + event: { + uid: string; + title: string; + description?: string; + location?: string; + startTime: Date; + endTime: Date; + isAllDay?: boolean; + recurrenceRule?: string; + } + ): Promise<{ etag?: string }> { + try { + const client = await this.createClient(serverUrl, username, password); + + const calendars = await client.fetchCalendars(); + const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl); + + if (!calendar) { + throw new Error('Calendar not found'); + } + + // Generate iCal data for this single event + const icalData = this.icalService.generateICalData('Event', [ + { + id: event.uid, + title: event.title, + description: event.description, + location: event.location, + startTime: event.startTime, + endTime: event.endTime, + isAllDay: event.isAllDay, + recurrenceRule: event.recurrenceRule, + }, + ]); + + // Create or update the event + const eventUrl = `${calendarUrl}${event.uid}.ics`; + + const result = await client.createCalendarObject({ + calendar, + filename: `${event.uid}.ics`, + iCalString: icalData, + }); + + return { etag: (result as { etag?: string } | undefined)?.etag }; + } catch (error) { + this.logger.error(`Failed to upsert CalDAV event: ${error}`); + throw error; + } + } + + /** + * Delete an event from a CalDAV server + */ + async deleteEvent( + serverUrl: string, + calendarUrl: string, + username: string, + password: string, + uid: string + ): Promise { + try { + const client = await this.createClient(serverUrl, username, password); + + const calendars = await client.fetchCalendars(); + const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl); + + if (!calendar) { + throw new Error('Calendar not found'); + } + + // Find the calendar object + const objects = await client.fetchCalendarObjects({ calendar }); + const calendarObject = objects.find((obj: DAVCalendarObject) => { + if (!obj.data) return false; + try { + const parsed = this.icalService.parseICalData(obj.data); + return parsed.some((e) => e.uid === uid); + } catch { + return false; + } + }); + + if (calendarObject) { + await client.deleteCalendarObject({ + calendarObject, + }); + } + } catch (error) { + this.logger.error(`Failed to delete CalDAV event: ${error}`); + throw error; + } + } + + /** + * Check if calendar has changes (using ctag) + */ + async hasChanges( + serverUrl: string, + calendarUrl: string, + username: string, + password: string, + lastCtag?: string + ): Promise<{ hasChanges: boolean; ctag?: string }> { + try { + const client = await this.createClient(serverUrl, username, password); + const calendars = await client.fetchCalendars(); + const calendar = calendars.find((c: DAVCalendar) => c.url === calendarUrl); + + if (!calendar) { + throw new Error('Calendar not found'); + } + + const currentCtag = calendar.ctag; + const hasChanges = !lastCtag || lastCtag !== currentCtag; + + return { hasChanges, ctag: currentCtag }; + } catch (error) { + this.logger.error(`Failed to check CalDAV changes: ${error}`); + throw error; + } + } + + /** + * Get Apple CalDAV server URL + */ + getAppleCalDavUrl(): string { + return 'https://caldav.icloud.com'; + } + + /** + * Get Google CalDAV server URL + */ + getGoogleCalDavUrl(): string { + return 'https://apidata.googleusercontent.com/caldav/v2'; + } + + /** + * Extract color from CalDAV calendar properties + */ + private extractColor(calendar: DAVCalendar): string | undefined { + // CalDAV calendar-color is typically in the props + const calWithProps = calendar as DAVCalendar & { props?: Record }; + if (calWithProps.props && typeof calWithProps.props['calendar-color'] === 'string') { + return calWithProps.props['calendar-color']; + } + return undefined; + } +} diff --git a/apps/calendar/apps/backend/src/sync/dto/index.ts b/apps/calendar/apps/backend/src/sync/dto/index.ts new file mode 100644 index 000000000..f6931d51d --- /dev/null +++ b/apps/calendar/apps/backend/src/sync/dto/index.ts @@ -0,0 +1,90 @@ +import { IsString, IsOptional, IsEnum, IsInt, Min, Max, IsUrl, IsBoolean } from 'class-validator'; + +export class ConnectCalendarDto { + @IsString() + name!: string; + + @IsEnum(['google', 'apple', 'caldav', 'ical_url']) + provider!: 'google' | 'apple' | 'caldav' | 'ical_url'; + + @IsUrl() + calendarUrl!: string; + + @IsOptional() + @IsString() + username?: string; + + @IsOptional() + @IsString() + password?: string; + + @IsOptional() + @IsString() + accessToken?: string; + + @IsOptional() + @IsString() + refreshToken?: string; + + @IsOptional() + @IsEnum(['import', 'export', 'both']) + syncDirection?: 'import' | 'export' | 'both'; + + @IsOptional() + @IsInt() + @Min(5) + @Max(1440) + syncInterval?: number; + + @IsOptional() + @IsString() + color?: string; +} + +export class UpdateExternalCalendarDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsEnum(['import', 'export', 'both']) + syncDirection?: 'import' | 'export' | 'both'; + + @IsOptional() + @IsInt() + @Min(5) + @Max(1440) + syncInterval?: number; + + @IsOptional() + @IsBoolean() + syncEnabled?: boolean; + + @IsOptional() + @IsString() + color?: string; + + @IsOptional() + @IsBoolean() + isVisible?: boolean; +} + +export class DiscoverCalDavDto { + @IsUrl() + serverUrl!: string; + + @IsString() + username!: string; + + @IsString() + password!: string; +} + +export class GoogleOAuthCallbackDto { + @IsString() + code!: string; + + @IsOptional() + @IsString() + state?: string; +} diff --git a/apps/calendar/apps/backend/src/sync/google-calendar.service.ts b/apps/calendar/apps/backend/src/sync/google-calendar.service.ts new file mode 100644 index 000000000..5b9bfb204 --- /dev/null +++ b/apps/calendar/apps/backend/src/sync/google-calendar.service.ts @@ -0,0 +1,418 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ParsedEvent } from './ical.service'; + +interface GoogleCalendarEvent { + id: string; + summary: string; + description?: string; + location?: string; + start: { + dateTime?: string; + date?: string; + timeZone?: string; + }; + end: { + dateTime?: string; + date?: string; + timeZone?: string; + }; + recurrence?: string[]; + status?: string; + organizer?: { + email: string; + displayName?: string; + }; + attendees?: Array<{ + email: string; + displayName?: string; + responseStatus?: string; + }>; + updated?: string; + created?: string; +} + +interface GoogleCalendarList { + id: string; + summary: string; + description?: string; + backgroundColor?: string; + primary?: boolean; +} + +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; +} + +@Injectable() +export class GoogleCalendarService { + private readonly logger = new Logger(GoogleCalendarService.name); + private readonly clientId: string; + private readonly clientSecret: string; + private readonly redirectUri: string; + + constructor(private readonly configService: ConfigService) { + this.clientId = this.configService.get('GOOGLE_CLIENT_ID') || ''; + this.clientSecret = this.configService.get('GOOGLE_CLIENT_SECRET') || ''; + this.redirectUri = this.configService.get('GOOGLE_REDIRECT_URI') || ''; + } + + /** + * Check if Google Calendar is configured + */ + isConfigured(): boolean { + return !!(this.clientId && this.clientSecret && this.redirectUri); + } + + /** + * Get OAuth2 authorization URL + */ + getAuthUrl(state?: string): string { + const params = new URLSearchParams({ + client_id: this.clientId, + redirect_uri: this.redirectUri, + response_type: 'code', + scope: [ + 'https://www.googleapis.com/auth/calendar.readonly', + 'https://www.googleapis.com/auth/calendar.events', + ].join(' '), + access_type: 'offline', + prompt: 'consent', + }); + + if (state) { + params.set('state', state); + } + + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; + } + + /** + * Exchange authorization code for tokens + */ + async exchangeCodeForTokens(code: string): Promise { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: this.redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + this.logger.error(`Token exchange failed: ${error}`); + throw new Error('Failed to exchange authorization code'); + } + + return response.json(); + } + + /** + * Refresh access token + */ + async refreshAccessToken(refreshToken: string): Promise { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token', + }), + }); + + if (!response.ok) { + const error = await response.text(); + this.logger.error(`Token refresh failed: ${error}`); + throw new Error('Failed to refresh access token'); + } + + return response.json(); + } + + /** + * List available calendars + */ + async listCalendars(accessToken: string): Promise { + const response = await fetch('https://www.googleapis.com/calendar/v3/users/me/calendarList', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to list calendars: ${response.status}`); + } + + const data = await response.json(); + return data.items || []; + } + + /** + * Fetch events from a calendar + */ + async fetchEvents( + accessToken: string, + calendarId: string, + startDate?: Date, + endDate?: Date + ): Promise { + const params = new URLSearchParams({ + singleEvents: 'true', + orderBy: 'startTime', + maxResults: '2500', + }); + + if (startDate) { + params.set('timeMin', startDate.toISOString()); + } + if (endDate) { + params.set('timeMax', endDate.toISOString()); + } + + const response = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch events: ${response.status}`); + } + + const data = await response.json(); + const googleEvents: GoogleCalendarEvent[] = data.items || []; + + return googleEvents.map((event) => this.convertGoogleEvent(event)); + } + + /** + * Create an event in Google Calendar + */ + async createEvent( + accessToken: string, + calendarId: string, + event: { + title: string; + description?: string; + location?: string; + startTime: Date; + endTime: Date; + isAllDay?: boolean; + recurrenceRule?: string; + } + ): Promise<{ id: string }> { + const googleEvent: Partial = { + summary: event.title, + description: event.description, + location: event.location, + }; + + if (event.isAllDay) { + googleEvent.start = { date: event.startTime.toISOString().split('T')[0] }; + googleEvent.end = { date: event.endTime.toISOString().split('T')[0] }; + } else { + googleEvent.start = { dateTime: event.startTime.toISOString() }; + googleEvent.end = { dateTime: event.endTime.toISOString() }; + } + + if (event.recurrenceRule) { + googleEvent.recurrence = [`RRULE:${event.recurrenceRule}`]; + } + + const response = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(googleEvent), + } + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to create event: ${response.status} ${error}`); + } + + const created = await response.json(); + return { id: created.id }; + } + + /** + * Update an event in Google Calendar + */ + async updateEvent( + accessToken: string, + calendarId: string, + eventId: string, + event: { + title?: string; + description?: string; + location?: string; + startTime?: Date; + endTime?: Date; + isAllDay?: boolean; + } + ): Promise { + // First fetch the existing event + const getResponse = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!getResponse.ok) { + throw new Error(`Failed to fetch event for update: ${getResponse.status}`); + } + + const existingEvent = await getResponse.json(); + + // Merge updates + const updatedEvent: Partial = { + ...existingEvent, + summary: event.title ?? existingEvent.summary, + description: event.description ?? existingEvent.description, + location: event.location ?? existingEvent.location, + }; + + if (event.startTime && event.endTime) { + if (event.isAllDay) { + updatedEvent.start = { date: event.startTime.toISOString().split('T')[0] }; + updatedEvent.end = { date: event.endTime.toISOString().split('T')[0] }; + } else { + updatedEvent.start = { dateTime: event.startTime.toISOString() }; + updatedEvent.end = { dateTime: event.endTime.toISOString() }; + } + } + + const response = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updatedEvent), + } + ); + + if (!response.ok) { + throw new Error(`Failed to update event: ${response.status}`); + } + } + + /** + * Delete an event from Google Calendar + */ + async deleteEvent(accessToken: string, calendarId: string, eventId: string): Promise { + const response = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok && response.status !== 404) { + throw new Error(`Failed to delete event: ${response.status}`); + } + } + + /** + * Convert Google Calendar event to our ParsedEvent format + */ + private convertGoogleEvent(event: GoogleCalendarEvent): ParsedEvent { + const isAllDay = !!event.start.date; + + let startDate: Date; + let endDate: Date; + + if (isAllDay) { + startDate = new Date(event.start.date!); + endDate = new Date(event.end.date!); + } else { + startDate = new Date(event.start.dateTime!); + endDate = new Date(event.end.dateTime!); + } + + // Extract RRULE from recurrence array + let rrule: string | undefined; + if (event.recurrence?.length) { + const rruleLine = event.recurrence.find((r) => r.startsWith('RRULE:')); + if (rruleLine) { + rrule = rruleLine.replace('RRULE:', ''); + } + } + + return { + uid: event.id, + summary: event.summary, + description: event.description, + location: event.location, + dtstart: startDate, + dtend: endDate, + isAllDay, + rrule, + status: this.mapGoogleStatus(event.status), + organizer: event.organizer?.email, + attendees: event.attendees?.map((a) => ({ + email: a.email, + name: a.displayName, + status: this.mapGoogleResponseStatus(a.responseStatus), + })), + lastModified: event.updated ? new Date(event.updated) : undefined, + created: event.created ? new Date(event.created) : undefined, + }; + } + + /** + * Map Google event status to our status + */ + private mapGoogleStatus(status?: string): string { + const mapping: Record = { + confirmed: 'confirmed', + tentative: 'tentative', + cancelled: 'cancelled', + }; + return mapping[status || 'confirmed'] || 'confirmed'; + } + + /** + * Map Google response status to our attendee status + */ + private mapGoogleResponseStatus(status?: string): string | undefined { + if (!status) return undefined; + const mapping: Record = { + accepted: 'accepted', + declined: 'declined', + tentative: 'tentative', + needsAction: 'pending', + }; + return mapping[status] || 'pending'; + } +} diff --git a/apps/calendar/apps/backend/src/sync/ical.service.ts b/apps/calendar/apps/backend/src/sync/ical.service.ts new file mode 100644 index 000000000..20570a6a7 --- /dev/null +++ b/apps/calendar/apps/backend/src/sync/ical.service.ts @@ -0,0 +1,263 @@ +import { Injectable, Logger } from '@nestjs/common'; +import ICAL from 'ical.js'; + +export interface ParsedEvent { + uid: string; + summary: string; + description?: string; + location?: string; + dtstart: Date; + dtend: Date; + isAllDay: boolean; + rrule?: string; + status?: string; + organizer?: string; + attendees?: Array<{ email: string; name?: string; status?: string }>; + lastModified?: Date; + created?: Date; + sequence?: number; +} + +export interface CalendarExport { + name: string; + events: ParsedEvent[]; +} + +@Injectable() +export class ICalService { + private readonly logger = new Logger(ICalService.name); + + /** + * Parse iCal/ICS data into structured events + */ + parseICalData(icalData: string): ParsedEvent[] { + const events: ParsedEvent[] = []; + + try { + const jcalData = ICAL.parse(icalData); + const vcalendar = new ICAL.Component(jcalData); + const vevents = vcalendar.getAllSubcomponents('vevent'); + + for (const vevent of vevents) { + try { + const event = this.parseVEvent(vevent); + if (event) { + events.push(event); + } + } catch (error) { + this.logger.warn(`Failed to parse event: ${error}`); + } + } + } catch (error) { + this.logger.error(`Failed to parse iCal data: ${error}`); + throw new Error('Invalid iCal data format'); + } + + return events; + } + + /** + * Parse a single VEVENT component + */ + private parseVEvent(vevent: ICAL.Component): ParsedEvent | null { + const event = new ICAL.Event(vevent); + + const uid = event.uid; + const summary = event.summary; + + if (!uid || !summary) { + return null; + } + + const dtstart = event.startDate; + const dtend = event.endDate; + + if (!dtstart || !dtend) { + return null; + } + + // Check if all-day event (DATE vs DATE-TIME) + const isAllDay = dtstart.isDate; + + // Extract RRULE if present + let rrule: string | undefined; + const rruleProp = vevent.getFirstProperty('rrule'); + if (rruleProp) { + rrule = rruleProp.toICALString().replace('RRULE:', ''); + } + + // Extract attendees + const attendees: Array<{ email: string; name?: string; status?: string }> = []; + const attendeeProps = vevent.getAllProperties('attendee'); + for (const attendee of attendeeProps) { + const email = attendee.getFirstValue()?.toString().replace('mailto:', '') || ''; + const cn = attendee.getParameter('cn'); + const partstat = attendee.getParameter('partstat'); + if (email) { + attendees.push({ + email, + name: cn?.toString(), + status: this.mapPartstat(partstat?.toString()), + }); + } + } + + // Extract organizer + let organizer: string | undefined; + const organizerProp = vevent.getFirstProperty('organizer'); + if (organizerProp) { + organizer = organizerProp.getFirstValue()?.toString().replace('mailto:', ''); + } + + // Extract timestamps + const lastModifiedProp = vevent.getFirstProperty('last-modified'); + const createdProp = vevent.getFirstProperty('created'); + const sequenceProp = vevent.getFirstProperty('sequence'); + + return { + uid, + summary, + description: event.description || undefined, + location: event.location || undefined, + dtstart: dtstart.toJSDate(), + dtend: dtend.toJSDate(), + isAllDay, + rrule, + status: this.mapStatus(vevent.getFirstPropertyValue('status')?.toString()), + organizer, + attendees: attendees.length > 0 ? attendees : undefined, + lastModified: lastModifiedProp?.getFirstValue() + ? new Date(lastModifiedProp.getFirstValue()?.toString() || '') + : undefined, + created: createdProp?.getFirstValue() + ? new Date(createdProp.getFirstValue()?.toString() || '') + : undefined, + sequence: sequenceProp + ? parseInt(sequenceProp.getFirstValue()?.toString() || '0', 10) + : undefined, + }; + } + + /** + * Map iCal PARTSTAT to our status + */ + private mapPartstat(partstat?: string): string | undefined { + if (!partstat) return undefined; + const mapping: Record = { + ACCEPTED: 'accepted', + DECLINED: 'declined', + TENTATIVE: 'tentative', + 'NEEDS-ACTION': 'pending', + }; + return mapping[partstat.toUpperCase()] || 'pending'; + } + + /** + * Map iCal STATUS to our status + */ + private mapStatus(status?: string): string | undefined { + if (!status) return 'confirmed'; + const mapping: Record = { + CONFIRMED: 'confirmed', + TENTATIVE: 'tentative', + CANCELLED: 'cancelled', + }; + return mapping[status.toUpperCase()] || 'confirmed'; + } + + /** + * Generate iCal/ICS data from events + */ + generateICalData( + calendarName: string, + events: Array<{ + id: string; + title: string; + description?: string | null; + location?: string | null; + startTime: Date; + endTime: Date; + isAllDay?: boolean | null; + recurrenceRule?: string | null; + status?: string | null; + }> + ): string { + const vcalendar = new ICAL.Component(['vcalendar', [], []]); + + // Set calendar properties + vcalendar.addPropertyWithValue('version', '2.0'); + vcalendar.addPropertyWithValue('prodid', '-//ManaCore Calendar//EN'); + vcalendar.addPropertyWithValue('calscale', 'GREGORIAN'); + vcalendar.addPropertyWithValue('method', 'PUBLISH'); + vcalendar.addPropertyWithValue('x-wr-calname', calendarName); + + for (const event of events) { + const vevent = new ICAL.Component('vevent'); + + // Required properties + vevent.addPropertyWithValue('uid', `${event.id}@manacore.app`); + vevent.addPropertyWithValue('summary', event.title); + + // Timestamps + const dtstart = ICAL.Time.fromJSDate(event.startTime, false); + const dtend = ICAL.Time.fromJSDate(event.endTime, false); + + if (event.isAllDay) { + dtstart.isDate = true; + dtend.isDate = true; + } + + vevent.addPropertyWithValue('dtstart', dtstart); + vevent.addPropertyWithValue('dtend', dtend); + + // Optional properties + if (event.description) { + vevent.addPropertyWithValue('description', event.description); + } + if (event.location) { + vevent.addPropertyWithValue('location', event.location); + } + if (event.recurrenceRule) { + const rruleProp = new ICAL.Property('rrule'); + rruleProp.setValue(ICAL.Recur.fromString(event.recurrenceRule)); + vevent.addProperty(rruleProp); + } + + // Status + const status = (event.status || 'confirmed').toUpperCase(); + vevent.addPropertyWithValue('status', status); + + // Metadata + vevent.addPropertyWithValue('dtstamp', ICAL.Time.now()); + vevent.addPropertyWithValue('created', ICAL.Time.fromJSDate(new Date(), false)); + + vcalendar.addSubcomponent(vevent); + } + + return vcalendar.toString(); + } + + /** + * Fetch and parse iCal from URL + */ + async fetchAndParseICalUrl(url: string): Promise { + try { + const response = await fetch(url, { + headers: { + Accept: 'text/calendar', + 'User-Agent': 'ManaCore Calendar/1.0', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch iCal: ${response.status} ${response.statusText}`); + } + + const icalData = await response.text(); + return this.parseICalData(icalData); + } catch (error) { + this.logger.error(`Failed to fetch iCal from ${url}: ${error}`); + throw error; + } + } +} diff --git a/apps/calendar/apps/backend/src/sync/index.ts b/apps/calendar/apps/backend/src/sync/index.ts new file mode 100644 index 000000000..6467f4ed8 --- /dev/null +++ b/apps/calendar/apps/backend/src/sync/index.ts @@ -0,0 +1,7 @@ +export * from './sync.module'; +export * from './sync.service'; +export * from './sync.controller'; +export * from './ical.service'; +export * from './caldav.service'; +export * from './google-calendar.service'; +export * from './dto'; diff --git a/apps/calendar/apps/backend/src/sync/sync.controller.ts b/apps/calendar/apps/backend/src/sync/sync.controller.ts new file mode 100644 index 000000000..d53bc3b72 --- /dev/null +++ b/apps/calendar/apps/backend/src/sync/sync.controller.ts @@ -0,0 +1,141 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + Res, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { Response } from 'express'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { SyncService } from './sync.service'; +import { ConnectCalendarDto, UpdateExternalCalendarDto, DiscoverCalDavDto } from './dto'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class SyncController { + constructor(private readonly syncService: SyncService) {} + + /** + * List all external calendars for the current user + */ + @Get('sync/external') + async listExternalCalendars(@CurrentUser() user: CurrentUserData) { + const calendars = await this.syncService.findByUser(user.userId); + return { calendars }; + } + + /** + * Connect a new external calendar + */ + @Post('sync/external') + async connectCalendar(@CurrentUser() user: CurrentUserData, @Body() dto: ConnectCalendarDto) { + const calendar = await this.syncService.connect(user.userId, dto); + return { calendar }; + } + + /** + * Get a specific external calendar + */ + @Get('sync/external/:id') + async getExternalCalendar(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + const calendar = await this.syncService.findOne(id, user.userId); + return { calendar }; + } + + /** + * Update external calendar settings + */ + @Put('sync/external/:id') + async updateExternalCalendar( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateExternalCalendarDto + ) { + const calendar = await this.syncService.update(id, user.userId, dto); + return { calendar }; + } + + /** + * Disconnect an external calendar + */ + @Delete('sync/external/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async disconnectCalendar(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + await this.syncService.disconnect(id, user.userId); + } + + /** + * Trigger manual sync for an external calendar + */ + @Post('sync/external/:id/sync') + async triggerSync(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + // Verify ownership + await this.syncService.findOne(id, user.userId); + + const result = await this.syncService.syncCalendar(id); + return { + success: result.success, + eventsImported: result.eventsImported, + eventsExported: result.eventsExported, + errors: result.errors, + }; + } + + /** + * Discover CalDAV calendars on a server + */ + @Post('sync/caldav/discover') + async discoverCalDav(@Body() dto: DiscoverCalDavDto) { + return this.syncService.discoverCalDav(dto); + } + + /** + * Get Google OAuth authorization URL + */ + @Get('sync/google/auth-url') + async getGoogleAuthUrl(@Query('state') state?: string) { + const url = this.syncService.getGoogleAuthUrl(state); + return { url }; + } + + /** + * Handle Google OAuth callback + */ + @Get('sync/google/callback') + async handleGoogleCallback( + @CurrentUser() user: CurrentUserData, + @Query('code') code: string, + @Query('state') state?: string + ) { + const result = await this.syncService.handleGoogleCallback(code, user.userId); + return { + ...result, + state, + }; + } + + /** + * Export a local calendar as iCal file + */ + @Get('calendars/:calendarId/export.ics') + async exportCalendar( + @CurrentUser() user: CurrentUserData, + @Param('calendarId') calendarId: string, + @Res() res: Response + ) { + const icalData = await this.syncService.exportCalendarAsIcal(calendarId, user.userId); + + res.set({ + 'Content-Type': 'text/calendar; charset=utf-8', + 'Content-Disposition': `attachment; filename="calendar-${calendarId}.ics"`, + }); + res.send(icalData); + } +} diff --git a/apps/calendar/apps/backend/src/sync/sync.module.ts b/apps/calendar/apps/backend/src/sync/sync.module.ts new file mode 100644 index 000000000..b3af23fdc --- /dev/null +++ b/apps/calendar/apps/backend/src/sync/sync.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../db/database.module'; +import { SyncController } from './sync.controller'; +import { SyncService } from './sync.service'; +import { ICalService } from './ical.service'; +import { CalDavService } from './caldav.service'; +import { GoogleCalendarService } from './google-calendar.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [SyncController], + providers: [SyncService, ICalService, CalDavService, GoogleCalendarService], + exports: [SyncService, ICalService], +}) +export class SyncModule {} diff --git a/apps/calendar/apps/backend/src/sync/sync.service.ts b/apps/calendar/apps/backend/src/sync/sync.service.ts new file mode 100644 index 000000000..97cfb2456 --- /dev/null +++ b/apps/calendar/apps/backend/src/sync/sync.service.ts @@ -0,0 +1,632 @@ +import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { eq, and, lte, isNull, or } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { externalCalendars, ExternalCalendar, events, calendars } from '../db/schema'; +import { ICalService, ParsedEvent } from './ical.service'; +import { CalDavService } from './caldav.service'; +import { GoogleCalendarService } from './google-calendar.service'; +import { ConnectCalendarDto, UpdateExternalCalendarDto, DiscoverCalDavDto } from './dto'; + +interface SyncResult { + success: boolean; + eventsImported: number; + eventsExported: number; + errors: string[]; +} + +@Injectable() +export class SyncService { + private readonly logger = new Logger(SyncService.name); + + constructor( + @Inject(DATABASE_CONNECTION) private readonly db: Database, + private readonly icalService: ICalService, + private readonly caldavService: CalDavService, + private readonly googleCalendarService: GoogleCalendarService + ) {} + + /** + * Connect an external calendar + */ + async connect(userId: string, dto: ConnectCalendarDto): Promise { + // Validate connection based on provider + if (dto.provider === 'caldav' || dto.provider === 'apple') { + if (!dto.username || !dto.password) { + throw new BadRequestException('CalDAV requires username and password'); + } + // Test connection + await this.caldavService.discoverCalendars( + dto.provider === 'apple' ? this.caldavService.getAppleCalDavUrl() : dto.calendarUrl, + dto.username, + dto.password + ); + } else if (dto.provider === 'google') { + if (!dto.accessToken) { + throw new BadRequestException('Google Calendar requires OAuth tokens'); + } + } else if (dto.provider === 'ical_url') { + // Test that we can fetch the URL + await this.icalService.fetchAndParseICalUrl(dto.calendarUrl); + } + + const [externalCalendar] = await this.db + .insert(externalCalendars) + .values({ + userId, + name: dto.name, + provider: dto.provider, + calendarUrl: dto.calendarUrl, + username: dto.username, + encryptedPassword: dto.password, // TODO: Encrypt in production + accessToken: dto.accessToken, + refreshToken: dto.refreshToken, + tokenExpiresAt: dto.accessToken ? new Date(Date.now() + 3600 * 1000) : null, + syncDirection: dto.syncDirection || 'both', + syncInterval: dto.syncInterval || 15, + color: dto.color || '#6B7280', + }) + .returning(); + + // Trigger initial sync + this.syncCalendar(externalCalendar.id).catch((err) => { + this.logger.error(`Initial sync failed for ${externalCalendar.id}: ${err}`); + }); + + return externalCalendar; + } + + /** + * Disconnect an external calendar + */ + async disconnect(id: string, userId: string): Promise { + const [externalCalendar] = await this.db + .select() + .from(externalCalendars) + .where(and(eq(externalCalendars.id, id), eq(externalCalendars.userId, userId))); + + if (!externalCalendar) { + throw new NotFoundException('External calendar not found'); + } + + // Delete synced events first + await this.db.delete(events).where(eq(events.externalCalendarId, id)); + + // Delete the external calendar + await this.db.delete(externalCalendars).where(eq(externalCalendars.id, id)); + } + + /** + * Update external calendar settings + */ + async update( + id: string, + userId: string, + dto: UpdateExternalCalendarDto + ): Promise { + const [existing] = await this.db + .select() + .from(externalCalendars) + .where(and(eq(externalCalendars.id, id), eq(externalCalendars.userId, userId))); + + if (!existing) { + throw new NotFoundException('External calendar not found'); + } + + const [updated] = await this.db + .update(externalCalendars) + .set({ + ...dto, + updatedAt: new Date(), + }) + .where(eq(externalCalendars.id, id)) + .returning(); + + return updated; + } + + /** + * Get all external calendars for a user + */ + async findByUser(userId: string): Promise { + return this.db.select().from(externalCalendars).where(eq(externalCalendars.userId, userId)); + } + + /** + * Get single external calendar + */ + async findOne(id: string, userId: string): Promise { + const [externalCalendar] = await this.db + .select() + .from(externalCalendars) + .where(and(eq(externalCalendars.id, id), eq(externalCalendars.userId, userId))); + + if (!externalCalendar) { + throw new NotFoundException('External calendar not found'); + } + + return externalCalendar; + } + + /** + * Discover CalDAV calendars + */ + async discoverCalDav(dto: DiscoverCalDavDto) { + const discovered = await this.caldavService.discoverCalendars( + dto.serverUrl, + dto.username, + dto.password + ); + + return { + calendars: discovered.map((cal) => ({ + url: cal.url, + name: cal.displayName, + color: cal.color, + description: cal.description, + })), + }; + } + + /** + * Get Google OAuth URL + */ + getGoogleAuthUrl(state?: string): string { + if (!this.googleCalendarService.isConfigured()) { + throw new BadRequestException('Google Calendar is not configured'); + } + return this.googleCalendarService.getAuthUrl(state); + } + + /** + * Handle Google OAuth callback + */ + async handleGoogleCallback(code: string, userId: string) { + const tokens = await this.googleCalendarService.exchangeCodeForTokens(code); + + // List available calendars + const calendarList = await this.googleCalendarService.listCalendars(tokens.access_token); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + calendars: calendarList.map((cal) => ({ + id: cal.id, + name: cal.summary, + description: cal.description, + color: cal.backgroundColor, + primary: cal.primary, + })), + }; + } + + /** + * Sync a specific external calendar + */ + async syncCalendar(externalCalendarId: string): Promise { + const [externalCalendar] = await this.db + .select() + .from(externalCalendars) + .where(eq(externalCalendars.id, externalCalendarId)); + + if (!externalCalendar) { + throw new NotFoundException('External calendar not found'); + } + + if (!externalCalendar.syncEnabled) { + return { success: true, eventsImported: 0, eventsExported: 0, errors: ['Sync is disabled'] }; + } + + const result: SyncResult = { + success: true, + eventsImported: 0, + eventsExported: 0, + errors: [], + }; + + try { + // Import events + if ( + externalCalendar.syncDirection === 'import' || + externalCalendar.syncDirection === 'both' + ) { + const imported = await this.importEvents(externalCalendar); + result.eventsImported = imported; + } + + // Export events + if ( + externalCalendar.syncDirection === 'export' || + externalCalendar.syncDirection === 'both' + ) { + const exported = await this.exportEvents(externalCalendar); + result.eventsExported = exported; + } + + // Update last sync time + await this.db + .update(externalCalendars) + .set({ + lastSyncAt: new Date(), + lastSyncError: null, + updatedAt: new Date(), + }) + .where(eq(externalCalendars.id, externalCalendarId)); + } catch (error) { + result.success = false; + result.errors.push(error instanceof Error ? error.message : 'Unknown error'); + + // Update error status + await this.db + .update(externalCalendars) + .set({ + lastSyncError: error instanceof Error ? error.message : 'Unknown error', + updatedAt: new Date(), + }) + .where(eq(externalCalendars.id, externalCalendarId)); + } + + return result; + } + + /** + * Import events from external calendar + */ + private async importEvents(externalCalendar: ExternalCalendar): Promise { + let parsedEvents: ParsedEvent[] = []; + + // Calculate time range (sync last 30 days to next 365 days) + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 30); + const endDate = new Date(); + endDate.setFullYear(endDate.getFullYear() + 1); + + switch (externalCalendar.provider) { + case 'ical_url': + parsedEvents = await this.icalService.fetchAndParseICalUrl(externalCalendar.calendarUrl); + break; + + case 'caldav': + case 'apple': { + const serverUrl = + externalCalendar.provider === 'apple' + ? this.caldavService.getAppleCalDavUrl() + : new URL(externalCalendar.calendarUrl).origin; + + const result = await this.caldavService.fetchEvents( + serverUrl, + externalCalendar.calendarUrl, + externalCalendar.username || '', + externalCalendar.encryptedPassword || '', // TODO: Decrypt + startDate, + endDate + ); + parsedEvents = result.events; + + // Store ctag for change detection + if (result.ctag) { + await this.db + .update(externalCalendars) + .set({ + providerData: { + ...externalCalendar.providerData, + caldavCtag: result.ctag, + }, + }) + .where(eq(externalCalendars.id, externalCalendar.id)); + } + break; + } + + case 'google': { + // Refresh token if needed + let accessToken = externalCalendar.accessToken; + if ( + externalCalendar.tokenExpiresAt && + new Date(externalCalendar.tokenExpiresAt) < new Date() + ) { + if (!externalCalendar.refreshToken) { + throw new Error('Token expired and no refresh token available'); + } + const tokens = await this.googleCalendarService.refreshAccessToken( + externalCalendar.refreshToken + ); + accessToken = tokens.access_token; + + await this.db + .update(externalCalendars) + .set({ + accessToken: tokens.access_token, + tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), + }) + .where(eq(externalCalendars.id, externalCalendar.id)); + } + + const googleCalendarId = externalCalendar.providerData?.googleCalendarId || 'primary'; + parsedEvents = await this.googleCalendarService.fetchEvents( + accessToken!, + googleCalendarId, + startDate, + endDate + ); + break; + } + } + + // Get or create a local calendar for imported events + let [localCalendar] = await this.db + .select() + .from(calendars) + .where( + and( + eq(calendars.userId, externalCalendar.userId), + eq(calendars.name, `${externalCalendar.name} (Sync)`) + ) + ); + + if (!localCalendar) { + [localCalendar] = await this.db + .insert(calendars) + .values({ + userId: externalCalendar.userId, + name: `${externalCalendar.name} (Sync)`, + color: externalCalendar.color || '#6B7280', + isDefault: false, + }) + .returning(); + } + + // Upsert events + let importedCount = 0; + for (const parsedEvent of parsedEvents) { + try { + // Check if event already exists + const [existingEvent] = await this.db + .select() + .from(events) + .where( + and( + eq(events.externalId, parsedEvent.uid), + eq(events.externalCalendarId, externalCalendar.id) + ) + ); + + if (existingEvent) { + // Update existing event + await this.db + .update(events) + .set({ + title: parsedEvent.summary, + description: parsedEvent.description, + location: parsedEvent.location, + startTime: parsedEvent.dtstart, + endTime: parsedEvent.dtend, + isAllDay: parsedEvent.isAllDay, + recurrenceRule: parsedEvent.rrule, + status: parsedEvent.status, + lastSyncedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(events.id, existingEvent.id)); + } else { + // Create new event + await this.db.insert(events).values({ + calendarId: localCalendar.id, + userId: externalCalendar.userId, + title: parsedEvent.summary, + description: parsedEvent.description, + location: parsedEvent.location, + startTime: parsedEvent.dtstart, + endTime: parsedEvent.dtend, + isAllDay: parsedEvent.isAllDay, + recurrenceRule: parsedEvent.rrule, + status: parsedEvent.status, + externalId: parsedEvent.uid, + externalCalendarId: externalCalendar.id, + lastSyncedAt: new Date(), + metadata: parsedEvent.attendees + ? { + attendees: parsedEvent.attendees.map((a) => ({ + email: a.email, + name: a.name, + status: + (a.status as 'accepted' | 'declined' | 'tentative' | 'pending') || undefined, + })), + } + : undefined, + }); + importedCount++; + } + } catch (error) { + this.logger.warn(`Failed to import event ${parsedEvent.uid}: ${error}`); + } + } + + return importedCount; + } + + /** + * Export events to external calendar + */ + private async exportEvents(externalCalendar: ExternalCalendar): Promise { + // Only CalDAV and Google support export + if (externalCalendar.provider === 'ical_url') { + return 0; // iCal URLs are read-only + } + + // Get local events that haven't been synced yet + const [localCalendar] = await this.db + .select() + .from(calendars) + .where(and(eq(calendars.userId, externalCalendar.userId), eq(calendars.isDefault, true))); + + if (!localCalendar) { + return 0; + } + + // Get events from default calendar that need to be exported + const localEvents = await this.db + .select() + .from(events) + .where( + and( + eq(events.calendarId, localCalendar.id), + or(isNull(events.lastSyncedAt), lte(events.lastSyncedAt, events.updatedAt)) + ) + ); + + let exportedCount = 0; + + for (const event of localEvents) { + try { + switch (externalCalendar.provider) { + case 'caldav': + case 'apple': { + const serverUrl = + externalCalendar.provider === 'apple' + ? this.caldavService.getAppleCalDavUrl() + : new URL(externalCalendar.calendarUrl).origin; + + await this.caldavService.upsertEvent( + serverUrl, + externalCalendar.calendarUrl, + externalCalendar.username || '', + externalCalendar.encryptedPassword || '', + { + uid: event.externalId || event.id, + title: event.title, + description: event.description ?? undefined, + location: event.location ?? undefined, + startTime: event.startTime, + endTime: event.endTime, + isAllDay: event.isAllDay ?? false, + recurrenceRule: event.recurrenceRule ?? undefined, + } + ); + break; + } + + case 'google': { + let accessToken = externalCalendar.accessToken; + if ( + externalCalendar.tokenExpiresAt && + new Date(externalCalendar.tokenExpiresAt) < new Date() + ) { + if (!externalCalendar.refreshToken) { + throw new Error('Token expired'); + } + const tokens = await this.googleCalendarService.refreshAccessToken( + externalCalendar.refreshToken + ); + accessToken = tokens.access_token; + } + + const googleCalendarId = externalCalendar.providerData?.googleCalendarId || 'primary'; + + if (event.externalId) { + await this.googleCalendarService.updateEvent( + accessToken!, + googleCalendarId, + event.externalId, + { + title: event.title, + description: event.description ?? undefined, + location: event.location ?? undefined, + startTime: event.startTime, + endTime: event.endTime, + isAllDay: event.isAllDay ?? false, + } + ); + } else { + const result = await this.googleCalendarService.createEvent( + accessToken!, + googleCalendarId, + { + title: event.title, + description: event.description ?? undefined, + location: event.location ?? undefined, + startTime: event.startTime, + endTime: event.endTime, + isAllDay: event.isAllDay ?? false, + recurrenceRule: event.recurrenceRule ?? undefined, + } + ); + + // Update the local event with the external ID + await this.db + .update(events) + .set({ externalId: result.id }) + .where(eq(events.id, event.id)); + } + break; + } + } + + // Mark as synced + await this.db + .update(events) + .set({ lastSyncedAt: new Date() }) + .where(eq(events.id, event.id)); + + exportedCount++; + } catch (error) { + this.logger.warn(`Failed to export event ${event.id}: ${error}`); + } + } + + return exportedCount; + } + + /** + * Export a local calendar to iCal format + */ + async exportCalendarAsIcal(calendarId: string, userId: string): Promise { + const [calendar] = await this.db + .select() + .from(calendars) + .where(and(eq(calendars.id, calendarId), eq(calendars.userId, userId))); + + if (!calendar) { + throw new NotFoundException('Calendar not found'); + } + + const calendarEvents = await this.db + .select() + .from(events) + .where(eq(events.calendarId, calendarId)); + + return this.icalService.generateICalData(calendar.name, calendarEvents); + } + + /** + * Scheduled sync job - runs every 5 minutes + */ + @Cron(CronExpression.EVERY_5_MINUTES) + async scheduledSync() { + this.logger.log('Running scheduled calendar sync...'); + + // Get calendars that need syncing + const now = new Date(); + const calendarsToSync = await this.db + .select() + .from(externalCalendars) + .where(eq(externalCalendars.syncEnabled, true)); + + for (const calendar of calendarsToSync) { + // Check if enough time has passed since last sync + const lastSync = calendar.lastSyncAt ? new Date(calendar.lastSyncAt) : null; + const intervalMs = (calendar.syncInterval || 15) * 60 * 1000; + + if (!lastSync || now.getTime() - lastSync.getTime() >= intervalMs) { + try { + await this.syncCalendar(calendar.id); + this.logger.log(`Synced calendar: ${calendar.name} (${calendar.id})`); + } catch (error) { + this.logger.error(`Failed to sync calendar ${calendar.id}: ${error}`); + } + } + } + } +}