feat(05-03): calendar providers (CalDAV, ICS, Exchange)
- Implement ICSProvider with fetch + node-ical parsing + RRULE expansion - Implement CalDAVProvider with tsdav DAVClient + time-range filtering - Implement ExchangeProvider dispatching on exchangeMode (graph vs ews) - Graph mode uses @microsoft/microsoft-graph-client /me/calendarView - EWS mode uses ews-javascript-api FindAppointments - Exchange gracefully degrades: returns empty array on failure (D-08) - No provider logs decrypted passwords (T-05-13)
This commit is contained in:
@@ -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<CalendarEvent[]> {
|
||||
// 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<boolean> {
|
||||
// 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<string> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user