diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts index 574a88edd..95233b768 100644 --- a/src/app/api/measurement-reminders/[id]/satisfy/route.ts +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -13,10 +13,7 @@ import { auditLog } from "@/lib/auth/audit"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { - computeReminderNextDueAt, - type ReminderScheduleInput, -} from "@/lib/measurement-reminders/scheduling"; +import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; type RouteParams = { params: Promise<{ id: string }> }; @@ -42,19 +39,14 @@ export const POST = apiHandler( const timezone = userRow?.timezone || DEFAULT_TIMEZONE; const now = new Date(); - const scheduleInput: ReminderScheduleInput = { - intervalDays: existing.intervalDays, - rrule: existing.rrule, - anchorDate: existing.anchorDate, - notifyHour: existing.notifyHour, - lastSatisfiedAt: now, - createdAt: existing.createdAt, - }; - const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now); + // The ONE shared satisfaction primitive — same code the cron + // auto-resolve and the eventful worker use. A manual "Erledigt" is + // always strictly after any prior satisfy, so the forward-only guard + // advances it. + await satisfyReminder(prisma, existing, timezone, now); - const updated = await prisma.measurementReminder.update({ + const updated = await prisma.measurementReminder.findUniqueOrThrow({ where: { id }, - data: { lastSatisfiedAt: now, nextDueAt }, }); await auditLog("measurementReminder.satisfy", { diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 230f398bb..0d960d838 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -42,6 +42,8 @@ import { computeReminderNextDueAt, type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; +import { findSatisfyingEvent } from "@/lib/measurement-reminders/resolve"; +import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; /** * v1.18.0 — map a reminder's `measurementType` to the toggleable module @@ -137,17 +139,6 @@ export function buildMeasurementReminderPayload( 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, @@ -216,47 +207,27 @@ export async function runMeasurementReminderTick( 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 }, - }); + // ── Auto-resolve from an incoming event ──────────────────────── + // Cheap safety-net poll, in the cron (the eventful `reminder-satisfy` + // worker is the fast path). A typed reminder resolves from a matching + // Measurement; a free-text reminder resolves from a LabResult (the + // Lab↔Vorsorge link). Both go through the shared `findSatisfyingEvent` + // + `satisfyReminder` primitives so the cron and the worker can never + // diverge. A reading inside the current due cycle means the user + // already measured — re-anchor + skip the nudge. + const satisfiedAt = await findSatisfyingEvent( + prisma, + reminder.user.id, + reminder, + ); + if (satisfiedAt) { + const result = await satisfyReminder( + prisma, + reminder, + timezone, + satisfiedAt, + ); + if (result.satisfied) { summary.autoResolved += 1; continue; } @@ -360,6 +331,105 @@ export async function runMeasurementReminderTick( return summary; } +export interface ReminderSatisfySummary { + candidatesScanned: number; + satisfied: number; + skippedModuleDisabled: number; + skippedNoEvent: number; + failed: number; +} + +/** + * v1.18.1 — eventful satisfaction for one user. Called by the + * `reminder-satisfy` worker right after a measurement / lab write lands + * (from any ingest path: manual create, batch, or a device sync). The + * 15-min cron remains the idempotent safety-net behind this. + * + * Loads the user's live, enabled reminders that could auto-resolve (typed + * → a Measurement; free-text → a LabResult), and for each runs the SAME + * `findSatisfyingEvent` + `satisfyReminder` primitives the cron uses, so + * "I just weighed myself, stop reminding me" resolves immediately instead + * of waiting up to 15 minutes. + * + * Respects the module toggle: a reminder whose measurement type belongs to + * a disabled module produces no engine activity (the same gate the cron + * dispatch applies). Forward-only `satisfyReminder` makes a duplicate + * enqueue + the trailing cron poll converge without double-stamping. + */ +export async function runReminderSatisfyForUser( + prisma: PrismaClient, + userId: string, + now: Date, + options: { isModuleEnabled?: typeof isModuleEnabled } = {}, +): Promise { + void now; + const moduleGate = options.isModuleEnabled ?? isModuleEnabled; + + const summary: ReminderSatisfySummary = { + candidatesScanned: 0, + satisfied: 0, + skippedModuleDisabled: 0, + skippedNoEvent: 0, + failed: 0, + }; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + const timezone = user?.timezone || "Europe/Berlin"; + + const reminders = await prisma.measurementReminder.findMany({ + where: { userId, deletedAt: null, enabled: true }, + }); + + for (const reminder of reminders) { + summary.candidatesScanned += 1; + try { + // Module toggle — no engine activity for a reminder whose type + // belongs to a disabled module. Core-vital + free-text reminders map + // to no module and are never gated. + const gatedModule = moduleForMeasurementType(reminder.measurementType); + if ( + gatedModule !== null && + !(await moduleGate(userId, gatedModule)) + ) { + summary.skippedModuleDisabled += 1; + continue; + } + + const satisfiedAt = await findSatisfyingEvent(prisma, userId, reminder); + if (!satisfiedAt) { + summary.skippedNoEvent += 1; + continue; + } + + const result = await satisfyReminder( + prisma, + reminder, + timezone, + satisfiedAt, + ); + if (result.satisfied) { + summary.satisfied += 1; + getEvent()?.addMeta("measurement_reminder.satisfied_eventful", reminder.id); + } else { + // Forward-only no-op — the cron or a prior enqueue already + // advanced this row past the event. + summary.skippedNoEvent += 1; + } + } catch (err: unknown) { + summary.failed += 1; + const message = err instanceof Error ? err.message : String(err); + getEvent()?.addWarning( + `reminder-satisfy per-reminder resolve 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 diff --git a/src/lib/measurement-reminders/__tests__/resolve.test.ts b/src/lib/measurement-reminders/__tests__/resolve.test.ts new file mode 100644 index 000000000..1ef49016b --- /dev/null +++ b/src/lib/measurement-reminders/__tests__/resolve.test.ts @@ -0,0 +1,112 @@ +/** + * v1.18.1 — `findSatisfyingEvent`: the shared "did a satisfying event + * land?" matcher. A typed reminder resolves from a Measurement; a + * free-text reminder resolves from a LabResult (the Lab↔Vorsorge link, D2). + */ +import { describe, expect, it, vi } from "vitest"; + +import { findSatisfyingEvent, satisfactionFloor } from "../resolve"; + +type FindFirstArgs = { where: Record }; + +function makePrisma(opts: { + measurement?: { measuredAt: Date } | null; + lab?: { takenAt: Date } | null; +}) { + return { + measurement: { + findFirst: vi.fn((args: FindFirstArgs) => { + void args; + return Promise.resolve(opts.measurement ?? null); + }), + }, + labResult: { + findFirst: vi.fn((args: FindFirstArgs) => { + void args; + return Promise.resolve(opts.lab ?? null); + }), + }, + }; +} + +const base = { + measurementType: null, + anchorDate: null, + lastSatisfiedAt: null, + createdAt: new Date("2026-06-01T00:00:00Z"), +} as const; + +describe("satisfactionFloor", () => { + it("prefers lastSatisfiedAt, then anchorDate, then createdAt", () => { + const last = new Date("2026-06-10T00:00:00Z"); + const anchor = new Date("2026-06-05T00:00:00Z"); + expect( + satisfactionFloor({ ...base, lastSatisfiedAt: last, anchorDate: anchor }), + ).toEqual(last); + expect(satisfactionFloor({ ...base, anchorDate: anchor })).toEqual(anchor); + expect(satisfactionFloor(base)).toEqual(base.createdAt); + }); +}); + +describe("findSatisfyingEvent", () => { + it("resolves a typed reminder from a matching Measurement, not a lab", async () => { + const measuredAt = new Date("2026-06-14T18:00:00Z"); + const prisma = makePrisma({ measurement: { measuredAt }, lab: { takenAt: new Date() } }); + + const at = await findSatisfyingEvent(prisma as never, "u1", { + ...base, + measurementType: "WEIGHT", + }); + + expect(at).toEqual(measuredAt); + expect(prisma.measurement.findFirst).toHaveBeenCalledTimes(1); + expect(prisma.labResult.findFirst).not.toHaveBeenCalled(); + // Queries the right type, non-deleted, after the floor. + const where = prisma.measurement.findFirst.mock.calls[0]![0].where as { + type: string; + deletedAt: null; + measuredAt: { gt: Date }; + }; + expect(where.type).toBe("WEIGHT"); + expect(where.deletedAt).toBeNull(); + expect(where.measuredAt.gt).toEqual(base.createdAt); + }); + + it("returns null for a typed reminder with no matching reading", async () => { + const prisma = makePrisma({ measurement: null }); + const at = await findSatisfyingEvent(prisma as never, "u1", { + ...base, + measurementType: "BLOOD_PRESSURE_SYS", + }); + expect(at).toBeNull(); + }); + + it("resolves a free-text reminder from any LabResult (D2)", async () => { + const takenAt = new Date("2026-06-15T09:00:00Z"); + const prisma = makePrisma({ lab: { takenAt } }); + + const at = await findSatisfyingEvent(prisma as never, "u1", { + ...base, + measurementType: null, + }); + + expect(at).toEqual(takenAt); + expect(prisma.labResult.findFirst).toHaveBeenCalledTimes(1); + expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); + const where = prisma.labResult.findFirst.mock.calls[0]![0].where as { + deletedAt: null; + takenAt: { gt: Date }; + }; + expect(where.deletedAt).toBeNull(); + expect(where.takenAt.gt).toEqual(base.createdAt); + }); + + it("returns null for a free-text reminder with no lab landed", async () => { + const prisma = makePrisma({ lab: null }); + const at = await findSatisfyingEvent(prisma as never, "u1", { + ...base, + measurementType: null, + }); + expect(at).toBeNull(); + }); +}); diff --git a/src/lib/measurement-reminders/__tests__/satisfy.test.ts b/src/lib/measurement-reminders/__tests__/satisfy.test.ts new file mode 100644 index 000000000..8fdcec82f --- /dev/null +++ b/src/lib/measurement-reminders/__tests__/satisfy.test.ts @@ -0,0 +1,117 @@ +/** + * v1.18.1 — the shared `satisfyReminder` primitive: forward-only guard + + * re-anchored reschedule. The cron, the manual satisfy route, and the + * eventful worker all route through it, so these tests pin the invariants + * every caller relies on. + */ +import { describe, expect, it, vi } from "vitest"; + +import { satisfyReminder } from "../satisfy"; + +const TZ = "Europe/Berlin"; + +function makePrisma() { + const updates: Array<{ id: string; data: Record }> = []; + const prisma = { + measurementReminder: { + update: vi.fn( + async ({ + where, + data, + }: { + where: { id: string }; + data: Record; + }) => { + updates.push({ id: where.id, data }); + return { id: where.id, ...data }; + }, + ), + }, + }; + return { prisma, updates }; +} + +function reminder(overrides: Partial[1]> = {}) { + return { + id: "r1", + intervalDays: 7, + rrule: null, + anchorDate: null, + notifyHour: 9, + lastSatisfiedAt: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + ...overrides, + }; +} + +describe("satisfyReminder", () => { + it("stamps lastSatisfiedAt + recomputes nextDueAt when never satisfied", async () => { + const { prisma, updates } = makePrisma(); + const at = new Date("2026-06-14T18:00:00Z"); + + const result = await satisfyReminder( + prisma as never, + reminder(), + TZ, + at, + ); + + expect(result.satisfied).toBe(true); + expect(result.nextDueAt).toBeInstanceOf(Date); + expect(updates).toHaveLength(1); + expect(updates[0].data.lastSatisfiedAt).toEqual(at); + // Rolling +7d from the satisfy instant — strictly in the future. + expect((updates[0].data.nextDueAt as Date).getTime()).toBeGreaterThan( + at.getTime(), + ); + }); + + it("advances when the event is strictly after the existing lastSatisfiedAt", async () => { + const { prisma, updates } = makePrisma(); + const prev = new Date("2026-06-10T08:00:00Z"); + const at = new Date("2026-06-17T08:00:00Z"); + + const result = await satisfyReminder( + prisma as never, + reminder({ lastSatisfiedAt: prev }), + TZ, + at, + ); + + expect(result.satisfied).toBe(true); + expect(updates[0].data.lastSatisfiedAt).toEqual(at); + }); + + it("is a forward-only no-op when the event is older than lastSatisfiedAt", async () => { + const { prisma, updates } = makePrisma(); + const prev = new Date("2026-06-17T08:00:00Z"); + const stale = new Date("2026-06-10T08:00:00Z"); + + const result = await satisfyReminder( + prisma as never, + reminder({ lastSatisfiedAt: prev }), + TZ, + stale, + ); + + expect(result.satisfied).toBe(false); + expect(result.nextDueAt).toBeNull(); + expect(prisma.measurementReminder.update).not.toHaveBeenCalled(); + expect(updates).toHaveLength(0); + }); + + it("is a no-op when the event equals lastSatisfiedAt (cron behind an applied hook)", async () => { + const { prisma } = makePrisma(); + const same = new Date("2026-06-17T08:00:00Z"); + + const result = await satisfyReminder( + prisma as never, + reminder({ lastSatisfiedAt: same }), + TZ, + same, + ); + + expect(result.satisfied).toBe(false); + expect(prisma.measurementReminder.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/measurement-reminders/resolve.ts b/src/lib/measurement-reminders/resolve.ts new file mode 100644 index 000000000..cc04cc7f3 --- /dev/null +++ b/src/lib/measurement-reminders/resolve.ts @@ -0,0 +1,83 @@ +/** + * v1.18.1 — the shared "did a satisfying event land?" matcher for the + * Vorsorge engine. + * + * One reminder, one question: since the last satisfy (or the anchor when + * never satisfied), did a reading land that fulfils this reminder's + * cadence? Both the 15-min cron and the eventful ingest-driven worker call + * this, so the suppression logic lives in exactly one place. + * + * Two satisfaction sources: + * + * 1. **Typed reminder** (`measurementType` non-NULL): a non-deleted + * `Measurement` of that type measured after the floor. BP matches on + * `BLOOD_PRESSURE_SYS` (the SYS sentinel) so SYS + DIA don't + * double-count — the validation allow-list only ever stores SYS. + * 2. **Free-text reminder** (`measurementType` NULL): an "annual blood + * panel" / lab Vorsorge has no Measurement to match. v1.18.1 closes + * the clinical loop (gap D2): a non-deleted `LabResult` taken after + * the floor satisfies it. Without this a lab reminder could only ever + * resolve on a manual "Erledigt". + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +/** The reminder fields the matcher reads. */ +export interface ResolvableReminder { + measurementType: import("@/generated/prisma/client").MeasurementType | null; + anchorDate: Date | null; + lastSatisfiedAt: Date | null; + createdAt: Date; +} + +/** + * The floor a satisfying event must land strictly after: the last satisfy, + * else the anchor, else the create instant. A reading inside the current + * due cycle means the user already measured. + */ +export function satisfactionFloor(reminder: ResolvableReminder): Date { + return reminder.lastSatisfiedAt ?? reminder.anchorDate ?? reminder.createdAt; +} + +/** + * Find the most recent satisfying event instant for a reminder, or `null` + * when none has landed since the floor. + * + * A typed reminder resolves from a matching `Measurement`; a free-text + * reminder resolves from any `LabResult` (the Lab↔Vorsorge link, D2). + */ +export async function findSatisfyingEvent( + prisma: PrismaClient, + userId: string, + reminder: ResolvableReminder, +): Promise { + const floor = satisfactionFloor(reminder); + + if (reminder.measurementType !== null) { + const match = await prisma.measurement.findFirst({ + where: { + userId, + type: reminder.measurementType, + deletedAt: null, + measuredAt: { gt: floor }, + }, + orderBy: { measuredAt: "desc" }, + select: { measuredAt: true }, + }); + return match?.measuredAt ?? null; + } + + // Free-text / checklist reminder → a lab panel fulfils it. Match on + // `takenAt` (the sample instant), mirroring the measurement path's + // `measuredAt`. Any analyte counts: a "Großes Blutbild" reminder is + // satisfied the moment the user records any value from that panel. + const lab = await prisma.labResult.findFirst({ + where: { + userId, + deletedAt: null, + takenAt: { gt: floor }, + }, + orderBy: { takenAt: "desc" }, + select: { takenAt: true }, + }); + return lab?.takenAt ?? null; +} diff --git a/src/lib/measurement-reminders/satisfy.ts b/src/lib/measurement-reminders/satisfy.ts new file mode 100644 index 000000000..d0fd8ce43 --- /dev/null +++ b/src/lib/measurement-reminders/satisfy.ts @@ -0,0 +1,101 @@ +/** + * v1.18.1 — the ONE shared satisfaction primitive for the Vorsorge / + * measurement-reminder engine. + * + * Every path that marks a reminder's cadence fulfilled routes through + * `satisfyReminder`: the manual "Erledigt" route, the cron auto-resolve, + * and the eventful ingest-driven satisfaction worker. No duplicated + * reschedule logic — one place stamps `lastSatisfiedAt` and recomputes the + * server-authoritative `nextDueAt`. + * + * Two invariants the primitive owns: + * + * 1. **Forward-only.** `lastSatisfiedAt` only ever moves forward. A + * cron poll and an ingest enqueue can both fire for the same reading; + * the second is a no-op (returns `false`) rather than re-stamping an + * older instant. This is what makes the cron a safe idempotent + * safety-net behind the eventful hook. + * 2. **Re-anchored reschedule.** `nextDueAt` is recomputed from the + * satisfy instant via the canonical recurrence engine + * (`computeReminderNextDueAt`), so a freshly-satisfied reminder rolls + * strictly past the current due cycle and the self-tracker never gets + * nagged. + * + * Pure-ish: it reads the row fields it is handed and issues one Prisma + * update. The caller supplies the reminder row + the user's timezone. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; + +/** + * The reminder fields `satisfyReminder` needs. A subset of the Prisma row + * so callers (and tests) can construct it without the full model. + */ +export interface SatisfiableReminder { + id: string; + intervalDays: number | null; + rrule: string | null; + anchorDate: Date | null; + notifyHour: number; + lastSatisfiedAt: Date | null; + createdAt: Date; +} + +export interface SatisfyResult { + /** True when the reminder was advanced; false when the event was older + * than (or equal to) the existing `lastSatisfiedAt` (forward-only + * no-op). */ + satisfied: boolean; + /** The recomputed next-due instant when satisfied, else `null`. */ + nextDueAt: Date | null; +} + +/** + * Mark a reminder's cadence satisfied at `satisfiedAt` and reschedule. + * + * Forward-only: if `satisfiedAt` is not strictly after the existing + * `lastSatisfiedAt`, nothing is written and `{ satisfied: false }` is + * returned. Otherwise stamps `lastSatisfiedAt = satisfiedAt`, recomputes + * `nextDueAt` from the satisfy instant, persists both, and returns + * `{ satisfied: true, nextDueAt }`. + */ +export async function satisfyReminder( + prisma: PrismaClient, + reminder: SatisfiableReminder, + timezone: string, + satisfiedAt: Date, +): Promise { + // Forward-only guard. A null `lastSatisfiedAt` always advances. Equal + // instants are a no-op so a cron poll behind an already-applied ingest + // hook doesn't churn the row. + if ( + reminder.lastSatisfiedAt !== null && + satisfiedAt.getTime() <= reminder.lastSatisfiedAt.getTime() + ) { + return { satisfied: false, nextDueAt: null }; + } + + const scheduleInput: ReminderScheduleInput = { + intervalDays: reminder.intervalDays, + rrule: reminder.rrule, + anchorDate: reminder.anchorDate, + notifyHour: reminder.notifyHour, + lastSatisfiedAt: satisfiedAt, + createdAt: reminder.createdAt, + }; + const nextDueAt = computeReminderNextDueAt( + scheduleInput, + timezone, + satisfiedAt, + ); + + await prisma.measurementReminder.update({ + where: { id: reminder.id }, + data: { lastSatisfiedAt: satisfiedAt, nextDueAt }, + }); + + return { satisfied: true, nextDueAt }; +}