diff --git a/apps/api/src/broker/poller.ts b/apps/api/src/broker/poller.ts new file mode 100644 index 0000000..fe32f63 --- /dev/null +++ b/apps/api/src/broker/poller.ts @@ -0,0 +1,85 @@ +/** + * 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 { 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 { + 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) + const [stored] = await db + .select() + .from(calendars) + .where(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) + }) + }) +}