feat(01-03): implement broker client, syncCalendar, and events route (CAL-01)
- client.ts: createFastmailClient(email, appPassword) → tsdav DAVClient via Basic auth - sync.ts: syncCalendar upserts calendar row, selects ID, fetches+parses VEVENTs with ical.js - all-day DATE → dtstartDate (Date obj at T00:00:00Z), dtstartUtc=null, allDay=true - timed → dtstartUtc (JS Date), dtstartDate=null, allDay=false (D-13/Pitfall #3) - onDuplicateKeyUpdate on calendarId+uid composite key (idempotent) - routes/events.ts: GET /api/events reads cache only — no tsdav import (T-03-02) - tsc --noEmit clean; all 6 sync tests pass
This commit is contained in:
@@ -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<ReturnType<typeof createDAVClient>>
|
||||
|
||||
/**
|
||||
* 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<FastmailClient> {
|
||||
return createDAVClient({
|
||||
serverUrl: 'https://caldav.fastmail.com',
|
||||
credentials: {
|
||||
username: email,
|
||||
password: appPassword,
|
||||
},
|
||||
authMethod: 'Basic',
|
||||
defaultAccountType: 'caldav',
|
||||
})
|
||||
}
|
||||
@@ -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<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).
|
||||
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<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
|
||||
|
||||
// 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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in New Issue
Block a user