diff --git a/apps/api/src/broker/reminderScheduler.ts b/apps/api/src/broker/reminderScheduler.ts index d589291..02f9a60 100644 --- a/apps/api/src/broker/reminderScheduler.ts +++ b/apps/api/src/broker/reminderScheduler.ts @@ -183,7 +183,8 @@ export async function runReminderCheck(now = new Date()): Promise { { uid: string; title: string | null; - dtstartMs: number; + dtstartMs: number; // key component — used for compound dedup key + pruneMs: number; // when to prune: dtstartUtc for timed; end-of-event-date for all-day reminderLeadMinutes: number; dateStr: string; subs: SubRow[]; @@ -211,6 +212,7 @@ export async function runReminderCheck(now = new Date()): Promise { uid: row.uid, title: row.title ?? null, dtstartMs, + pruneMs: dtstartMs, // timed: prune when event has started (dtstartUtc <= now) reminderLeadMinutes: lead, dateStr: yyyyMmDd(dtstartUtc), subs: [], @@ -248,11 +250,17 @@ export async function runReminderCheck(now = new Date()): Promise { const dtstartMs = Date.UTC(y, m - 1, d); // UTC midnight of event date const dedupKey = `${row.uid}:${dtstartMs}`; + // Prune value for all-day events: start-of-next-day UTC. + // Using UTC midnight of dtstartDate would be pruned immediately (it's in the past by 9 AM); + // using end-of-event-day ensures the entry persists through the full fire window. + const pruneMs = Date.UTC(y, m - 1, d + 1); // UTC midnight of event date + 1 day + if (!byKey.has(dedupKey)) { byKey.set(dedupKey, { uid: row.uid, title: row.title ?? null, dtstartMs, + pruneMs, reminderLeadMinutes: lead, dateStr: dtstartDate, subs: [], @@ -302,7 +310,10 @@ export async function runReminderCheck(now = new Date()): Promise { // 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. - sentReminders.set(dedupKey, event.dtstartMs); + // Store pruneMs (not dtstartMs) so the CR-01 prune removes the entry at the right time: + // - timed: pruneMs = dtstartUtc → pruned when event starts + // - all-day: pruneMs = start-of-next-day → pruned after the event date + sentReminders.set(dedupKey, event.pruneMs); } catch (err) { // Per-event error isolation (T-05-18): one bad event never aborts remaining events. console.error( diff --git a/apps/api/tests/broker/reminderScheduler.test.ts b/apps/api/tests/broker/reminderScheduler.test.ts index be04096..04a9a39 100644 --- a/apps/api/tests/broker/reminderScheduler.test.ts +++ b/apps/api/tests/broker/reminderScheduler.test.ts @@ -652,6 +652,153 @@ describe('reminderScheduler — D-09: humanized push body in dispatch', () => { }); }); +// ── NOTIF-06: All-day 9 AM-local fire branch ───────────────────────────────── + +describe('reminderScheduler — NOTIF-06: all-day 9 AM-local fire branch', () => { + // All-day tests use the server timezone (America/New_York = UTC-4 in summer). + // computeAlertInstantUtc('2026-06-15', 0, 'America/New_York') = 2026-06-15T13:00:00Z. + // Tests set `now` to the expected alert UTC to trigger the fire window. + + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('all-day 0-lead fires at 9 AM local (not midnight) on event date', async () => { + // 2026-06-15 all-day, 0-lead → alert = computeAlertInstantUtc('2026-06-15', 0, tz) + // America/New_York summer (EDT, UTC-4) → 9 AM EDT = 2026-06-15T13:00:00Z + const alertUtc = new Date('2026-06-15T13:00:00Z'); + vi.setSystemTime(alertUtc); // now = alert time → fire window triggers + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + const allDayRow = makeEventRow({ + uid: 'allday-0lead-uid', + title: 'All-Day Event', + dtstartDate: '2026-06-15', + dtstartUtc: null, + allDay: true, + reminderLeadMinutes: 0, + }); + + // Second query (all-day) returns this row; timed query empty + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(alertUtc); + + // Must dispatch at 13:00 UTC (9 AM EDT) — NOT at midnight + expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); + }); + + it('all-day 0-lead does NOT fire at midnight (2026-06-15T00:00:00Z)', async () => { + // Midnight UTC is NOT 9 AM local → must not dispatch + const midnight = new Date('2026-06-15T00:00:00Z'); + vi.setSystemTime(midnight); + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + const allDayRow = makeEventRow({ + uid: 'allday-midnight-uid', + dtstartDate: '2026-06-15', + dtstartUtc: null, + allDay: true, + reminderLeadMinutes: 0, + }); + + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(midnight); + + // Alert time is 13:00 UTC, not midnight → no dispatch + expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); + }); + + it('all-day 1440-lead fires at 9 AM local the day before (2026-06-14T13:00:00Z)', async () => { + // 2026-06-15 all-day, 1440-lead → alert day = 2026-06-14 + // 9 AM EDT on 2026-06-14 = 2026-06-14T13:00:00Z + const alertUtc = new Date('2026-06-14T13:00:00Z'); + vi.setSystemTime(alertUtc); + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + const allDayRow = makeEventRow({ + uid: 'allday-1440lead-uid', + dtstartDate: '2026-06-15', + dtstartUtc: null, + allDay: true, + reminderLeadMinutes: 1440, + }); + + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(alertUtc); + + expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); + }); + + it('all-day 10080-lead fires at 9 AM local 7 days before the event', async () => { + // 2026-06-22 all-day, 10080-lead (7 days) → alert day = 2026-06-15 + // 9 AM EDT on 2026-06-15 = 2026-06-15T13:00:00Z + const alertUtc = new Date('2026-06-15T13:00:00Z'); + vi.setSystemTime(alertUtc); + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + const allDayRow = makeEventRow({ + uid: 'allday-10080lead-uid', + dtstartDate: '2026-06-22', + dtstartUtc: null, + allDay: true, + reminderLeadMinutes: 10080, + }); + + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(alertUtc); + + expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); + }); + + it('all-day dedup: same uid:dtstartMs fires exactly once across consecutive ticks', async () => { + // NOTIF-06: uid:dtstartDate-midnight-ms dedup for all-day events + const alertUtc = new Date('2026-06-15T13:00:00Z'); // fire time for '2026-06-15', 0-lead, EDT + + const { db } = await import('../../src/db/client.js'); + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); + + const allDayRow = makeEventRow({ + uid: 'allday-dedup-uid', + dtstartDate: '2026-06-15', + dtstartUtc: null, + allDay: true, + reminderLeadMinutes: 0, + }); + + // Tick 1: at alert time — fires + vi.setSystemTime(alertUtc); + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(alertUtc); + expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); + + // Tick 2: 30s later — same uid:dtstartMs in sentReminders → no re-dispatch + const tick2 = new Date(alertUtc.getTime() + 30 * 1000); + vi.setSystemTime(tick2); + mockTwoQueries(vi.mocked(db), [], [allDayRow]); + await runReminderCheck(tick2); + expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1 + }); +}); + // ── T-05-19: per-subscription error isolation ──────────────────────────────── describe('reminderScheduler — T-05-19: per-subscription error isolation', () => {