/** * 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, notInArray } from 'drizzle-orm'; import { db } from '../db/client.js'; import { calendars, calendarEvents } from '../db/schema.js'; import type { EventChange } from '../lib/eventChangeDispatcher.js'; import { classifyValarms } from './vevent.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, onChanges?: (changes: EventChange[]) => void, ): 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. // Track every uid we see on the server so step 5 can prune cache rows that // no longer exist on Fastmail (deletes — local or external). // Also collect EventChange records for the onChanges callback (NOTIF-03). const seenUids: string[] = []; const changes: EventChange[] = []; for (const obj of objects) { if (!obj.data) continue; let parsed: ReturnType; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it parsed = ICAL.parse(obj.data as string); } catch { // Malformed VCALENDAR — skip but do not crash the sync continue; } // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value 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; seenUids.push(uid); // 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; // Extract SUMMARY → title (NOTIF-01 dependency; closes title column stub). const titleValue: string | null = (vevent.getFirstPropertyValue('summary') as string | null) ?? null; // Extract LOCATION for meaningful-change detection (D-04). const locationValue: string | null = (vevent.getFirstPropertyValue('location') as string | null) ?? null; // Phase 11: derive reminderLeadMinutes from the VALARM classification (D-07, NOTIF-05). // classifyValarms wraps ICAL.parse in try/catch (T-11-07 mitigated). // preset/offlist → single relative lead in minutes (scheduler ground truth). // custom (absolute DATE-TIME or multiple VALARMs) → null (can't resolve a single lead). // none → null (no VALARM present). const alarmClass = classifyValarms(obj.data as string); const reminderLeadMinutesValue: number | null = alarmClass.kind === 'preset' || alarmClass.kind === 'offlist' ? alarmClass.leadMinutes : null; // NOTIF-03: look up the existing row so we can classify add vs update. // One indexed lookup on (calendarId, uid) — cheap, covered by uniq_calendar_uid. // D-13: this read is from MariaDB cache, not Fastmail. let oldRow: typeof calendarEvents.$inferSelect | null = null; if (onChanges) { const existing = await db .select() .from(calendarEvents) .where(and(eq(calendarEvents.calendarId, cal.id), eq(calendarEvents.uid, uid))) .limit(1); oldRow = existing[0] ?? null; } await db .insert(calendarEvents) .values({ calendarId: cal.id, uid, etag: obj.etag ?? null, objectUrl: obj.url ?? null, rawVevent: obj.data as string, title: titleValue, dtstartUtc: dtstartUtcValue, dtstartDate: dtstartDateValue, allDay, hasRrule: isRecurring, // Phase 11: ground-truth VALARM lead for the scheduler (D-07/NOTIF-05, T-11-07) reminderLeadMinutes: reminderLeadMinutesValue, }) .onDuplicateKeyUpdate({ set: { etag: obj.etag ?? null, objectUrl: obj.url ?? null, rawVevent: obj.data as string, title: titleValue, dtstartUtc: dtstartUtcValue, dtstartDate: dtstartDateValue, allDay, hasRrule: isRecurring, // Phase 11: keep reminderLeadMinutes current on re-sync (native client may change VALARM) reminderLeadMinutes: reminderLeadMinutesValue, updatedAt: new Date(), }, }); // Collect change record for onChanges callback (NOTIF-03). if (onChanges) { if (oldRow === null) { // New event — always meaningful (added) changes.push({ uid, title: titleValue, operation: 'create', dtstartUtc: dtstartUtcValue, allDay, }); } else { // Existing event — compute which meaningful fields changed (D-04) const changedFields: string[] = []; // Compare dtstartUtc (timed events) const oldUtcMs = oldRow.dtstartUtc ? new Date(oldRow.dtstartUtc).getTime() : null; const newUtcMs = dtstartUtcValue ? dtstartUtcValue.getTime() : null; if (oldUtcMs !== newUtcMs) changedFields.push('dtstartUtc'); // Compare dtstartDate (all-day events) — compare ISO date string const oldDateStr = oldRow.dtstartDate ? new Date(oldRow.dtstartDate).toISOString().slice(0, 10) : null; const newDateStr = dtstartDateValue ? dtstartDateValue.toISOString().slice(0, 10) : null; if (oldDateStr !== newDateStr) changedFields.push('dtstartDate'); // Compare allDay flag if ((oldRow.allDay ? true : false) !== allDay) changedFields.push('allDay'); // Compare title (SUMMARY) const oldTitle = oldRow.title ?? null; if (oldTitle !== titleValue) changedFields.push('title'); // Compare location — extract from old rawVevent for comparison let oldLocation: string | null = null; if (oldRow.rawVevent) { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() returns 'any'; ICAL.Component is the correct consumer of this value const oldComp = new ICAL.Component(ICAL.parse(oldRow.rawVevent)); const oldVevent = oldComp.getFirstSubcomponent('vevent'); if (oldVevent) { oldLocation = (oldVevent.getFirstPropertyValue('location') as string | null) ?? null; } } catch { // Malformed old VEVENT — skip location comparison } } if (oldLocation !== locationValue) changedFields.push('location'); if (changedFields.length > 0) { changes.push({ uid, title: titleValue, operation: 'update', changedFields, dtstartUtc: dtstartUtcValue, allDay, }); } } } } // 5. Prune deletes: remove cached events for THIS calendar whose uid is no // longer present on the server. Without this, a deleted event (local delete // via the outbox, or an external delete in another client) lingers in // calendar_events forever — GET /api/events keeps returning it and the UI // shows a ghost event that "won't delete". Scoped to cal.id so it never // touches another calendar or the other household member's rows (BUG B). // When the server returns zero events, prune the whole calendar's cache. // NOTIF-03: collect deleted uids for the onChanges callback. // // WR-04: pre-capture the rows to delete BEFORE the DB delete so we know what // was removed, then push changes AFTER the delete completes. This ensures the // onChanges payload only describes events that are truly gone from the cache — // not events that may have been re-fetched in a concurrent poll. let pendingDeleteRows: Array<{ uid: string; title: string | null }> = []; if (onChanges && seenUids.length > 0) { // Find cached uids that are about to be pruned so we can emit delete changes pendingDeleteRows = await db .select({ uid: calendarEvents.uid, title: calendarEvents.title }) .from(calendarEvents) .where(and(eq(calendarEvents.calendarId, cal.id), notInArray(calendarEvents.uid, seenUids))); } else if (onChanges) { // NEW-WR-01: server returned zero events → entire calendar cache will be cleared. // Capture ALL currently-cached rows before the delete so delete-change events // are emitted for each one. Without this, bulk/clear deletions were silently dropped. pendingDeleteRows = await db .select({ uid: calendarEvents.uid, title: calendarEvents.title }) .from(calendarEvents) .where(eq(calendarEvents.calendarId, cal.id)); } if (seenUids.length > 0) { await db .delete(calendarEvents) .where(and(eq(calendarEvents.calendarId, cal.id), notInArray(calendarEvents.uid, seenUids))); } else { await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id)); } // Collect delete changes AFTER the DB delete (WR-04: avoids race where the same // uid is re-inserted by a concurrent poll before onChanges fires). for (const row of pendingDeleteRows) { changes.push({ uid: row.uid, title: row.title ?? null, operation: 'delete', }); } // 6. Fire onChanges callback if provided and there are changes (NOTIF-03). // Fire-and-forget: sync correctness must not depend on push success. if (onChanges && changes.length > 0) { onChanges(changes); } }