diff --git a/apps/api/src/calendar/providers/caldav.provider.ts b/apps/api/src/calendar/providers/caldav.provider.ts index 96e1fae..61ee28f 100644 --- a/apps/api/src/calendar/providers/caldav.provider.ts +++ b/apps/api/src/calendar/providers/caldav.provider.ts @@ -1,27 +1,137 @@ import { Injectable, Logger } from '@nestjs/common'; +import { DAVClient } from 'tsdav'; +import * as ical from 'node-ical'; import { CalendarEvent, CalendarProvider } from '../calendar.service'; /** * CalDAV calendar provider — uses tsdav for protocol handling. - * Full implementation in Task 2. + * + * Connects via Basic auth (username + decrypted password), fetches calendars, + * then fetchCalendarObjects with time-range filter (A2). + * Parses returned iCal data with node-ical and maps to CalendarEvent. */ @Injectable() export class CalDAVProvider implements CalendarProvider { private readonly logger = new Logger(CalDAVProvider.name); + /** + * Fetches events from a CalDAV server within the specified date range. + * Uses tsdav's time-range filter to only fetch relevant events (A2). + */ async fetchEvents( source: { url: string; username?: string; password?: string; id: string; color?: string | null }, from: Date, to: Date, ): Promise { - // Stub — implemented in Task 2 - return []; + const events: CalendarEvent[] = []; + + try { + const client = await this.createClient(source); + const calendars = await client.fetchCalendars(); + + for (const calendar of calendars) { + const objects = await client.fetchCalendarObjects({ + calendar, + timeRange: { + start: from.toISOString(), + end: to.toISOString(), + }, + expand: true, + }); + + for (const obj of objects) { + if (!obj.data) continue; + + try { + const parsed = ical.sync.parseICS(obj.data); + + for (const key of Object.keys(parsed)) { + const component = parsed[key]; + if (!component || component.type !== 'VEVENT') continue; + + const vevent = component as ical.VEvent; + const start = new Date(vevent.start); + const end = vevent.end + ? new Date(vevent.end) + : new Date(start.getTime() + 3600000); + + events.push({ + id: `${source.id}-${vevent.uid || key}`, + sourceId: source.id, + title: extractString(vevent.summary), + start, + end, + allDay: vevent.datetype === 'date', + location: vevent.location + ? extractString(vevent.location) + : undefined, + description: vevent.description + ? extractString(vevent.description) + : undefined, + color: source.color ?? undefined, + }); + } + } catch (parseErr) { + this.logger.warn( + `Failed to parse CalDAV object: ${(parseErr as Error).message}`, + ); + } + } + } + } catch (error) { + this.logger.error( + `Failed to fetch CalDAV events from ${source.url}: ${(error as Error).message}`, + ); + throw error; + } + + return events; } + /** + * Tests connection by attempting login and listing calendars. + */ async testConnection( source: { url: string; username?: string; password?: string; id: string }, ): Promise { - // Stub — implemented in Task 2 - return false; + try { + const client = await this.createClient(source); + const calendars = await client.fetchCalendars(); + return calendars.length > 0; + } catch { + return false; + } + } + + /** + * Creates a tsdav DAVClient with Basic auth and logs in. + */ + private async createClient( + source: { url: string; username?: string; password?: string }, + ): Promise { + const client = new DAVClient({ + serverUrl: source.url, + credentials: { + username: source.username || '', + password: source.password || '', + }, + authMethod: 'Basic', + defaultAccountType: 'caldav', + }); + + await client.login(); + return client; } } + +/** + * Extracts a plain string from a node-ical ParameterValue. + */ +function extractString( + value: ical.ParameterValue | string | undefined, +): string { + if (!value) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'object' && 'val' in value) return String(value.val); + return String(value); +} diff --git a/apps/api/src/calendar/providers/exchange.provider.ts b/apps/api/src/calendar/providers/exchange.provider.ts index a1a359e..f87ff79 100644 --- a/apps/api/src/calendar/providers/exchange.provider.ts +++ b/apps/api/src/calendar/providers/exchange.provider.ts @@ -3,26 +3,225 @@ import { CalendarEvent, CalendarProvider } from '../calendar.service'; /** * Exchange calendar provider — dispatches on exchangeMode ('graph' vs 'ews'). - * Uses @microsoft/microsoft-graph-client for Graph and ews-javascript-api for EWS. - * Full implementation in Task 2. + * + * - 'graph': Uses @microsoft/microsoft-graph-client for Exchange Online / M365 + * - 'ews': Uses ews-javascript-api for on-premise Exchange Server + * + * Both modes gracefully degrade: on auth failure, returns empty array and + * surfaces a generic error (no credential details — Security V7 / T-05-13). */ @Injectable() export class ExchangeProvider implements CalendarProvider { private readonly logger = new Logger(ExchangeProvider.name); + /** + * Fetches events from Exchange, dispatching by exchangeMode. + * D-08: source TYPE is configurable and attempted — widget must not crash. + */ async fetchEvents( - source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string; color?: string | null }, + source: { + url: string; + username?: string; + password?: string; + exchangeMode?: string | null; + id: string; + color?: string | null; + }, from: Date, to: Date, ): Promise { - // Stub — implemented in Task 2 - return []; + const mode = source.exchangeMode || 'graph'; + + try { + if (mode === 'graph') { + return await this.fetchViaGraph(source, from, to); + } else { + return await this.fetchViaEws(source, from, to); + } + } catch (error) { + // Graceful degradation — T-05-13: no credential details in error + this.logger.error( + `Exchange (${mode}) fetch failed for source ${source.id}: ${(error as Error).message}`, + ); + return []; + } } + /** + * Tests connection to Exchange. Returns false on any auth/network failure. + */ async testConnection( - source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string }, + source: { + url: string; + username?: string; + password?: string; + exchangeMode?: string | null; + id: string; + }, ): Promise { - // Stub — implemented in Task 2 - return false; + const mode = source.exchangeMode || 'graph'; + + try { + if (mode === 'graph') { + return await this.testGraphConnection(source); + } else { + return await this.testEwsConnection(source); + } + } catch { + return false; + } + } + + /** + * Fetches events via Microsoft Graph API (Exchange Online / M365). + * Uses @microsoft/microsoft-graph-client with /me/calendarView. + */ + private async fetchViaGraph( + source: { + url: string; + username?: string; + password?: string; + id: string; + color?: string | null; + }, + from: Date, + to: Date, + ): Promise { + // Dynamic import to avoid loading Graph SDK when not needed + const { Client: GraphClient } = await import( + '@microsoft/microsoft-graph-client' + ); + + const client = GraphClient.init({ + authProvider: (done: (error: any, token: string) => void) => { + // Use the password as the access token (OAuth bearer token) + // Users configure their OAuth token in the password field for Graph API + done(null, source.password || ''); + }, + }); + + const result = await client + .api('/me/calendarView') + .query({ + startDateTime: from.toISOString(), + endDateTime: to.toISOString(), + }) + .select('id,subject,start,end,isAllDay,location,bodyPreview') + .orderby('start/dateTime') + .top(100) + .get(); + + const events: CalendarEvent[] = []; + + if (result?.value) { + for (const item of result.value) { + events.push({ + id: `${source.id}-${item.id}`, + sourceId: source.id, + title: item.subject || 'Untitled', + start: new Date(item.start?.dateTime + 'Z'), + end: new Date(item.end?.dateTime + 'Z'), + allDay: item.isAllDay || false, + location: item.location?.displayName || undefined, + description: item.bodyPreview || undefined, + color: source.color ?? undefined, + }); + } + } + + return events; + } + + /** + * Fetches events via Exchange Web Services (on-premise Exchange). + * Uses ews-javascript-api with FindAppointments over a CalendarView. + * + * Note: ews-javascript-api has no TypeScript definitions; + * we use dynamic import + any casting for type safety. + */ + private async fetchViaEws( + source: { + url: string; + username?: string; + password?: string; + id: string; + color?: string | null; + }, + from: Date, + to: Date, + ): Promise { + // Dynamic import — ews-javascript-api is JS-only, no .d.ts + const ews: any = await import('ews-javascript-api'); + + const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013); + service.Url = new ews.Uri(source.url); + service.Credentials = new ews.WebCredentials( + source.username || '', + source.password || '', + ); + + // CalendarView constructor accepts JS Dates in ews-javascript-api + const calendarView = new ews.CalendarView(from, to, 100); + const findResults = await service.FindAppointments( + ews.WellKnownFolderName.Calendar, + calendarView, + ); + + const events: CalendarEvent[] = []; + + for (const appointment of findResults.Items) { + events.push({ + id: `${source.id}-${appointment.Id?.UniqueId || String(Date.now())}`, + sourceId: source.id, + title: appointment.Subject || 'Untitled', + start: appointment.Start ? new Date(String(appointment.Start)) : new Date(), + end: appointment.End ? new Date(String(appointment.End)) : new Date(), + allDay: appointment.IsAllDayEvent || false, + location: appointment.Location || undefined, + description: undefined, // Body requires separate load call + color: source.color ?? undefined, + }); + } + + return events; + } + + /** + * Tests Graph API connection by requesting calendar list. + */ + private async testGraphConnection( + source: { url: string; password?: string }, + ): Promise { + const { Client: GraphClient } = await import( + '@microsoft/microsoft-graph-client' + ); + + const client = GraphClient.init({ + authProvider: (done: (error: any, token: string) => void) => { + done(null, source.password || ''); + }, + }); + + const result = await client.api('/me/calendars').top(1).get(); + return !!result?.value; + } + + /** + * Tests EWS connection by binding to the calendar folder. + */ + private async testEwsConnection( + source: { url: string; username?: string; password?: string }, + ): Promise { + const ews: any = await import('ews-javascript-api'); + + const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013); + service.Url = new ews.Uri(source.url); + service.Credentials = new ews.WebCredentials( + source.username || '', + source.password || '', + ); + + await ews.Folder.Bind(service, ews.WellKnownFolderName.Calendar); + return true; } } diff --git a/apps/api/src/calendar/providers/ics.provider.ts b/apps/api/src/calendar/providers/ics.provider.ts index 37406de..2d6b974 100644 --- a/apps/api/src/calendar/providers/ics.provider.ts +++ b/apps/api/src/calendar/providers/ics.provider.ts @@ -1,27 +1,151 @@ import { Injectable, Logger } from '@nestjs/common'; +import * as ical from 'node-ical'; import { CalendarEvent, CalendarProvider } from '../calendar.service'; /** * ICS calendar provider — fetches and parses .ics files via HTTPS. - * Uses node-ical for parsing. Full implementation in Task 2. + * + * Uses node-ical for parsing iCal data including recurring event expansion (A6). + * Applies date-range filtering and maps to normalized CalendarEvent format. */ @Injectable() export class ICSProvider implements CalendarProvider { private readonly logger = new Logger(ICSProvider.name); + /** + * Fetches events from an ICS URL and filters to the given date range. + * Expands recurring events via node-ical's expandRecurringEvent (A6). + */ async fetchEvents( source: { url: string; id: string; color?: string | null }, from: Date, to: Date, ): Promise { - // Stub — implemented in Task 2 - return []; + const events: CalendarEvent[] = []; + + try { + // Fetch with timeout (Pitfall 4) then parse + const icsText = await this.fetchIcsText(source.url); + const data = ical.sync.parseICS(icsText); + + for (const key of Object.keys(data)) { + const component = data[key]; + if (!component || component.type !== 'VEVENT') continue; + + const vevent = component as ical.VEvent; + + // Handle recurring events + if (vevent.rrule) { + try { + const instances = ical.expandRecurringEvent(vevent, { + from, + to, + includeOverrides: true, + excludeExdates: true, + }); + + for (const instance of instances) { + events.push({ + id: `${source.id}-${vevent.uid}-${instance.start.getTime()}`, + sourceId: source.id, + title: extractString(instance.summary ?? vevent.summary), + start: new Date(instance.start), + end: new Date(instance.end), + allDay: instance.isFullDay, + location: vevent.location ? extractString(vevent.location) : undefined, + description: vevent.description ? extractString(vevent.description) : undefined, + color: source.color ?? undefined, + }); + } + } catch (err) { + // Fallback: if RRULE expansion fails, include the base event if in range + this.logger.warn(`RRULE expansion failed for ${vevent.uid}: ${err}`); + const mapped = this.mapSingleEvent(vevent, source, from, to); + if (mapped) events.push(mapped); + } + } else { + // Non-recurring event — check if in range + const mapped = this.mapSingleEvent(vevent, source, from, to); + if (mapped) events.push(mapped); + } + } + } catch (error) { + this.logger.error(`Failed to fetch ICS from ${source.url}: ${(error as Error).message}`); + throw error; + } + + return events; } + /** + * Tests connection by fetching the ICS URL and verifying it parses. + */ async testConnection( source: { url: string; id: string }, ): Promise { - // Stub — implemented in Task 2 - return false; + try { + const icsText = await this.fetchIcsText(source.url); + const data = ical.sync.parseICS(icsText); + return Object.keys(data).length > 0; + } catch { + return false; + } + } + + /** + * Fetches raw ICS text from a URL with an 8-second timeout. + */ + private async fetchIcsText(url: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.text(); + } finally { + clearTimeout(timeout); + } + } + + /** + * Maps a single (non-recurring) VEvent to CalendarEvent if it falls within the date range. + */ + private mapSingleEvent( + vevent: ical.VEvent, + source: { id: string; color?: string | null }, + from: Date, + to: Date, + ): CalendarEvent | null { + const start = new Date(vevent.start); + const end = vevent.end ? new Date(vevent.end) : new Date(start.getTime() + 3600000); + + // Check if event overlaps with the requested range + if (end < from || start > to) return null; + + return { + id: `${source.id}-${vevent.uid}`, + sourceId: source.id, + title: extractString(vevent.summary), + start, + end, + allDay: vevent.datetype === 'date', + location: vevent.location ? extractString(vevent.location) : undefined, + description: vevent.description ? extractString(vevent.description) : undefined, + color: source.color ?? undefined, + }; } } + +/** + * Extracts a plain string from a node-ical ParameterValue + * (which can be a string or an object with `val` property). + */ +function extractString(value: ical.ParameterValue | string | undefined): string { + if (!value) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'object' && 'val' in value) return String(value.val); + return String(value); +}