/** * CalDAV broker poller — runs every 5 minutes via setInterval. * * 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 a 5-minute setInterval. * (node-cron 4.2.1 silently skipped scheduled executions in the long-running server process; * setInterval fires reliably in the same process — replaced to fix the silent skip.) */ 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'; import { dispatchEventChange } from '../lib/eventChangeDispatcher.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 { 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; // Skip if ctag is present on both sides and unchanged if (currentCtag !== null && currentCtag === knownCtag) { continue; } // NOTIF-03: pass onChanges so external event changes push to non-actor members. // actor = cred.userId (the member whose Fastmail poll detected the change — D-03). await syncCalendar(client, davCal, cred.userId, (changes) => { for (const change of changes) { dispatchEventChange(change, cred.userId).catch((err: unknown) => { console.error( '[broker/poller] dispatchEventChange error:', err instanceof Error ? err.message : String(err), ); }); } }); } } 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). * Uses setInterval instead of node-cron: node-cron 4.2.1 silently skipped executions * in the long-running server process; setInterval fires reliably. */ export function startBrokerPoller(): void { setInterval( () => { runPoll().catch((err: unknown) => { console.error('[broker/poller] Unhandled runPoll error:', err); }); }, 5 * 60 * 1000, ); }