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:
2026-06-24 15:13:34 +02:00
parent 9ec6313f4d
commit 389ac9b692
3 changed files with 451 additions and 18 deletions
@@ -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);
}