Files
familysync/apps/api/src/broker/poller.ts
T
Lucas Berger a9d3de658e fix(03): correct event-write timezone + per-user calendar identity (Gate 2 Part D)
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.
2026-06-06 22:32:10 -04:00

90 lines
3.5 KiB
TypeScript

/**
* CalDAV broker poller — runs every 5 minutes via node-cron.
*
* Responsibilities (D-13, D-02):
* - Load all member_credentials (N-credential per-member model)
* - Decrypt each app password via decryptPassword (T-03-04 — never log the decrypted value)
* - Create a tsdav client per credential, fetch calendars via PROPFIND
* - For each calendar: compare current ctag to stored ctag
* → ctag unchanged (and non-null): SKIP (no DB write, no Fastmail round-trip)
* → ctag changed or null: call syncCalendar (REPORT → ical.js → DB upsert)
*
* runPoll is exported for unit testing (inject mocks via vi.mock at the module level).
* startBrokerPoller wraps it in node-cron's 5-minute schedule.
*
* Source: https://github.com/node-cron/node-cron (v4 stable basic API)
*/
import { schedule } from 'node-cron'
import { and, eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { memberCredentials, calendars } from '../db/schema.js'
import { decryptPassword } from './crypto.js'
import { createFastmailClient } from './client.js'
import { syncCalendar } from './sync.js'
/**
* Runs one full poll cycle:
* 1. Load all member credentials
* 2. For each credential: decrypt, create client, fetch calendars
* 3. For each calendar: check ctag; skip if unchanged, sync if changed
*
* Errors for individual credentials are caught and logged (not re-thrown),
* so one bad credential does not stop processing for others.
*/
export async function runPoll(): Promise<void> {
const creds = await db.select().from(memberCredentials)
for (const cred of creds) {
try {
// Decrypt before client creation (T-03-04: never expose decrypted value in logs)
const appPassword = decryptPassword(cred.encryptedPassword)
const client = await createFastmailClient(cred.fastmailEmail, appPassword)
const davCalendars = await client.fetchCalendars()
for (const davCal of davCalendars) {
// Look up the stored calendar row to get the known ctag (D-13).
// BUG B: scope by (userId, url). The two members share one Fastmail account
// (D-16), so the same collection URL exists for both. A url-only predicate
// matched the OTHER member's row → wrong ctag compared, and syncCalendar
// wrote events under the wrong calendar. Match on this member's row only.
const [stored] = await db
.select()
.from(calendars)
.where(and(eq(calendars.userId, cred.userId), eq(calendars.url, davCal.url)))
.limit(1)
// ctag/syncToken: defensive null handling (Pitfall #6)
const knownCtag = stored?.ctag ?? null
const currentCtag = (davCal.ctag ?? davCal.syncToken ?? null) as string | null
// Skip if ctag is present on both sides and unchanged
if (currentCtag !== null && currentCtag === knownCtag) {
continue
}
await syncCalendar(client, davCal, cred.userId)
}
} catch (err) {
// Log the error but do NOT log the app password or key (T-03-04)
console.error(
`[broker/poller] Error processing credential id=${cred.id} (${cred.fastmailEmail}):`,
err instanceof Error ? err.message : String(err),
)
}
}
}
/**
* Starts the 5-minute background polling schedule.
* Call once at API startup (Plan 04 wires this into index.ts).
*/
export function startBrokerPoller(): void {
schedule('*/5 * * * *', () => {
runPoll().catch((err: unknown) => {
console.error('[broker/poller] Unhandled runPoll error:', err)
})
})
}