/** * Reminder scheduler — shared-timed-event 15-min reminder scan. * * Fires every minute via setInterval. Each tick calls runReminderCheck() which: * (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.) * 1. Queries shared (isShared=true) timed (allDay=false) events whose dtstartUtc * falls in (now, now+16min] — strictly after now (future only; excludes * already-started events) and at most 16 min out. Because the lower bound * is `now` and not `now+14min`, a missed/late cron tick is recovered on the * next scan: as long as the event hasn't started, it re-appears in the window. * 2. Cross-joins with ALL push_subscriptions (shared reminder → every member). * 3. Deduplicates on uid ALONE so a recovered reminder fires EXACTLY ONCE * no matter how many consecutive ticks the event spends in the window. * CAVEAT: if an event's dtstart is rescheduled earlier after a reminder * already fired, it will not re-fire in v1 (acceptable for household use). * 4. Calls dispatchPush once per subscription per deduped event. * * Decisions enforced here: * D-05 — isShared=true in the SQL WHERE (not in copy); personal events excluded. * D-06 — fixed 16-min catch-up window; no per-event customisation. * D-07 — allDay=false in the SQL WHERE; all-day events are silently excluded. * D-11 — every push goes through dispatchPush which always sends a visible notification. * D-12 — in-memory Map; no Redis; single-process deployment. * Key: bare event uid. Value: event's dtstart in ms (for pruning). * Acceptable data loss on process restart for a two-person household. * D-16 — empty shared-calendar set (Family calendar not yet created) produces zero * sends and no crash (cross-join on empty table returns no rows). * * Threat mitigations: * T-05-17 — D-05 enforced in QUERY (WHERE isShared=true), not in copy. * T-05-18 — in-memory dedup Map keyed by uid; per-event try/catch. * T-05-19 — per-subscription try/catch; dispatchPush already swallows 410/404. */ import { and, eq, gt, lte, sql } from 'drizzle-orm'; import { db } from '../db/client.js'; import { calendars, calendarEvents, pushSubscriptions } from '../db/schema.js'; import { dispatchPush } from '../lib/pushDispatcher.js'; import type { NotificationPayload } from '../lib/pushDispatcher.js'; // ── In-memory dedup (D-12: single-process, no Redis) ───────────────────────── // Key: event uid (bare string — no minuteBucket suffix). // Value: event's dtstart in ms — used by CR-01 pruning to drop started events. // Prevents double-fire when the same event sits in the catch-up window across // multiple consecutive ticks (cross-tick exactly-once guarantee). // Acceptable data loss on process restart for a two-person household. const sentReminders = new Map(); // ── Helpers ─────────────────────────────────────────────────────────────────── /** * Format a UTC Date as "YYYY-MM-DD" for calendar deep-link URLs. */ function yyyyMmDd(d: Date): string { const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, '0'); const day = String(d.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } // ── Core scan ───────────────────────────────────────────────────────────────── /** * Run one reminder scan cycle. * * Exported with an injectable `now` parameter so tests can control time without * needing the cron schedule to fire. Production call passes no argument; * default = new Date() (current wall-clock time). */ export async function runReminderCheck(now = new Date()): Promise { const windowEnd = new Date(now.getTime() + 16 * 60 * 1000); // Single query: events (isShared, timed, in catch-up window) cross-joined with ALL // push_subscriptions. The cross-join (sql`1=1`) fans every matching event out // to every subscriber — shared reminder, every member notified (D-05, member-agnostic). // // Window: (now, now+16min] — strictly greater-than now excludes already-started events; // lte now+16min is the upper bound. Catching up: if the ideal 15-min tick was missed, // the event is still > now on the next tick and will be found. // // With an empty push_subscriptions table (no subscribers yet) the INNER JOIN // on sql`1=1` returns no rows — zero sends, no crash (D-16 empty-calendar analogue). const rows = await db .select({ uid: calendarEvents.uid, title: calendarEvents.title, dtstartUtc: calendarEvents.dtstartUtc, allDay: calendarEvents.allDay, isShared: calendars.isShared, subId: pushSubscriptions.id, subUserId: pushSubscriptions.userId, subEndpoint: pushSubscriptions.endpoint, subP256dh: pushSubscriptions.p256dh, subAuth: pushSubscriptions.auth, }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .innerJoin(pushSubscriptions, sql`1=1`) .where( and( eq(calendars.isShared, true), // D-05: shared calendar only (in QUERY, not copy) eq(calendarEvents.allDay, false), // D-07: timed events only gt(calendarEvents.dtstartUtc, now), // strictly future (excludes already-started) lte(calendarEvents.dtstartUtc, windowEnd), ), ); // Group flat (event, subscription) rows by event uid so we can: // a) dedup per event (not per (event, sub) pair), and // b) fan out to ALL subscriptions for a deduped event in one pass. type SubRow = { id: number; userId: number; endpoint: string; p256dh: string; auth: string }; const byUid = new Map< string, { uid: string; title: string | null; dtstartUtc: Date; subs: SubRow[] } >(); for (const row of rows) { if (!byUid.has(row.uid)) { byUid.set(row.uid, { uid: row.uid, title: row.title ?? null, // dtstartUtc is guaranteed non-null by the allDay=false + gt/lte WHERE dtstartUtc: row.dtstartUtc as Date, subs: [], }); } // subId is undefined when cross-join produces no subscriptions row (empty table) if (row.subId != null) { byUid.get(row.uid)!.subs.push({ id: row.subId, userId: row.subUserId, endpoint: row.subEndpoint, p256dh: row.subP256dh, auth: row.subAuth, }); } } // Dedup and dispatch for (const [uid, event] of byUid) { try { // Per-uid exactly-once dedup (D-12): keyed on bare uid, no minuteBucket. // Same event fires at most once regardless of how many ticks it sits in window. // CAVEAT: rescheduling dtstart earlier after a reminder fired will not re-fire (v1). if (sentReminders.has(uid)) continue; const dateStr = yyyyMmDd(event.dtstartUtc); // Lead-accurate body: compute actual minutes to start, guarded to minimum 1. const minutes = Math.max(1, Math.round((event.dtstartUtc.getTime() - now.getTime()) / 60000)); const notification: NotificationPayload = { // Null-safe title fallback (D-02 / NOTIF-01): title column may be NULL on rows // created before the Plan 05-07 sync update. Use the uid rather than "undefined". title: event.title ?? uid, body: `Starts in ${minutes} min`, tag: `reminder-${uid}`, navigate: `/calendar?date=${dateStr}&event=${uid}`, }; // Fan out to every subscriber (member-count-agnostic) for (const sub of event.subs) { try { await dispatchPush(sub, notification); } catch (err) { // Per-subscription error isolation (T-05-19): one bad sub never aborts the cycle. // dispatchPush itself never throws (it resolves after logging), so this outer // catch is a belt-and-suspenders guard for unexpected errors. console.error( `[broker/reminderScheduler] Error dispatching reminder to sub id=${sub.id} for event uid=${uid}:`, err instanceof Error ? err.message : String(err), ); } } // WR-01: mark sent AFTER all dispatches have been attempted. Pre-marking before // dispatch prevents retry when dispatchPush throws — at-least-once delivery // requires not pre-marking the uid. sentReminders.set(uid, event.dtstartUtc.getTime()); } catch (err) { // Per-event error isolation (T-05-18): one bad event never aborts remaining events. console.error( `[broker/reminderScheduler] Error processing event uid=${uid}:`, err instanceof Error ? err.message : String(err), ); } } // CR-01: Prune started events from sentReminders to prevent unbounded growth. // Any entry whose stored dtstart is <= now means the event has already started; // it can be removed safely (it will never re-enter the (now, now+16min] window). for (const [uid, dtstartMs] of sentReminders) { if (dtstartMs <= now.getTime()) { sentReminders.delete(uid); } } } // ── Scheduler ───────────────────────────────────────────────────────────────── /** * Start the 1-minute reminder scan schedule. * Call once from index.ts's isMainModule() guard — NOT at import time * (keeps the setInterval out of the test process; mirrors startBrokerPoller pattern). * 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 startReminderScheduler(): void { setInterval(() => { runReminderCheck().catch((err: unknown) => { console.error('[broker/reminderScheduler] Unhandled runReminderCheck error:', err); }); }, 60 * 1000); }