Files
familysync/apps/api/src/broker/reminderScheduler.ts
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
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.
2026-06-11 20:35:18 -04:00

216 lines
10 KiB
TypeScript

/**
* 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<string, number>();
// ── 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<void> {
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);
}