Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
105 lines
4.3 KiB
TypeScript
105 lines
4.3 KiB
TypeScript
/**
|
|
* 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<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;
|
|
|
|
// 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,
|
|
);
|
|
}
|