/** * 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 { and, 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). // BUG B: scope by (userId, url) — the same collection URL exists for both members // (shared Fastmail account, D-16). A url-only lookup returned the OTHER member's // row (lowest id), so events were cached under the wrong calendarId. const [cal] = await db .select() .from(calendars) .where(and(eq(calendars.userId, userId), 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 userId=${userId} 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 // Determine if this event is a recurring master (has RRULE or RDATE). // Use ICAL.Event.isRecurring() for parity with expand.ts — it checks both properties. const isRecurring: boolean = new ICAL.Event(vevent).isRecurring() // 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, objectUrl: obj.url ?? null, rawVevent: obj.data as string, dtstartUtc: dtstartUtcValue, dtstartDate: dtstartDateValue, allDay, hasRrule: isRecurring, }) .onDuplicateKeyUpdate({ set: { etag: obj.etag ?? null, objectUrl: obj.url ?? null, rawVevent: obj.data as string, dtstartUtc: dtstartUtcValue, dtstartDate: dtstartDateValue, allDay, hasRrule: isRecurring, updatedAt: new Date(), }, }) } }