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 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 { 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?.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 { 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); }