BUG A — timed events written 4h off: EventForm sent a naive local wall-clock string with no offset; the UTC API container parsed it via new Date() as UTC, so 09:00 America/Toronto serialized to DTSTART:...090000Z. Fix: new apps/pwa/src/lib/eventDateTime.ts serializes timed events to an unambiguous UTC instant in the browser (where the operator's zone is known); all-day stays a DATE string. No backend change. BUG B — created events attached to the wrong user's calendar + duplicate calendar rows per poll: calendars had no unique key on url, and poller/sync matched calendars by url alone — so under the shared single Fastmail account (D-16) one member's collection resolved to the other member's row. Fix: composite unique(user_id, url); scope poller lookup + sync select to (userId, url); hand migration 0001 (dedup + add key), applied to the live DB. Regression tests fail against the buggy url-only predicate. API 98/98, PWA 140/140, tsc clean both packages.
137 lines
5.0 KiB
TypeScript
137 lines
5.0 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
// 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<typeof ICAL.parse>
|
|
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(),
|
|
},
|
|
})
|
|
}
|
|
}
|