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,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<CalendarEvent[]> {
|
||||
// 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<boolean> {
|
||||
// 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<DAVClient> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user