feat(01-03): implement ctag poller with node-cron 5-min schedule
- startBrokerPoller schedules runPoll via node-cron */5 * * * * - runPoll loads all member_credentials, decrypts each app password - ctag/syncToken comparison skips syncCalendar when ctag unchanged - errors per-credential caught and logged without crashing the poller - runPoll exported for unit-testing with vi.mock injected dependencies
This commit is contained in:
@@ -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<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)
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user