From f92247760cab1ff6392ad21bc5d7569998d29159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 20:58:46 +0200 Subject: [PATCH 1/3] feat(vorsorge): one satisfaction primitive + lab auto-resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract a single satisfyReminder() that every path uses to mark a reminder's cadence fulfilled: stamp lastSatisfiedAt (forward-only) and recompute the server-authoritative nextDueAt. The cron auto-resolve and the manual satisfy route now route through it instead of duplicating the reschedule. Add findSatisfyingEvent(): a typed reminder resolves from a matching Measurement, a free-text reminder from any LabResult. This closes the Lab<->Vorsorge loop — an annual-blood-panel reminder is now satisfied when a lab result lands, not only on a manual check-off. --- .../[id]/satisfy/route.ts | 22 +-- src/lib/jobs/measurement-reminder.ts | 174 ++++++++++++------ .../__tests__/resolve.test.ts | 112 +++++++++++ .../__tests__/satisfy.test.ts | 117 ++++++++++++ src/lib/measurement-reminders/resolve.ts | 83 +++++++++ src/lib/measurement-reminders/satisfy.ts | 101 ++++++++++ 6 files changed, 542 insertions(+), 67 deletions(-) create mode 100644 src/lib/measurement-reminders/__tests__/resolve.test.ts create mode 100644 src/lib/measurement-reminders/__tests__/satisfy.test.ts create mode 100644 src/lib/measurement-reminders/resolve.ts create mode 100644 src/lib/measurement-reminders/satisfy.ts 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 }; +} From 7b57c1d3d84f3223d427a345374fddcdda3468e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 20:58:56 +0200 Subject: [PATCH 2/3] feat(vorsorge): resolve reminders on ingest, not only on the cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reminder-satisfy pg-boss queue (mirrors pr-detection) that every measurement and lab ingest path enqueues — manual create, batch, and the Withings / Polar / Oura / Nightscout / Fitbit / WHOOP syncs. A reminder now auto-resolves the moment a matching reading lands instead of waiting up to 15 minutes. The 15-minute cron stays as the idempotent safety net; the forward-only guard lets the eventful hook and the cron converge without double-stamping. The worker honours the module toggle (a reminder for a disabled module produces no engine activity) and runs the same satisfaction primitives the cron uses. --- src/app/api/labs/route.ts | 6 + src/app/api/measurements/batch/route.ts | 5 + src/app/api/measurements/route.ts | 9 ++ src/lib/fitbit/sync.ts | 5 + .../measurement-reminder-queue.test.ts | 22 +++ .../__tests__/measurement-reminder.test.ts | 35 +++- .../jobs/__tests__/reminder-satisfy.test.ts | 153 ++++++++++++++++++ src/lib/jobs/reminder-satisfy.ts | 54 +++++++ src/lib/jobs/reminder-worker.ts | 19 +++ src/lib/jobs/reminder/fitbit-sync.ts | 8 + src/lib/jobs/reminder/mood-cycle-checks.ts | 48 +++++- src/lib/jobs/reminder/poll-cohort.ts | 9 +- src/lib/jobs/reminder/whoop-sync.ts | 9 +- src/lib/jobs/reminder/withings-sync.ts | 12 ++ 14 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 src/lib/jobs/__tests__/reminder-satisfy.test.ts create mode 100644 src/lib/jobs/reminder-satisfy.ts 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/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/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/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}`, From a0d864db8ccca7668f0f66117e1c15f42a73550d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 20:59:06 +0200 Subject: [PATCH 3/3] feat(vorsorge): widen the linkable measurement set to the body-comp family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reminder type allow-list exposed 7 of the full MeasurementType enum. Widen it to the ~15 active-measurement set: vitals (weight, BP, pulse, glucose, SpO2, temperature) plus the body-composition family a scale produces (fat mass, fat-free mass, muscle, lean body mass, bone mass, body water, visceral fat, BMI). One step on the scale satisfies every linked reminder at once. Passive scores, cumulative activity, and device events stay excluded — nudging someone to 'go measure' a passive sample makes no sense. App-layer only: the column is the full enum, so no migration. Group the dropdown (Vitals / Body composition) and add the labels across all six locales. --- docs/api/openapi.yaml | 15 +++- messages/de.json | 14 +++- messages/en.json | 14 +++- messages/es.json | 14 +++- messages/fr.json | 14 +++- messages/it.json | 14 +++- messages/pl.json | 14 +++- .../vorsorge-section.tsx | 54 ++++++++++--- .../__tests__/measurement-reminders.test.ts | 77 +++++++++++++++++++ src/lib/validations/measurement-reminders.ts | 34 ++++++-- 10 files changed, 237 insertions(+), 27 deletions(-) create mode 100644 src/lib/validations/__tests__/measurement-reminders.test.ts 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/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/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<