diff --git a/apps/api/src/broker/client.ts b/apps/api/src/broker/client.ts new file mode 100644 index 0000000..a8509b0 --- /dev/null +++ b/apps/api/src/broker/client.ts @@ -0,0 +1,32 @@ +/** + * Fastmail CalDAV client factory. + * + * Creates a tsdav DAVClient per Fastmail credential set (one per member, per D-02). + * The broker module owns the client lifetime — never create one per API request (Pitfall #3). + * + * Source: https://github.com/natelindev/tsdav + * CalDAV principal URL pattern: https://caldav.fastmail.com/dav/principals/user/{email}/ + * (Pitfall #1: tsdav service discovery via /.well-known/caldav resolves to this principal) + */ + +import { createDAVClient } from 'tsdav' + +/** The resolved type of a tsdav client returned by createDAVClient. */ +export type FastmailClient = Awaited> + +/** + * Creates a tsdav DAVClient authenticated with Basic auth (app password). + * The returned client can call fetchCalendars() and fetchCalendarObjects(). + * This call performs a service-discovery round-trip (Pitfall #3 — cache this client). + */ +export async function createFastmailClient(email: string, appPassword: string): Promise { + return createDAVClient({ + serverUrl: 'https://caldav.fastmail.com', + credentials: { + username: email, + password: appPassword, + }, + authMethod: 'Basic', + defaultAccountType: 'caldav', + }) +} diff --git a/apps/api/src/broker/sync.ts b/apps/api/src/broker/sync.ts new file mode 100644 index 0000000..0613cb2 --- /dev/null +++ b/apps/api/src/broker/sync.ts @@ -0,0 +1,121 @@ +/** + * CalDAV sync: REPORT → ical.js parse → MariaDB upsert. + * + * D-13 schema contract: + * - All-day events: dtstart_date (DATE string), dtstart_utc=null, all_day=true + * - Timed events: dtstart_utc (TIMESTAMP as JS Date), dtstart_date=null, all_day=false + * - NEVER coerce DATE to DATETIME (Pitfall #3) + * - Raw VEVENT blob stored verbatim — only server-returned objects cached (T-03-05) + * + * Idempotency: calendar_id + uid composite unique key → onDuplicateKeyUpdate. + * ctag/syncToken updated on every sync so the poller can skip unchanged calendars. + * + * Sources: + * - https://github.com/natelindev/tsdav (fetchCalendarObjects) + * - https://github.com/kewisch/ical.js (ICAL.parse, ICAL.Component, ICAL.Time.isDate) + */ + +import type { DAVCalendar } from 'tsdav' +import type { FastmailClient } from './client.js' +import ICAL from 'ical.js' +import { eq } from 'drizzle-orm' +import { db } from '../db/client.js' +import { calendars, calendarEvents } from '../db/schema.js' + +/** + * Fetches all calendar objects for a given DAVCalendar, parses VEVENTs with ical.js, + * and upserts the results into the MariaDB cache. + * + * @param client - tsdav DAVClient (already authenticated) + * @param davCal - DAVCalendar object from fetchCalendars() + * @param userId - FamilySync user ID (from member_credentials row) + */ +export async function syncCalendar( + client: FastmailClient, + davCal: DAVCalendar, + userId: number, +): Promise { + // 1. Upsert the calendar collection row. + // ctag / syncToken: use ?? null defensively (Pitfall #6 — Fastmail may omit either) + await db + .insert(calendars) + .values({ + userId, + url: davCal.url, + displayName: typeof davCal.displayName === 'string' ? davCal.displayName : null, + ctag: davCal.ctag ?? null, + syncToken: davCal.syncToken ?? null, + lastSyncedAt: new Date(), + }) + .onDuplicateKeyUpdate({ + set: { + ctag: davCal.ctag ?? null, + syncToken: davCal.syncToken ?? null, + lastSyncedAt: new Date(), + }, + }) + + // 2. Select the calendar row to get its DB id (insertId is unreliable on ON DUPLICATE KEY UPDATE). + const [cal] = await db.select().from(calendars).where(eq(calendars.url, davCal.url)).limit(1) + if (!cal) { + // Should never happen — we just upserted it + throw new Error(`syncCalendar: could not find calendar row for url=${davCal.url}`) + } + + // 3. Fetch all calendar objects (REPORT calendar-query). + const objects = await client.fetchCalendarObjects({ calendar: davCal }) + + // 4. Parse each VCALENDAR/VEVENT and upsert into calendar_events. + for (const obj of objects) { + if (!obj.data) continue + + let parsed: ReturnType + try { + parsed = ICAL.parse(obj.data as string) + } catch { + // Malformed VCALENDAR — skip but do not crash the sync + continue + } + + const comp = new ICAL.Component(parsed) + const vevent = comp.getFirstSubcomponent('vevent') + if (!vevent) continue + + // ical.js getFirstPropertyValue returns a union type; cast to ICAL.Time for date handling + const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null + const uid = vevent.getFirstPropertyValue('uid') as string | null + if (!uid) continue + + // D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column + const allDay: boolean = dtstart?.isDate ?? false + + // dtstartDate: Drizzle's `date` column accepts a Date object or null. + // We convert the YYYY-MM-DD string from ical.js to a Date (at midnight UTC) so + // Drizzle serialises it correctly as a DATE without a time component. + const dtstartDateValue: Date | null = + allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null + const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : null + + await db + .insert(calendarEvents) + .values({ + calendarId: cal.id, + uid, + etag: obj.etag ?? null, + rawVevent: obj.data as string, + dtstartUtc: dtstartUtcValue, + dtstartDate: dtstartDateValue, + allDay, + }) + .onDuplicateKeyUpdate({ + set: { + etag: obj.etag ?? null, + rawVevent: obj.data as string, + dtstartUtc: dtstartUtcValue, + dtstartDate: dtstartDateValue, + allDay, + updatedAt: new Date(), + }, + }) + } +} diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts new file mode 100644 index 0000000..e250ad0 --- /dev/null +++ b/apps/api/src/routes/events.ts @@ -0,0 +1,29 @@ +/** + * GET /api/events — returns cached calendar events from MariaDB. + * + * Architecture invariant (T-03-02, anti-pattern guard): + * This route reads ONLY from the MariaDB cache. It NEVER calls Fastmail directly. + * All Fastmail I/O is owned exclusively by the broker module (src/broker/). + * No tsdav import here; no createFastmailClient import here. + * + * Mounted under /api/* in index.ts (Plan 04), so it is behind oidcAuthMiddleware. + */ + +import { Hono } from 'hono' +import { db } from '../db/client.js' +import { calendarEvents } from '../db/schema.js' + +export const eventsRouter = new Hono() + +/** + * GET /api/events + * Returns all cached calendar events from the database. + * Returns shape: { events: [...] } where each event has: + * id, uid, allDay, dtstartUtc, dtstartDate, calendarId, etag, updatedAt + * + * No live Fastmail call — broker poller keeps cache fresh every 5 minutes. + */ +eventsRouter.get('/', async (c) => { + const events = await db.select().from(calendarEvents) + return c.json({ events }) +})