From 383963d3b4dd21e92e88b79e7cf5104fc9c4bd87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:33:22 +0200 Subject: [PATCH] feat(reminders): every-15-min dispatcher for preventive-care reminders Add the measurement-reminder cron, mirroring the mood-reminder shape: a pure due-predicate (past nextDueAt AND inside the reminder's local notify-hour) and a tick runner that dispatches through the existing notification cascade. Auto-resolve runs in the cron, not on the ingest hot path: a matching reading of the reminder's type advances lastSatisfiedAt and skips the nudge (BP on the SYS sentinel; free-text reminders resolve only on a manual satisfy). Dedup is the nextDueAt advance past now, so no ledger table is needed. The queue is registered in the all-queues list, scheduled, and bound to its worker; a registration guard test pins that wiring. --- .../measurement-reminder-queue.test.ts | 60 ++++ .../__tests__/measurement-reminder.test.ts | 287 +++++++++++++++ src/lib/jobs/measurement-reminder.ts | 330 ++++++++++++++++++ src/lib/jobs/reminder-worker.ts | 32 ++ src/lib/jobs/reminder/mood-cycle-checks.ts | 40 +++ 5 files changed, 749 insertions(+) create mode 100644 src/lib/jobs/__tests__/measurement-reminder-queue.test.ts create mode 100644 src/lib/jobs/__tests__/measurement-reminder.test.ts create mode 100644 src/lib/jobs/measurement-reminder.ts diff --git a/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts b/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts new file mode 100644 index 000000000..e159e626f --- /dev/null +++ b/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts @@ -0,0 +1,60 @@ +/** + * v1.17.1 — measurement-reminder queue registration guard. + * + * Same source-text-grep approach as the cycle / mood / drain registration + * guards: assert the Vorsorge-reminder queue is declared, listed in + * `allQueues` (the v1.4.37 dead-queue lesson — an unregistered queue is + * never provisioned and the schedule silently no-ops), scheduled on its + * cron, and wired to a `boss.work` handler that delegates to + * `runMeasurementReminderTick`. No pg-boss / Prisma boot. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const source = + readFileSync(join(__dirname, "..", "reminder-worker.ts"), "utf8") + + readFileSync( + join(__dirname, "..", "reminder", "mood-cycle-checks.ts"), + "utf8", + ); + +describe("reminder-worker — measurement-reminder wiring", () => { + it("imports the measurement-reminder tick runner", () => { + expect(source).toMatch( + /from\s*["']@\/lib\/jobs\/measurement-reminder["']/, + ); + expect(source).toMatch(/\brunMeasurementReminderTick\b/); + }); + + it("declares the queue + cron constants", () => { + expect(source).toMatch( + /MEASUREMENT_REMINDER_QUEUE\s*=\s*["']measurement-reminder-check["']/, + ); + expect(source).toMatch( + /MEASUREMENT_REMINDER_CRON\s*=\s*["']\*\/15 \* \* \* \*["']/, + ); + }); + + it("registers the queue in the allQueues loop", () => { + const allQueuesMatch = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); + expect(allQueuesMatch).not.toBeNull(); + expect(allQueuesMatch![1]).toMatch(/\bMEASUREMENT_REMINDER_QUEUE\b/); + }); + + it("schedules the measurement-reminder cron", () => { + expect(source).toMatch( + /\[MEASUREMENT_REMINDER_QUEUE,\s*MEASUREMENT_REMINDER_CRON\]/, + ); + }); + + it("registers a boss.work handler that runs the tick", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,200}MEASUREMENT_REMINDER_QUEUE[\s\S]{0,200}handleMeasurementReminderCheck/, + ); + expect(source).toMatch( + /handleMeasurementReminderCheck[\s\S]{0,400}runMeasurementReminderTick/, + ); + }); +}); diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts new file mode 100644 index 000000000..5c6ad36d0 --- /dev/null +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -0,0 +1,287 @@ +/** + * v1.17.1 — Vorsorge (measurement) reminder dispatcher unit tests. + * + * Pins the contract: + * - Due-predicate window: fires only when past-due AND inside the + * reminder's local notify-hour (08:59 → no, 09:00 → yes, 09:59 → + * yes, 10:00 → no). A disabled reminder / null nextDueAt never fires. + * - Auto-resolve: a matching reading of the reminder's measurementType + * logged since the last satisfy advances lastSatisfiedAt + recomputes + * nextDueAt and suppresses the nudge. Free-text reminders never + * auto-resolve. + * - Ledger-free dedup: a successful dispatch advances nextDueAt past + * now so the same due cycle never re-fires. + * - clientManaged suppression skips the APNs send but still advances. + * + * The Prisma surface is stubbed manually to avoid a testcontainer boot. + */ +import { describe, it, expect, vi } from "vitest"; + +import { + evaluateMeasurementReminderDue, + runMeasurementReminderTick, +} from "../measurement-reminder"; +import type { NotificationPayload } from "@/lib/notifications/types"; +import type { DispatchOutcome } from "@/lib/notifications/dispatcher"; + +type DispatchFn = (payload: NotificationPayload) => Promise; + +const OK: DispatchOutcome = { + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, +}; + +const TZ = "Europe/Berlin"; + +// 09:00 Berlin in June = 07:00Z. +const NINE_LOCAL = new Date("2026-06-15T07:00:00Z"); + +describe("evaluateMeasurementReminderDue — window boundary", () => { + const reminder = { enabled: true, notifyHour: 9, nextDueAt: new Date(0) }; + + it("08:59 local → not in hour window", () => { + const d = evaluateMeasurementReminderDue( + reminder, + TZ, + new Date("2026-06-15T06:59:00Z"), + ); + expect(d.isDue).toBe(true); + expect(d.inHourWindow).toBe(false); + expect(d.fire).toBe(false); + }); + + it("09:00 local → fires", () => { + const d = evaluateMeasurementReminderDue(reminder, TZ, NINE_LOCAL); + expect(d.fire).toBe(true); + }); + + it("09:59 local → still in window", () => { + const d = evaluateMeasurementReminderDue( + reminder, + TZ, + new Date("2026-06-15T07:59:00Z"), + ); + expect(d.fire).toBe(true); + }); + + it("10:00 local → outside window", () => { + const d = evaluateMeasurementReminderDue( + reminder, + TZ, + new Date("2026-06-15T08:00:00Z"), + ); + expect(d.inHourWindow).toBe(false); + expect(d.fire).toBe(false); + }); + + it("not yet due → never fires", () => { + const d = evaluateMeasurementReminderDue( + { enabled: true, notifyHour: 9, nextDueAt: new Date("2099-01-01") }, + TZ, + NINE_LOCAL, + ); + expect(d.isDue).toBe(false); + expect(d.fire).toBe(false); + }); + + it("disabled / null nextDueAt → never fires", () => { + expect( + evaluateMeasurementReminderDue( + { enabled: false, notifyHour: 9, nextDueAt: new Date(0) }, + TZ, + NINE_LOCAL, + ).fire, + ).toBe(false); + expect( + evaluateMeasurementReminderDue( + { enabled: true, notifyHour: 9, nextDueAt: null }, + TZ, + NINE_LOCAL, + ).fire, + ).toBe(false); + }); +}); + +interface FakeReminder { + id: string; + measurementType: string | null; + intervalDays: number | null; + rrule: string | null; + anchorDate: Date | null; + notifyHour: number; + location: string | null; + nextDueAt: Date | null; + lastSatisfiedAt: Date | null; + enabled: boolean; + createdAt: Date; + user: { + id: string; + timezone: string; + locale: string | null; + notificationPrefs: unknown; + }; +} + +function makePrisma(opts: { + reminders: FakeReminder[]; + measurementMatch?: { measuredAt: Date } | null; +}) { + const updates: Array<{ id: string; data: Record }> = []; + const prisma = { + measurementReminder: { + findMany: vi.fn(async () => opts.reminders), + update: vi.fn( + async ({ + where, + data, + }: { + where: { id: string }; + data: Record; + }) => { + updates.push({ id: where.id, data }); + return { id: where.id, ...data }; + }, + ), + }, + measurement: { + findFirst: vi.fn(async () => opts.measurementMatch ?? null), + }, + }; + return { prisma, updates }; +} + +function reminder(overrides: Partial): FakeReminder { + return { + id: "r1", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 7, + rrule: null, + anchorDate: null, + notifyHour: 9, + location: null, + nextDueAt: new Date("2026-06-14T07:00:00Z"), // past at NINE_LOCAL + lastSatisfiedAt: null, + enabled: true, + createdAt: new Date("2026-06-01T00:00:00Z"), + user: { + id: "u1", + timezone: TZ, + locale: "de", + notificationPrefs: null, + }, + ...overrides, + }; +} + +describe("runMeasurementReminderTick", () => { + it("auto-resolves a typed reminder when a matching reading landed", async () => { + const matchAt = new Date("2026-06-14T18:00:00Z"); + const { prisma, updates } = makePrisma({ + reminders: [reminder({})], + measurementMatch: { measuredAt: matchAt }, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(summary.autoResolved).toBe(1); + expect(summary.dispatched).toBe(0); + expect(dispatch).not.toHaveBeenCalled(); + // Advanced lastSatisfiedAt to the reading instant + recomputed nextDueAt. + expect(updates).toHaveLength(1); + expect(updates[0].data.lastSatisfiedAt).toEqual(matchAt); + expect(updates[0].data.nextDueAt).toBeInstanceOf(Date); + }); + + it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => { + const { prisma, updates } = makePrisma({ + reminders: [reminder({})], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(summary.dispatched).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].eventType).toBe("MEASUREMENT_REMINDER"); + // nextDueAt advanced strictly past now. + expect(updates).toHaveLength(1); + const advanced = updates[0].data.nextDueAt as Date; + expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime()); + }); + + it("free-text reminder never auto-resolves (no measurement query match path)", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({ measurementType: null })], + // Even if a reading existed, a free-text reminder must not query it. + measurementMatch: { measuredAt: new Date("2026-06-14T18:00:00Z") }, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(summary.autoResolved).toBe(0); + expect(summary.dispatched).toBe(1); + expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); + }); + + it("suppresses the push under clientManaged but still advances nextDueAt", async () => { + const { prisma, updates } = makePrisma({ + reminders: [ + reminder({ + measurementType: null, + user: { + id: "u1", + timezone: TZ, + locale: "de", + notificationPrefs: { measurementReminder: { clientManaged: true } }, + }, + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(summary.skippedClientManaged).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(updates).toHaveLength(1); + expect(updates[0].data.nextDueAt).toBeInstanceOf(Date); + }); + + it("skips a reminder outside its notify-hour window", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({ measurementType: null })], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + new Date("2026-06-15T08:00:00Z"), // 10:00 Berlin — outside the 09 window + { dispatch }, + ); + + expect(summary.skippedOutsideWindow).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts new file mode 100644 index 000000000..7223d76ec --- /dev/null +++ b/src/lib/jobs/measurement-reminder.ts @@ -0,0 +1,330 @@ +/** + * v1.17.1 — Vorsorge (measurement) reminder dispatcher. + * + * Mirrors the mood-reminder cron (`mood-reminder.ts`): a separate module, + * a pure due-predicate, a `runMeasurementReminderTick(prisma, now)` + * runner. The cron fires every 15 minutes so it picks up every IANA + * timezone crossing the reminder's `notifyHour` without one cron entry + * per zone. + * + * Differences from the mood reminder: + * + * 1. No dedicated dispatch-ledger table. The reminder's own + * `nextDueAt` IS the dedup guard: after a successful dispatch the + * runner advances `nextDueAt` to the next occurrence strictly past + * now, so the same due cycle never re-fires. This collapses one + * table + one cleanup queue versus the mood pattern. + * 2. Auto-resolve from an incoming measurement. Before deciding to + * fire, the runner checks whether a matching reading of the + * reminder's `measurementType` has landed since the last satisfy + * (BP matched on `BLOOD_PRESSURE_SYS`). If so it advances + * `lastSatisfiedAt` + recomputes `nextDueAt` and skips the nudge — + * the user who already measured today never gets nagged. This is a + * query in the cron, NOT a hook on the hot iOS batch-ingest path. + * Free-text reminders (no `measurementType`) never auto-resolve; + * they advance only on a manual satisfy. + * 3. Per-reminder `clientManaged` suppression of the server-side APNs + * (the medication `clientManaged` precedent) is applied for the + * whole user via `notificationPrefs.measurementReminder.clientManaged`. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { Locale } from "@/lib/i18n/config"; +import { defaultLocale, locales } from "@/lib/i18n/config"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { getEvent } from "@/lib/logging/context"; +import { isMeasurementReminderClientManaged } from "@/lib/validations/notification-prefs"; +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; + +export interface MeasurementReminderSummary { + candidatesScanned: number; + inWindow: number; + dispatched: number; + autoResolved: number; + skippedNotDue: number; + skippedOutsideWindow: number; + skippedClientManaged: number; + skippedNoChannel: number; + failed: number; +} + +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + +/** + * Pure due-predicate: at this instant, is the reminder due AND inside its + * local notify-hour window? + * + * "Due" = `nextDueAt != null` and `now >= nextDueAt`. The hour gate keeps + * a reminder that became overdue overnight from firing at 03:00 — it + * waits for the user's chosen `notifyHour` to come round in their local + * timezone. Pulled out so the unit tests can pin the window boundary + * (08:59 → no, 09:00 → yes, 09:59 → yes, 10:00 → no) without the DB. + */ +export function evaluateMeasurementReminderDue( + reminder: { + enabled: boolean; + nextDueAt: Date | null; + notifyHour: number; + }, + timezone: string, + now: Date, +): { fire: boolean; inHourWindow: boolean; isDue: boolean } { + if (!reminder.enabled || reminder.nextDueAt === null) { + return { fire: false, inHourWindow: false, isDue: false }; + } + const isDue = now.getTime() >= reminder.nextDueAt.getTime(); + const parts = wallClockInTz(now, timezone || "Europe/Berlin"); + const inHourWindow = parts.hour === reminder.notifyHour; + return { fire: isDue && inHourWindow, inHourWindow, isDue }; +} + +/** + * Build the localised title + body for the Vorsorge push. + */ +export function buildMeasurementReminderPayload( + locale: string | null | undefined, + label: string, + location: string | null, +): { title: string; body: string } { + const t = getServerTranslator(resolveLocale(locale)).t; + const base = t("measurementReminders.pushBody", { label }); + const body = location + ? `${base} ${t("measurementReminders.pushLocation", { location })}` + : base; + return { title: t("measurementReminders.pushTitle"), body }; +} + +/** + * The canonical sentinel measurement type the cron queries for + * auto-resolve. BP is two rows (SYS + DIA); SYS is the "a BP was measured" + * anchor (matching both would double-count). + */ +function autoResolveQueryType( + reminderType: MeasurementType | null, +): MeasurementType | null { + return reminderType; +} + +/** + * Run one Vorsorge-reminder cron tick. Iterates every live, enabled + * reminder, auto-resolves the typed ones against incoming readings, + * and dispatches a `MEASUREMENT_REMINDER` push for any that are due + * inside the user's local notify-hour window. + */ +export async function runMeasurementReminderTick( + prisma: PrismaClient, + now: Date, + options: { + dispatch?: typeof dispatchNotification; + } = {}, +): Promise { + const dispatchImpl = options.dispatch ?? dispatchNotification; + + const summary: MeasurementReminderSummary = { + candidatesScanned: 0, + inWindow: 0, + dispatched: 0, + autoResolved: 0, + skippedNotDue: 0, + skippedOutsideWindow: 0, + skippedClientManaged: 0, + skippedNoChannel: 0, + failed: 0, + }; + + const reminders = await prisma.measurementReminder.findMany({ + where: { deletedAt: null, enabled: true }, + include: { + user: { + select: { + id: true, + timezone: true, + locale: true, + notificationPrefs: true, + }, + }, + }, + }); + + for (const reminder of reminders) { + summary.candidatesScanned += 1; + try { + const timezone = reminder.user.timezone || "Europe/Berlin"; + + // ── Auto-resolve from an incoming measurement ────────────────── + // Cheap guard, in the cron (not on the ingest path). Only typed + // reminders auto-resolve; free-text ones wait for a manual satisfy. + const queryType = autoResolveQueryType(reminder.measurementType); + if (queryType !== null) { + // Match readings logged AFTER the last satisfy (or, never + // satisfied, after the anchor). A reading inside the current due + // cycle means the user already measured — re-anchor + skip the + // nudge. `deletedAt: null` so a tombstoned reading never counts. + const sinceFloor = + reminder.lastSatisfiedAt ?? + reminder.anchorDate ?? + reminder.createdAt; + const match = await prisma.measurement.findFirst({ + where: { + userId: reminder.user.id, + type: queryType, + deletedAt: null, + measuredAt: { gt: sinceFloor }, + }, + orderBy: { measuredAt: "desc" }, + select: { measuredAt: true }, + }); + if (match) { + const scheduleInput: ReminderScheduleInput = { + intervalDays: reminder.intervalDays, + rrule: reminder.rrule, + anchorDate: reminder.anchorDate, + notifyHour: reminder.notifyHour, + lastSatisfiedAt: match.measuredAt, + createdAt: reminder.createdAt, + }; + const nextDueAt = computeReminderNextDueAt( + scheduleInput, + timezone, + match.measuredAt, + ); + await prisma.measurementReminder.update({ + where: { id: reminder.id }, + data: { lastSatisfiedAt: match.measuredAt, nextDueAt }, + }); + summary.autoResolved += 1; + continue; + } + } + + const decision = evaluateMeasurementReminderDue( + { + enabled: reminder.enabled, + nextDueAt: reminder.nextDueAt, + notifyHour: reminder.notifyHour, + }, + timezone, + now, + ); + + if (!decision.isDue) { + summary.skippedNotDue += 1; + continue; + } + if (!decision.inHourWindow) { + summary.skippedOutsideWindow += 1; + continue; + } + + summary.inWindow += 1; + + // Per-user client-managed suppression (the medication precedent). + if ( + isMeasurementReminderClientManaged(reminder.user.notificationPrefs) + ) { + getEvent()?.addMeta( + "measurement_reminder.suppressed_client_managed", + reminder.id, + ); + summary.skippedClientManaged += 1; + // Advance past this cycle anyway so a client-managed reminder does + // not pin the server tick re-evaluating the same overdue slot + // every 15 minutes for the rest of the day. + await advanceNextDue(prisma, reminder, timezone, now); + continue; + } + + const { title, body } = buildMeasurementReminderPayload( + reminder.user.locale, + reminder.label, + reminder.location, + ); + + const outcome = await dispatchImpl({ + eventType: "MEASUREMENT_REMINDER", + userId: reminder.user.id, + title, + message: body, + metadata: { + scheduledAt: now.toISOString(), + reminderId: reminder.id, + }, + }); + + // No channel succeeded — leave `nextDueAt` where it is so the next + // tick (or the user's next channel) retries. The reminder simply + // stays overdue, which the surface already shows as "überfällig". + if (!outcome.dispatched) { + summary.skippedNoChannel += 1; + continue; + } + + // Dispatch succeeded — advance `nextDueAt` past now so this due + // cycle never re-fires (the ledger-free dedup guard). + await advanceNextDue(prisma, reminder, timezone, now); + summary.dispatched += 1; + } catch (err: unknown) { + summary.failed += 1; + const message = err instanceof Error ? err.message : String(err); + getEvent()?.addWarning( + `measurement-reminder per-reminder dispatch failed for ${reminder.id}: ${message}`, + ); + } + } + + return summary; +} + +/** + * Advance `nextDueAt` to the next occurrence strictly after `now`. Used + * after a successful dispatch (and after a client-managed skip) as the + * ledger-free dedup guard. Does NOT touch `lastSatisfiedAt` — a fired + * reminder is not "satisfied", it just rolls to its next slot. + */ +async function advanceNextDue( + prisma: PrismaClient, + reminder: { + id: string; + intervalDays: number | null; + rrule: string | null; + anchorDate: Date | null; + notifyHour: number; + lastSatisfiedAt: Date | null; + createdAt: Date; + }, + timezone: string, + now: Date, +): Promise { + // The dedup guard must move the slot strictly forward. For an `rrule` + // the engine already walks to the next strictly-after-now occurrence, + // so passing the row as-is is correct. For a ROLLING reminder the + // engine anchors the first-due slot AT `anchorDate ?? createdAt` when + // never satisfied, which stays ≤ now and would re-fire every tick — so + // re-anchor the rolling cadence on `now` (a fire is the rhythm + // restarting from this dispatch) to roll it forward by exactly one + // interval. This does NOT advance `lastSatisfiedAt`: a fired reminder + // is not satisfied, just rescheduled past the slot it nagged on. + const rolling = reminder.intervalDays !== null; + const scheduleInput: ReminderScheduleInput = { + intervalDays: reminder.intervalDays, + rrule: reminder.rrule, + anchorDate: reminder.anchorDate, + notifyHour: reminder.notifyHour, + lastSatisfiedAt: rolling ? now : reminder.lastSatisfiedAt, + createdAt: reminder.createdAt, + }; + const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now); + await prisma.measurementReminder.update({ + where: { id: reminder.id }, + data: { nextDueAt }, + }); +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 57aa681f0..f2187e306 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -239,8 +239,10 @@ import { import { MoodReminderPayload, CycleReminderPayload, + MeasurementReminderPayload, handleMoodReminderCheck, handleCycleReminderCheck, + handleMeasurementReminderCheck, } from "./reminder/mood-cycle-checks"; import { HostMetricSamplePayload, @@ -500,6 +502,17 @@ const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *"; const CYCLE_REMINDER_QUEUE = "cycle-reminder-check"; const CYCLE_REMINDER_CRON = "*/15 * * * *"; + +// v1.17.1 — every-15-min tick for Vorsorge (measurement) reminders. +// Same cadence + short-circuit shape as the mood / cycle reminders: the +// handler only fires a reminder whose `nextDueAt` is past AND whose local +// time is the reminder's `notifyHour`, so the 15-min cadence picks up +// every IANA timezone crossing that hour without one cron entry per zone. +// Dedup is the reminder's own `nextDueAt` advance — no ledger table. + +const MEASUREMENT_REMINDER_QUEUE = "measurement-reminder-check"; + +const MEASUREMENT_REMINDER_CRON = "*/15 * * * *"; // v1.4.38 — the per-sample cutoff hours constant now lives on the // helper module so the worker, the admin route, and the CLI all read // the same source of truth. Re-export pulled in alongside @@ -716,6 +729,11 @@ export async function startReminderWorker() { // without this entry the every-15-min schedule silently no-ops and the // cycle dispatcher never fires (the v1.4.37 dead-queue class). CYCLE_REMINDER_QUEUE, + // v1.17.1 — Vorsorge (measurement) reminder cron tick. Same pg-boss + // v12 createQueue contract; without this entry pg-boss never + // provisions the queue and the every-15-min schedule silently no-ops + // (the v1.4.37 dead-queue class). + MEASUREMENT_REMINDER_QUEUE, // v1.4.49 — push-attempt ledger cleanup. Same createQueue contract // as the other cleanup jobs; the daily schedule below would // silently no-op without this entry. @@ -908,6 +926,11 @@ export async function startReminderWorker() { // hour, so the cron costs ~one prediction-row scan per tick for the // opted-in cohort. [CYCLE_REMINDER_QUEUE, CYCLE_REMINDER_CRON], + // v1.17.1 — every-15-min tick for the Vorsorge (measurement) reminder. + // The handler short-circuits unless a reminder is past-due AND the + // user's local time matches the reminder's notifyHour, so the cron + // costs ~one reminder-row scan per tick for the active cohort. + [MEASUREMENT_REMINDER_QUEUE, MEASUREMENT_REMINDER_CRON], // v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts. [PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON], // v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight @@ -1193,6 +1216,15 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleCycleReminderCheck, ); + // v1.17.1 — single-flight Vorsorge (measurement) reminder worker. + // localConcurrency=1 keeps two ticks from racing the `nextDueAt` + // advance that anchors the per-cycle idempotency, exactly like the + // mood / cycle reminder workers. + await boss.work( + MEASUREMENT_REMINDER_QUEUE, + { localConcurrency: 1 }, + handleMeasurementReminderCheck, + ); // v1.4.49 — daily prune of the push-attempt ledger. Single-flight // matches every other cleanup queue; two ticks racing on the same // DELETE statement is wasted work and the second tick's payload diff --git a/src/lib/jobs/reminder/mood-cycle-checks.ts b/src/lib/jobs/reminder/mood-cycle-checks.ts index cc94c338b..655a5b2b3 100644 --- a/src/lib/jobs/reminder/mood-cycle-checks.ts +++ b/src/lib/jobs/reminder/mood-cycle-checks.ts @@ -9,6 +9,7 @@ import { recordError } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; import { runMoodReminderTick } from "@/lib/jobs/mood-reminder"; import { runCycleReminderTick } from "@/lib/jobs/cycle-reminder"; +import { runMeasurementReminderTick } from "@/lib/jobs/measurement-reminder"; import { getWorkerPrisma } from "./shared"; export interface MoodReminderPayload { @@ -19,6 +20,10 @@ export interface CycleReminderPayload { triggeredAt: string; } +export interface MeasurementReminderPayload { + triggeredAt: string; +} + export async function handleMoodReminderCheck( jobs: Job[], ) { @@ -80,3 +85,38 @@ export async function handleCycleReminderCheck( } }); } + +/** + * v1.17.1 — every-15-min Vorsorge (measurement) reminder tick. Thin shim + * around `runMeasurementReminderTick` so the unit tests exercise the + * due-predicate + auto-resolve + advance logic without pg-boss. + */ +export async function handleMeasurementReminderCheck( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.measurement_reminder", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const summary = await runMeasurementReminderTick(prisma, new Date()); + evt.setBackground({ + task_name: "job.measurement_reminder", + result: { + candidates_scanned: summary.candidatesScanned, + in_window: summary.inWindow, + dispatched: summary.dispatched, + auto_resolved: summary.autoResolved, + skipped_not_due: summary.skippedNotDue, + skipped_outside_window: summary.skippedOutsideWindow, + skipped_client_managed: summary.skippedClientManaged, + skipped_no_channel: summary.skippedNoChannel, + failed: summary.failed, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +}