diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9485410cb..e349dc30a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7184,10 +7184,19 @@ components: - PULSE - BLOOD_GLUCOSE - OXYGEN_SATURATION - - BODY_FAT - BODY_TEMPERATURE - description: Auto-resolve target metric. BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' - sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy. + - BODY_FAT + - FAT_MASS + - FAT_FREE_MASS + - MUSCLE_MASS + - LEAN_BODY_MASS + - BONE_MASS + - TOTAL_BODY_WATER + - VISCERAL_FAT + - BODY_MASS_INDEX + description: Auto-resolve target metric (vitals + body-composition family). BP resolves on BLOOD_PRESSURE_SYS (the SYS + row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy + or a matching lab result. MeasurementReminderUpdate: type: object properties: diff --git a/messages/de.json b/messages/de.json index 35c813012..c8f36560c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Blutzucker", "OXYGEN_SATURATION": "Sauerstoffsättigung", "BODY_FAT": "Körperfett", - "BODY_TEMPERATURE": "Körpertemperatur" + "BODY_TEMPERATURE": "Körpertemperatur", + "FAT_MASS": "Fettmasse", + "FAT_FREE_MASS": "Fettfreie Masse", + "MUSCLE_MASS": "Muskelmasse", + "LEAN_BODY_MASS": "Magermasse", + "BONE_MASS": "Knochenmasse", + "TOTAL_BODY_WATER": "Körperwasser", + "VISCERAL_FAT": "Viszeralfett", + "BODY_MASS_INDEX": "Body-Mass-Index (BMI)" + }, + "typeGroups": { + "vitals": "Vitalwerte", + "bodyComposition": "Körperzusammensetzung" }, "empty": { "title": "Noch keine Erinnerungen", diff --git a/messages/en.json b/messages/en.json index ce511ebb8..7b5bbdced 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Blood glucose", "OXYGEN_SATURATION": "Oxygen saturation", "BODY_FAT": "Body fat", - "BODY_TEMPERATURE": "Body temperature" + "BODY_TEMPERATURE": "Body temperature", + "FAT_MASS": "Fat mass", + "FAT_FREE_MASS": "Fat-free mass", + "MUSCLE_MASS": "Muscle mass", + "LEAN_BODY_MASS": "Lean body mass", + "BONE_MASS": "Bone mass", + "TOTAL_BODY_WATER": "Body water", + "VISCERAL_FAT": "Visceral fat", + "BODY_MASS_INDEX": "Body mass index (BMI)" + }, + "typeGroups": { + "vitals": "Vitals", + "bodyComposition": "Body composition" }, "empty": { "title": "No reminders yet", diff --git a/messages/es.json b/messages/es.json index 083fee1bc..27d84b2a8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Glucosa en sangre", "OXYGEN_SATURATION": "Saturación de oxígeno", "BODY_FAT": "Grasa corporal", - "BODY_TEMPERATURE": "Temperatura corporal" + "BODY_TEMPERATURE": "Temperatura corporal", + "FAT_MASS": "Masa grasa", + "FAT_FREE_MASS": "Masa libre de grasa", + "MUSCLE_MASS": "Masa muscular", + "LEAN_BODY_MASS": "Masa magra", + "BONE_MASS": "Masa ósea", + "TOTAL_BODY_WATER": "Agua corporal", + "VISCERAL_FAT": "Grasa visceral", + "BODY_MASS_INDEX": "Índice de masa corporal (IMC)" + }, + "typeGroups": { + "vitals": "Constantes vitales", + "bodyComposition": "Composición corporal" }, "empty": { "title": "Aún no hay recordatorios", diff --git a/messages/fr.json b/messages/fr.json index 9b837a2c6..25c877947 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Glycémie", "OXYGEN_SATURATION": "Saturation en oxygène", "BODY_FAT": "Masse grasse", - "BODY_TEMPERATURE": "Température corporelle" + "BODY_TEMPERATURE": "Température corporelle", + "FAT_MASS": "Masse grasse", + "FAT_FREE_MASS": "Masse maigre totale", + "MUSCLE_MASS": "Masse musculaire", + "LEAN_BODY_MASS": "Masse maigre", + "BONE_MASS": "Masse osseuse", + "TOTAL_BODY_WATER": "Eau corporelle", + "VISCERAL_FAT": "Graisse viscérale", + "BODY_MASS_INDEX": "Indice de masse corporelle (IMC)" + }, + "typeGroups": { + "vitals": "Constantes vitales", + "bodyComposition": "Composition corporelle" }, "empty": { "title": "Aucun rappel pour l'instant", diff --git a/messages/it.json b/messages/it.json index 23305e7da..2f27bd97c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Glicemia", "OXYGEN_SATURATION": "Saturazione di ossigeno", "BODY_FAT": "Grasso corporeo", - "BODY_TEMPERATURE": "Temperatura corporea" + "BODY_TEMPERATURE": "Temperatura corporea", + "FAT_MASS": "Massa grassa", + "FAT_FREE_MASS": "Massa magra totale", + "MUSCLE_MASS": "Massa muscolare", + "LEAN_BODY_MASS": "Massa magra", + "BONE_MASS": "Massa ossea", + "TOTAL_BODY_WATER": "Acqua corporea", + "VISCERAL_FAT": "Grasso viscerale", + "BODY_MASS_INDEX": "Indice di massa corporea (IMC)" + }, + "typeGroups": { + "vitals": "Parametri vitali", + "bodyComposition": "Composizione corporea" }, "empty": { "title": "Ancora nessun promemoria", diff --git a/messages/pl.json b/messages/pl.json index 8e1ff3360..2a5354dbc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Glukoza we krwi", "OXYGEN_SATURATION": "Saturacja tlenem", "BODY_FAT": "Tkanka tłuszczowa", - "BODY_TEMPERATURE": "Temperatura ciała" + "BODY_TEMPERATURE": "Temperatura ciała", + "FAT_MASS": "Masa tłuszczowa", + "FAT_FREE_MASS": "Masa beztłuszczowa", + "MUSCLE_MASS": "Masa mięśniowa", + "LEAN_BODY_MASS": "Masa szczupła", + "BONE_MASS": "Masa kostna", + "TOTAL_BODY_WATER": "Woda w organizmie", + "VISCERAL_FAT": "Tłuszcz trzewny", + "BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)" + }, + "typeGroups": { + "vitals": "Parametry życiowe", + "bodyComposition": "Skład ciała" }, "empty": { "title": "Brak przypomnień", diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index a80ff3f41..2a8ec3902 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -11,6 +11,7 @@ import { import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; +import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; import { @@ -184,5 +185,10 @@ async function postLabResult(request: NextRequest) { meta: { labResultId: created.id }, }); + // v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just + // landed; resolve the user's free-text "annual blood panel" reminders + // now rather than waiting on the 15-min cron. Fire-and-forget. + void enqueueReminderSatisfy(user.id).catch(() => {}); + return apiSuccess(serialiseLabResult(created), 201); } 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/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 16aea90fe..849d78c5a 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -52,6 +52,7 @@ import { validateMeasurementRange } from "@/lib/validations/measurement"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; import { checkRateLimit } from "@/lib/rate-limit"; import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; +import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { @@ -599,6 +600,10 @@ async function postBatch(request: NextRequest): Promise { // personal record. if (insertedCount > 0 || updatedCount > 0 || duplicateCount > 0) { const silent = entries.length > PR_DETECTION_SILENT_THRESHOLD; + // v1.18.1 — eventful Vorsorge satisfaction. A matching reading just + // landed; resolve the user's reminders now rather than waiting on the + // 15-min cron. Fire-and-forget — the cron reconciles on enqueue miss. + void enqueueReminderSatisfy(user.id).catch(() => {}); try { await enqueuePrDetection(user.id, { silent }); await auditLog("personal_records.detection_enqueued", { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 4490aaa3d..57f78876c 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -29,6 +29,7 @@ import { import { withIdempotency } from "@/lib/idempotency"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { recomputeBucketsForMeasurement, collapseToTypeDayKeys, @@ -924,6 +925,10 @@ async function postMeasurement(request: NextRequest) { // SWR readers don't serve the pre-write body back to the user. invalidateUserMeasurements(user.id, { evict: true }); + // v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's + // reminders against the just-landed readings now. Fire-and-forget. + void enqueueReminderSatisfy(user.id).catch(() => {}); + // v1.5.0 — refresh the persistent rollup table for every distinct // (type, day) the batch touched so the next analytics / coach read // hits the cache rather than falling through to live aggregation. @@ -1037,6 +1042,10 @@ async function postMeasurement(request: NextRequest) { // pre-write body back to the user. invalidateUserMeasurements(user.id, { evict: true }); + // v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's reminders + // against the just-landed reading now. Fire-and-forget. + void enqueueReminderSatisfy(user.id).catch(() => {}); + // v1.5.0 — refresh the persistent rollup row for the affected // (type, day) tuple. Runs inline so the next read of the // comprehensive / analytics surface is correct without waiting on diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 43bd02fa6..2a9c85b7b 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -38,14 +38,37 @@ import { type MeasurementReminder, } from "@/hooks/use-measurement-reminders"; -const TYPE_OPTIONS = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "PULSE", - "BLOOD_GLUCOSE", - "OXYGEN_SATURATION", - "BODY_FAT", - "BODY_TEMPERATURE", +// v1.18.1 (V3) — the active-measurement set, grouped so the dropdown +// doesn't read as a flat 15-item list. Mirrors the Zod allow-list in +// `validations/measurement-reminders.ts`; the labels resolve through +// `measurementReminders.types.*`, the group headers through +// `measurementReminders.typeGroups.*`. +const TYPE_GROUPS = [ + { + group: "vitals", + types: [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "PULSE", + "BLOOD_GLUCOSE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + ], + }, + { + group: "bodyComposition", + types: [ + "BODY_FAT", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "BONE_MASS", + "TOTAL_BODY_WATER", + "VISCERAL_FAT", + "BODY_MASS_INDEX", + ], + }, ] as const; const INTERVAL_PRESETS = [7, 14, 30, 90, 180, 365] as const; @@ -220,10 +243,17 @@ export function VorsorgeSection({ value={measurementType} onChange={(e) => setMeasurementType(e.target.value)} > - {TYPE_OPTIONS.map((type) => ( - + {TYPE_GROUPS.map(({ group, types }) => ( + + {types.map((type) => ( + + ))} + ))} diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index c50525594..2c4c02059 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -672,6 +672,10 @@ export async function runFitbitPollCohort( concurrency?: number; sync?: (userId: string) => Promise; onUserError?: (userId: string, err: unknown) => void; + /** v1.18.1 — invoked after a user's sync lands `imported` measurements, + * so the worker can fire the eventful Vorsorge satisfaction enqueue + * without the fitbit lib reaching into the job layer. */ + onUserSynced?: (userId: string, imported: number) => void; } = {}, ): Promise<{ usersSynced: number; measurementsImported: number }> { const sync = opts.sync ?? ((userId: string) => syncUserFitbit(userId)); @@ -689,6 +693,7 @@ export async function runFitbitPollCohort( const n = await sync(userId); measurementsImported = measurementsImported + n; usersSynced = usersSynced + 1; + opts.onUserSynced?.(userId, n); } catch (err) { opts.onUserError?.(userId, err); } diff --git a/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts b/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts index e159e626f..8eb1ac19b 100644 --- a/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder-queue.test.ts @@ -58,3 +58,25 @@ describe("reminder-worker — measurement-reminder wiring", () => { ); }); }); + +describe("reminder-worker — eventful reminder-satisfy wiring (v1.18.1)", () => { + it("imports the satisfy queue constants", () => { + expect(source).toMatch(/from\s*["']@\/lib\/jobs\/reminder-satisfy["']/); + expect(source).toMatch(/\bREMINDER_SATISFY_QUEUE\b/); + }); + + it("registers the satisfy queue in the allQueues loop", () => { + const allQueuesMatch = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); + expect(allQueuesMatch).not.toBeNull(); + expect(allQueuesMatch![1]).toMatch(/\bREMINDER_SATISFY_QUEUE\b/); + }); + + it("binds a boss.work handler for the satisfy queue", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,200}REMINDER_SATISFY_QUEUE[\s\S]{0,200}handleReminderSatisfy/, + ); + expect(source).toMatch( + /handleReminderSatisfy[\s\S]{0,600}runReminderSatisfyForUser/, + ); + }); +}); diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 22f3ad6fe..49dff77d3 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -126,6 +126,9 @@ interface FakeReminder { function makePrisma(opts: { reminders: FakeReminder[]; measurementMatch?: { measuredAt: Date } | null; + /** v1.18.1 (D2) — a free-text reminder now auto-resolves from a matching + * LabResult. Default `null` (no lab landed). */ + labMatch?: { takenAt: Date } | null; }) { const updates: Array<{ id: string; data: Record }> = []; const prisma = { @@ -147,6 +150,9 @@ function makePrisma(opts: { measurement: { findFirst: vi.fn(async () => opts.measurementMatch ?? null), }, + labResult: { + findFirst: vi.fn(async () => opts.labMatch ?? null), + }, }; return { prisma, updates }; } @@ -220,11 +226,13 @@ describe("runMeasurementReminderTick", () => { expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime()); }); - it("free-text reminder never auto-resolves (no measurement query match path)", async () => { + it("free-text reminder never queries Measurement (matches on LabResult instead)", async () => { const { prisma } = makePrisma({ reminders: [reminder({ measurementType: null })], - // Even if a reading existed, a free-text reminder must not query it. + // Even if a reading existed, a free-text reminder must not query it — + // it resolves from a LabResult (D2), and none landed here. measurementMatch: { measuredAt: new Date("2026-06-14T18:00:00Z") }, + labMatch: null, }); const dispatch = vi.fn(async () => OK); @@ -239,6 +247,29 @@ describe("runMeasurementReminderTick", () => { expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); }); + it("v1.18.1 (D2) — free-text reminder auto-resolves from a matching LabResult", async () => { + const takenAt = new Date("2026-06-14T18:00:00Z"); + const { prisma, updates } = makePrisma({ + reminders: [reminder({ measurementType: null })], + labMatch: { takenAt }, + }); + 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(); + expect(updates).toHaveLength(1); + expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt); + // A free-text reminder resolves from labs, never from measurements. + expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); + }); + it("suppresses the push under clientManaged but still advances nextDueAt", async () => { const { prisma, updates } = makePrisma({ reminders: [ diff --git a/src/lib/jobs/__tests__/reminder-satisfy.test.ts b/src/lib/jobs/__tests__/reminder-satisfy.test.ts new file mode 100644 index 000000000..5320c618b --- /dev/null +++ b/src/lib/jobs/__tests__/reminder-satisfy.test.ts @@ -0,0 +1,153 @@ +/** + * v1.18.1 — eventful Vorsorge satisfaction. + * + * 1. `enqueueReminderSatisfy` — a thin wrapper around the global boss, + * a silent no-op when no worker is attached (mirrors enqueuePrDetection). + * 2. `runReminderSatisfyForUser` — resolves a user's live reminders + * against their just-landed measurement / lab via the shared + * primitives, honouring the module toggle and the forward-only guard. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const sendMock = vi.fn(); +let bossInstance: { send: typeof sendMock } | null = { send: sendMock }; + +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: () => bossInstance, +})); + +import { enqueueReminderSatisfy, REMINDER_SATISFY_QUEUE } from "../reminder-satisfy"; +import { runReminderSatisfyForUser } from "../measurement-reminder"; + +describe("enqueueReminderSatisfy", () => { + beforeEach(() => { + sendMock.mockReset(); + bossInstance = { send: sendMock }; + }); + + it("sends a job carrying the userId to the satisfy queue", async () => { + await enqueueReminderSatisfy("u1"); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0]![0]).toBe(REMINDER_SATISFY_QUEUE); + expect(sendMock.mock.calls[0]![1]).toMatchObject({ userId: "u1" }); + }); + + it("is a silent no-op when no boss is attached (tests / web process)", async () => { + bossInstance = null; + await expect(enqueueReminderSatisfy("u1")).resolves.toBeUndefined(); + expect(sendMock).not.toHaveBeenCalled(); + }); +}); + +const TZ = "Europe/Berlin"; + +function makePrisma(opts: { + reminders: Array>; + measurement?: { measuredAt: Date } | null; + lab?: { takenAt: Date } | null; +}) { + const updates: Array<{ id: string; data: Record }> = []; + const prisma = { + user: { findUnique: vi.fn(async () => ({ timezone: TZ })) }, + 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.measurement ?? null) }, + labResult: { findFirst: vi.fn(async () => opts.lab ?? null) }, + }; + return { prisma, updates }; +} + +function reminderRow(overrides: Record = {}) { + return { + id: "r1", + measurementType: "WEIGHT", + intervalDays: 7, + rrule: null, + anchorDate: null, + notifyHour: 9, + lastSatisfiedAt: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + ...overrides, + }; +} + +describe("runReminderSatisfyForUser", () => { + it("satisfies a typed reminder eventfully when a matching reading landed", async () => { + const measuredAt = new Date("2026-06-14T18:00:00Z"); + const { prisma, updates } = makePrisma({ + reminders: [reminderRow()], + measurement: { measuredAt }, + }); + + const summary = await runReminderSatisfyForUser( + prisma as never, + "u1", + new Date("2026-06-14T20:00:00Z"), + { isModuleEnabled: vi.fn(async () => true) }, + ); + + expect(summary.satisfied).toBe(1); + expect(updates).toHaveLength(1); + expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt); + }); + + it("satisfies a free-text reminder from a LabResult (D2)", async () => { + const takenAt = new Date("2026-06-15T09:00:00Z"); + const { prisma, updates } = makePrisma({ + reminders: [reminderRow({ measurementType: null, intervalDays: 365 })], + lab: { takenAt }, + }); + + const summary = await runReminderSatisfyForUser( + prisma as never, + "u1", + new Date("2026-06-15T12:00:00Z"), + ); + + expect(summary.satisfied).toBe(1); + expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt); + }); + + it("produces no engine activity for a disabled module", async () => { + const { prisma, updates } = makePrisma({ + reminders: [reminderRow({ measurementType: "BLOOD_GLUCOSE" })], + measurement: { measuredAt: new Date("2026-06-14T18:00:00Z") }, + }); + const isModuleEnabled = vi.fn(async () => false); + + const summary = await runReminderSatisfyForUser( + prisma as never, + "u1", + new Date(), + { isModuleEnabled }, + ); + + expect(summary.skippedModuleDisabled).toBe(1); + expect(summary.satisfied).toBe(0); + expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose"); + expect(updates).toHaveLength(0); + // The matcher never runs for a gated reminder. + expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); + }); + + it("is forward-only: a stale event is a no-op (cron + hook converge)", async () => { + const { prisma, updates } = makePrisma({ + reminders: [ + reminderRow({ lastSatisfiedAt: new Date("2026-06-20T08:00:00Z") }), + ], + measurement: { measuredAt: new Date("2026-06-10T08:00:00Z") }, + }); + + const summary = await runReminderSatisfyForUser(prisma as never, "u1", new Date()); + + expect(summary.satisfied).toBe(0); + expect(updates).toHaveLength(0); + }); +}); 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/jobs/reminder-satisfy.ts b/src/lib/jobs/reminder-satisfy.ts new file mode 100644 index 000000000..b06e511cb --- /dev/null +++ b/src/lib/jobs/reminder-satisfy.ts @@ -0,0 +1,54 @@ +/** + * v1.18.1 — enqueue helper for the eventful Vorsorge-reminder satisfaction + * queue. Mirrors `pr-detection.ts`: the Next.js ingest routes import + * `enqueueReminderSatisfy` and never touch pg-boss directly, the worker + * binds the handler in `reminder-worker.ts`, and the 15-min + * measurement-reminder cron is the idempotent safety-net for any ingest + * path that forgets to enqueue (or any job that fails to land). + * + * Why a queue and not an inline call on the ingest path: the batch route's + * own comment warns against hooking the hot iOS batch-ingest path. The + * matcher re-queries the user's measurements/labs, so keep it off the + * request and out of the sync worker's tight loop — enqueue + * fire-and-forget, resolve in the worker. + */ +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; + +export const REMINDER_SATISFY_QUEUE = "reminder-satisfy"; + +/** Concurrency budget for the worker process. The job is read-heavy on the + * user's own reminders + a single indexed measurement/lab lookup per + * typed reminder, so a small fleet drains an ingest spike without + * crowding the reminder check or the daily insights workload on the same + * node. */ +export const REMINDER_SATISFY_CONCURRENCY = 5; + +export interface ReminderSatisfyPayload { + userId: string; + /** ISO timestamp — handy for debugging, never used for logic. */ + triggeredAt: string; +} + +/** + * Submit a reminder-satisfaction job for one user after a measurement / + * lab write. Best-effort: when no boss instance is attached (the route + * runs in a context without the worker — typically tests, scripts), the + * call is a silent no-op and the 15-min cron reconciles the user within + * one tick. + */ +export async function enqueueReminderSatisfy(userId: string): Promise { + const boss = getGlobalBoss(); + if (!boss) return; + const payload: ReminderSatisfyPayload = { + userId, + triggeredAt: new Date().toISOString(), + }; + await boss.send(REMINDER_SATISFY_QUEUE, payload, { + // Gentle retry — the resolver is read-heavy on the user's own data; a + // stampede of failed jobs against a briefly-flaky DB would compound the + // problem. The cron is the ultimate backstop anyway. + retryLimit: 3, + retryDelay: 30, + retryBackoff: true, + }); +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index db6b4d5b2..84fc7ce5e 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -42,6 +42,11 @@ import { PR_DETECTION_FALLBACK_CRON, type PrDetectionPayload, } from "@/lib/jobs/pr-detection"; +import { + REMINDER_SATISFY_QUEUE, + REMINDER_SATISFY_CONCURRENCY, + type ReminderSatisfyPayload, +} from "@/lib/jobs/reminder-satisfy"; import { MEDICATION_INVENTORY_EXPIRE_QUEUE, MEDICATION_INVENTORY_EXPIRE_CRON, @@ -250,6 +255,7 @@ import { handleMoodReminderCheck, handleCycleReminderCheck, handleMeasurementReminderCheck, + handleReminderSatisfy, } from "./reminder/mood-cycle-checks"; import { HostMetricSamplePayload, @@ -677,6 +683,11 @@ export async function startReminderWorker() { // schedule below silently no-ops (the v1.4.37 dead-queue class). TLS_PIN_MONITOR_QUEUE, PR_DETECTION_QUEUE, + // v1.18.1 — eventful Vorsorge satisfaction. No cron of its own (the + // 15-min measurement-reminder check is the safety-net); enqueued by the + // ingest paths. Must still be registered here or the worker binding + // below never provisions the queue (v1.4.37 dead-queue lesson). + REMINDER_SATISFY_QUEUE, MEDICATION_INVENTORY_EXPIRE_QUEUE, // v1.4.46 — hourly auto-skip pass for stale unmarked intakes. // Same pg-boss v12 createQueue contract as the other crons; without @@ -1281,6 +1292,14 @@ export async function startReminderWorker() { { localConcurrency: PR_DETECTION_CONCURRENCY }, handlePrDetection, ); + // v1.18.1 — eventful Vorsorge satisfaction. Resolves a user's reminders + // against their just-landed measurement / lab. Read-heavy on the user's + // own data; the same small concurrency budget as PR detection. + await boss.work( + REMINDER_SATISFY_QUEUE, + { localConcurrency: REMINDER_SATISFY_CONCURRENCY }, + handleReminderSatisfy, + ); await boss.work( MEDICATION_INVENTORY_EXPIRE_QUEUE, { localConcurrency: 1 }, diff --git a/src/lib/jobs/reminder/fitbit-sync.ts b/src/lib/jobs/reminder/fitbit-sync.ts index 53e6ae00d..b591bfcd9 100644 --- a/src/lib/jobs/reminder/fitbit-sync.ts +++ b/src/lib/jobs/reminder/fitbit-sync.ts @@ -8,6 +8,7 @@ import { type Job } from "pg-boss"; import { recordError } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; import { runFitbitPollCohort } from "@/lib/fitbit/sync"; +import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { cleanupExpiredFitbitOAuthStates } from "@/lib/jobs/fitbit-oauth-state-cleanup"; import { getWorkerPrisma } from "./shared"; @@ -45,6 +46,13 @@ export async function handleFitbitSync(jobs: Job[]) { { onUserError: (userId, err) => evt.addWarning(`job.fitbit_sync failed for user ${userId}: ${err}`), + // v1.18.1 — a fresh reading landed; resolve the user's Vorsorge + // reminders eventfully. Fire-and-forget; the cron is the net. + onUserSynced: (userId, imported) => { + if (imported > 0) { + void enqueueReminderSatisfy(userId).catch(() => {}); + } + }, }, ); diff --git a/src/lib/jobs/reminder/mood-cycle-checks.ts b/src/lib/jobs/reminder/mood-cycle-checks.ts index 655a5b2b3..b9cd2dbcd 100644 --- a/src/lib/jobs/reminder/mood-cycle-checks.ts +++ b/src/lib/jobs/reminder/mood-cycle-checks.ts @@ -9,7 +9,11 @@ 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 { + runMeasurementReminderTick, + runReminderSatisfyForUser, +} from "@/lib/jobs/measurement-reminder"; +import type { ReminderSatisfyPayload } from "@/lib/jobs/reminder-satisfy"; import { getWorkerPrisma } from "./shared"; export interface MoodReminderPayload { @@ -120,3 +124,45 @@ export async function handleMeasurementReminderCheck( } }); } + +/** + * v1.18.1 — eventful Vorsorge satisfaction. Enqueued by every measurement + * / lab ingest path (manual create, batch, device syncs) so a reminder + * auto-resolves the moment a matching reading lands rather than waiting up + * to 15 minutes for the cron. Batches one job per user; the resolver loads + * that user's live reminders and runs the shared satisfy primitive. + */ +export async function handleReminderSatisfy( + jobs: Job[], +) { + await withBackgroundEvent("job.reminder_satisfy", async (evt) => { + const prisma = getWorkerPrisma(); + const now = new Date(); + // Distinct users in this batch — a multi-source ingest spike can enqueue + // several jobs for one user; resolve each user once. + const userIds = Array.from( + new Set(jobs.map((j) => j.data?.userId).filter((id): id is string => !!id)), + ); + let satisfied = 0; + let candidates = 0; + try { + for (const userId of userIds) { + const summary = await runReminderSatisfyForUser(prisma, userId, now); + satisfied += summary.satisfied; + candidates += summary.candidatesScanned; + } + evt.setBackground({ + task_name: "job.reminder_satisfy", + result: { + users: userIds.length, + candidates_scanned: candidates, + satisfied, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} diff --git a/src/lib/jobs/reminder/poll-cohort.ts b/src/lib/jobs/reminder/poll-cohort.ts index e07bdcc18..a0e086aca 100644 --- a/src/lib/jobs/reminder/poll-cohort.ts +++ b/src/lib/jobs/reminder/poll-cohort.ts @@ -21,6 +21,7 @@ import { type Job } from "pg-boss"; import { recordError } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; +import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { getWorkerPrisma } from "./shared"; /** Every poll-cohort handler accepts an optional single-user payload. */ @@ -69,8 +70,14 @@ export function makePollCohortHandler({ let measurementsImported = 0; for (const { userId } of targets) { try { - measurementsImported += await syncUser(userId); + const imported = await syncUser(userId); + measurementsImported += imported; usersSynced++; + // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge + // reminders eventfully. Fire-and-forget; the cron is the net. + if (imported > 0) { + void enqueueReminderSatisfy(userId).catch(() => {}); + } } catch (err) { evt.addWarning(`${taskName} failed for user ${userId}: ${err}`); } diff --git a/src/lib/jobs/reminder/whoop-sync.ts b/src/lib/jobs/reminder/whoop-sync.ts index aab16721d..92a1e2ec3 100644 --- a/src/lib/jobs/reminder/whoop-sync.ts +++ b/src/lib/jobs/reminder/whoop-sync.ts @@ -20,6 +20,7 @@ import { syncUserWorkout, syncWhoopWorkoutById, } from "@/lib/whoop/sync-workout"; +import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { getWorkerPrisma } from "./shared"; /** @@ -91,11 +92,17 @@ export async function runWhoopResourceSync( let measurementsImported = 0; for (const { userId, resourceId } of targets) { try { - measurementsImported += + const imported = resourceId && byIdFn ? await byIdFn(userId, resourceId) : await syncFn(userId); + measurementsImported += imported; usersSynced++; + // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge + // reminders eventfully. Fire-and-forget; the cron is the net. + if (imported > 0) { + void enqueueReminderSatisfy(userId).catch(() => {}); + } } catch (err) { evt.addWarning(`${taskName} failed for user ${userId}: ${err}`); } diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts index 3360813e9..0ebf2d182 100644 --- a/src/lib/jobs/reminder/withings-sync.ts +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -10,6 +10,7 @@ import { withBackgroundEvent } from "@/lib/logging/background"; import { syncUserMeasurements } from "@/lib/withings/sync"; import { syncUserActivity } from "@/lib/withings/sync-activity"; import { syncUserSleep } from "@/lib/withings/sync-sleep"; +import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { getWorkerPrisma } from "./shared"; export interface WithingsSyncPayload { @@ -64,6 +65,11 @@ export async function handleWithingsFallbackSync( const imported = await syncUserMeasurements(connection.userId); usersSynced++; measurementsImported += imported; + // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge + // reminders eventfully. Fire-and-forget; the cron is the net. + if (imported > 0) { + void enqueueReminderSatisfy(connection.userId).catch(() => {}); + } } catch (err) { evt.addWarning( `Fallback sync failed for user ${connection.userId}: ${err}`, @@ -131,6 +137,9 @@ export async function handleWithingsActivitySync( const imported = await syncUserActivity(userId); usersSynced++; measurementsImported += imported; + if (imported > 0) { + void enqueueReminderSatisfy(userId).catch(() => {}); + } } catch (err) { evt.addWarning( `Withings activity sync failed for user ${userId}: ${err}`, @@ -186,6 +195,9 @@ export async function handleWithingsSleepSync( const imported = await syncUserSleep(userId); usersSynced++; measurementsImported += imported; + if (imported > 0) { + void enqueueReminderSatisfy(userId).catch(() => {}); + } } catch (err) { evt.addWarning( `Withings sleep sync failed for user ${userId}: ${err}`, 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 }; +} diff --git a/src/lib/validations/__tests__/measurement-reminders.test.ts b/src/lib/validations/__tests__/measurement-reminders.test.ts new file mode 100644 index 000000000..219e395ee --- /dev/null +++ b/src/lib/validations/__tests__/measurement-reminders.test.ts @@ -0,0 +1,77 @@ +/** + * v1.18.1 (V3) — the Vorsorge measurement-type allow-list. Widened from + * the original 7 to the ~15 active-measurement set (vitals + the + * body-composition family). App-layer only — the DB column is the full + * MeasurementType enum, so this is the gate, not a migration. + */ +import { describe, expect, it } from "vitest"; + +import { + measurementReminderTypeEnum, + createMeasurementReminderSchema, +} from "../measurement-reminders"; + +describe("measurementReminderTypeEnum (V3 — completeness)", () => { + const allowed = measurementReminderTypeEnum.options; + + it("exposes the full active set: vitals + body composition", () => { + expect(new Set(allowed)).toEqual( + new Set([ + // Vitals + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "PULSE", + "BLOOD_GLUCOSE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + // Body composition + "BODY_FAT", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "BONE_MASS", + "TOTAL_BODY_WATER", + "VISCERAL_FAT", + "BODY_MASS_INDEX", + ]), + ); + }); + + it("accepts a newly-added body-composition type on create", () => { + const parsed = createMeasurementReminderSchema.safeParse({ + label: "Körperzusammensetzung messen", + measurementType: "MUSCLE_MASS", + intervalDays: 7, + }); + expect(parsed.success).toBe(true); + }); + + it("matches BP on the SYS sentinel only (DIA would double-count)", () => { + expect(allowed).toContain("BLOOD_PRESSURE_SYS"); + expect(allowed).not.toContain("BLOOD_PRESSURE_DIA"); + }); + + it("deliberately excludes passive / cumulative / event types", () => { + for (const excluded of [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "VO2_MAX", + "ACTIVITY_STEPS", + "RECOVERY_SCORE", + "SLEEP_DURATION", + "IRREGULAR_RHYTHM_NOTIFICATION", + ]) { + expect(allowed).not.toContain(excluded); + } + }); + + it("still accepts a free-text reminder (null type) resolving on satisfy / lab", () => { + const parsed = createMeasurementReminderSchema.safeParse({ + label: "Großes Blutbild", + measurementType: null, + rrule: "FREQ=YEARLY", + }); + expect(parsed.success).toBe(true); + }); +}); diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index fe295c189..e8fa7c5a9 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -17,28 +17,50 @@ import { z } from "zod/v4"; /** * The auto-resolve target types. Kept as an explicit allow-list rather * than the full `MeasurementType` enum: a Vorsorge reminder only makes - * sense for the metrics a user actively measures on a cadence (BP, - * weight, glucose, pulse, SpO2, body composition, body temperature). - * Free-text reminders pass `null` and resolve only on a manual satisfy. + * sense for the metrics a user actively goes and measures on a cadence — + * vitals + the body-composition family a scale produces. + * + * v1.18.1 (V3) widens the original 7 to the full ~15 active-measurement + * set. The DB column is the full `MeasurementType` enum, so this is a + * pure application-layer change — no migration. + * + * DELIBERATELY excluded: passive wearable scores + cumulative activity + + * device events (RECOVERY_SCORE, ACTIVITY_STEPS, IRREGULAR_RHYTHM_*, …), + * and the passive nightly samples RESTING_HEART_RATE / HEART_RATE_VARIABILITY + * / VO2_MAX — nudging a user to "go measure" a passive sample makes no + * sense (they still auto-satisfy any free-text reminder via a LabResult if + * ever wanted, but they are not "remind me to measure" targets). * * BP is matched on `BLOOD_PRESSURE_SYS` as the canonical sentinel — a BP * reading is two rows (SYS + DIA) and matching either would double-count; - * SYS is the agreed "a BP was measured" anchor. + * SYS is the agreed "a BP was measured" anchor. A single step-on-the-scale + * event writes WEIGHT + several body-composition rows at once, so any one + * of them satisfies its linked reminder. */ export const measurementReminderTypeEnum = z .enum([ + // Vitals "WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE", "BLOOD_GLUCOSE", "OXYGEN_SATURATION", - "BODY_FAT", "BODY_TEMPERATURE", + // Body composition (one scale reading produces the whole family) + "BODY_FAT", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "BONE_MASS", + "TOTAL_BODY_WATER", + "VISCERAL_FAT", + "BODY_MASS_INDEX", ]) .meta({ id: "MeasurementReminderType", description: - "Auto-resolve target metric. BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy.", + "Auto-resolve target metric (vitals + body-composition family). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.", }); export type MeasurementReminderType = z.infer<