From 97ddf2da79d862ae83b7878b23bedeb685e91195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 19:22:25 +0200 Subject: [PATCH 01/48] build(deps): override hono and picomatch to clear transitive advisories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin hono to >=4.12.25 (GHSA-88fw-hqm2-52qc, CORS middleware reflects any Origin) and picomatch to >=4.0.4 (CVE-2026-33671, ReDoS) through pnpm overrides scoped to the vulnerable ranges. Both are build- and dev-tooling transitives — hono via Prisma's dev tooling and the shadcn CLI, picomatch via the build chain — and neither is on a runtime path: the app is Next.js and sets no CORS headers. The prod audit drops from one high to zero. --- package.json | 6 +++++- pnpm-lock.yaml | 46 +++++++++++++++++++--------------------------- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 2058f15ce..becd669dc 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,10 @@ "onlyBuiltDependencies": [ "prisma", "@prisma/engines" - ] + ], + "overrides": { + "hono@<4.12.25": "^4.12.25", + "picomatch@<4.0.4": "^4.0.4" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36bc054e3..15afc4d70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + hono@<4.12.25: ^4.12.25 + picomatch@<4.0.4: ^4.0.4 + importers: .: @@ -866,13 +870,13 @@ packages: resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: ^4.12.25 '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: ^4.12.25 '@hookform/resolvers@5.4.0': resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} @@ -4171,7 +4175,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: ^4.0.4 peerDependenciesMeta: picomatch: optional: true @@ -4412,12 +4416,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.15: - resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==} - engines: {node: '>=16.9.0'} - - hono@4.12.23: - resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -5444,10 +5444,6 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -7516,13 +7512,13 @@ snapshots: '@hexagon/base64@1.1.28': {} - '@hono/node-server@1.19.11(hono@4.12.15)': + '@hono/node-server@1.19.11(hono@4.12.25)': dependencies: - hono: 4.12.15 + hono: 4.12.25 - '@hono/node-server@1.19.14(hono@4.12.23)': + '@hono/node-server@1.19.14(hono@4.12.25)': dependencies: - hono: 4.12.23 + hono: 4.12.25 '@hookform/resolvers@5.4.0(react-hook-form@7.78.0(react@19.2.7))': dependencies: @@ -7706,7 +7702,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.23) + '@hono/node-server': 1.19.14(hono@4.12.25) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -7716,7 +7712,7 @@ snapshots: eventsource-parser: 3.1.0 express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.23 + hono: 4.12.25 jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -8202,13 +8198,13 @@ snapshots: '@electric-sql/pglite': 0.4.1 '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) - '@hono/node-server': 1.19.11(hono@4.12.15) + '@hono/node-server': 1.19.11(hono@4.12.25) '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 '@prisma/streams-local': 0.1.2 foreground-child: 3.3.1 get-port-please: 3.2.0 - hono: 4.12.15 + hono: 4.12.25 http-status-codes: 2.3.0 pathe: 2.0.3 proper-lockfile: 4.1.2 @@ -11132,9 +11128,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.15: {} - - hono@4.12.23: {} + hono@4.12.25: {} html-escaper@2.0.2: {} @@ -11689,7 +11683,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.2 + picomatch: 4.0.4 mime-db@1.54.0: {} @@ -12131,8 +12125,6 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.2: {} - picomatch@4.0.4: {} pkce-challenge@5.0.1: {} From e1e3cb6364e30cb2f55442bc495a0a0a29dc39fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 19:22:33 +0200 Subject: [PATCH 02/48] chore(release): v1.18.1 Maintenance patch clearing two transitive security advisories. Bump the package version, record the changelog entry, and regenerate the OpenAPI version line. No functional changes. --- CHANGELOG.md | 9 +++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c749534..332e4ad4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +## [1.18.1] — 2026-06-16 — dependency security bumps + +A maintenance patch that clears two freshly-disclosed transitive advisories. Neither reaches the running app — both live in build and development tooling — but the audit is now clean. No functional changes. + +### Security + +- Pin `hono` to `≥ 4.12.25` through an override, clearing the CORS-middleware advisory (GHSA-88fw-hqm2-52qc). The package is pulled in only by Prisma's and the shadcn CLI's tooling; the app is Next.js and sets no CORS headers anywhere, so the issue was never reachable at runtime. +- Pin `picomatch` to `≥ 4.0.4` through an override, clearing the ReDoS advisory (CVE-2026-33671). The package is a transitive build dependency. + ## [1.18.0] — 2026-06-16 — turn off what you don't track, and a settings menu that finally reads as one app This release makes the app yours to shape. Every optional area — cycle, mood, sleep, glucose, workouts, recovery, labs, achievements, the coach, AI insights and the doctor-report — can be turned off, and when it is, it disappears everywhere at once: out of the navigation, off the dashboard, gone from insights, the coach, your reminders, achievements and the exported report. Your data is kept; turn the area back on and it returns. The settings menu was redesigned alongside it, so notification channels, integrations, sources and per-area pages each sit where you'd look for them instead of spread across overlapping hubs. The core vitals — weight, blood pressure, pulse and medications — are always on. No breaking changes. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index af8ba7573..9485410cb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.18.0 + version: 1.18.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index becd669dc..7482279c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.18.0", + "version": "1.18.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", From 016eb12b42e683c9186abab7c82ee9bab15d6978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 20:36:13 +0200 Subject: [PATCH 03/48] feat(schema): add the v1.18.1 data foundation Three additive migrations the v1.18.1 feature surfaces build on: reminder engine fields (endsOn + origin, so coach-cadence and preventive care produce into one reminder table), a user-scoped biomarker catalog (name, unit, bounds, optional encrypted context, panel) with a nullable LabResult.biomarkerId link, and the condition-journal model (IllnessEpisode plus a CycleDayLog-shaped symptom timeline). No code consumes these yet; the surfaces ship gated behind the module system. --- .../migration.sql | 47 ++++ .../migration.sql | 80 ++++++ .../0171_v1181_illness_episode/migration.sql | 203 +++++++++++++++ prisma/schema.prisma | 245 +++++++++++++++++- 4 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0169_v1181_reminder_engine_fields/migration.sql create mode 100644 prisma/migrations/0170_v1181_biomarker_catalog/migration.sql create mode 100644 prisma/migrations/0171_v1181_illness_episode/migration.sql diff --git a/prisma/migrations/0169_v1181_reminder_engine_fields/migration.sql b/prisma/migrations/0169_v1181_reminder_engine_fields/migration.sql new file mode 100644 index 000000000..d33de4079 --- /dev/null +++ b/prisma/migrations/0169_v1181_reminder_engine_fields/migration.sql @@ -0,0 +1,47 @@ +-- v1.19.0 — reminder-engine fields for the "one engine" unification. +-- +-- Two additive columns on `measurement_reminders` so the AI coach can +-- produce cadence reminders into the SAME table the Vorsorge surface uses +-- (one model, one recurrence helper, one cron, one create endpoint — not a +-- second "coach reminder" table): +-- +-- * `ends_on` (nullable timestamp) — an optional course-window end. NULL = +-- open-ended cadence (the existing behaviour, every current row). Non-NULL +-- bounds a finite cadence so a Coach "measure BP morning+evening for a +-- week" self-expires: the recurrence engine stops producing occurrences +-- past this instant (the `medication.ends_on` precedent the engine already +-- supports). +-- +-- * `origin` (enum `reminder_origin`, default `VORSORGE`) — provenance. +-- `VORSORGE` = the user created it through the Vorsorge surface (the +-- existing behaviour; backfilled onto every current row by the column +-- default). `COACH` = the coach minted it from an evidence-based cadence +-- suggestion. Lets the UI label provenance and lets Coach dedupe its own +-- suggestions. +-- +-- No backfill needed: `ends_on` defaults NULL (open-ended) and `origin` +-- defaults `VORSORGE`, so every existing reminder keeps its current +-- behaviour. No new index — the cron already rides `(user_id, next_due_at)` +-- and these two columns are read alongside the row, never scanned on. +-- +-- Idempotent guards (`IF NOT EXISTS` / `DO $$`) so reruns are safe on prod. +-- +-- Reversibility (down): +-- ALTER TABLE "measurement_reminders" DROP COLUMN IF EXISTS "origin"; +-- ALTER TABLE "measurement_reminders" DROP COLUMN IF EXISTS "ends_on"; +-- DROP TYPE IF EXISTS "reminder_origin"; +-- A roll-back loses the coach/vorsorge distinction + any course-window end +-- (every reminder falls back to open-ended user-created), which is the safe +-- default — no data is destroyed. + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "reminder_origin" AS ENUM ('VORSORGE', 'COACH'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AlterTable +ALTER TABLE "measurement_reminders" + ADD COLUMN IF NOT EXISTS "ends_on" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "origin" "reminder_origin" NOT NULL DEFAULT 'VORSORGE'; diff --git a/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql b/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql new file mode 100644 index 000000000..e7ebc3553 --- /dev/null +++ b/prisma/migrations/0170_v1181_biomarker_catalog/migration.sql @@ -0,0 +1,80 @@ +-- v1.19.0 — user-scoped biomarker catalog. +-- +-- The lean v1.17.1 `lab_results` row stores analyte / unit / reference_low / +-- reference_high on EVERY row, so recording the same marker three times means +-- re-typing the name + unit + both bounds three times — each an opportunity +-- to fork the "same" biomarker into "LDL" / "ldl" / "LDL-C" or mistype +-- `mg/dL` as `mg/dl`. This adds a per-user catalog that defines a marker ONCE +-- (canonical name, unit, reference bounds, optional context note); readings +-- link to it and resolve their range/unit from the catalog row. +-- +-- * `biomarkers` table — user-scoped, unique per (user_id, name) so there +-- is never two "LDL" definitions. `context_encrypted` is a Bytes column +-- (the AES-256-GCM `*Encrypted` convention). `lower_bound` / `upper_bound` +-- are the optional reference window; `panel` is the optional grouping +-- moved off the per-entry row. +-- +-- * `lab_results.biomarker_id` (nullable FK) — every existing row stays +-- NULL. A pg-boss backfill job (not a tsx CLI per the standalone-image +-- rule) will later GROUP BY lower(analyte) per user, mint one Biomarker +-- per group, and link the rows. This migration only adds the column + +-- relation + supporting index; no row is touched, no data destroyed. +-- ON DELETE SET NULL: deleting a catalog marker orphans its readings +-- (they keep their legacy analyte/unit/reference columns) rather than +-- cascading the historical values away. +-- +-- Additive throughout; no backfill in the migration itself. +-- +-- Idempotent guards (`IF NOT EXISTS`) so reruns are safe on prod. The FK is +-- added under a DO-block existence guard for the same reason. +-- +-- Reversibility (down): +-- ALTER TABLE "lab_results" DROP CONSTRAINT IF EXISTS "lab_results_biomarker_id_fkey"; +-- DROP INDEX IF EXISTS "lab_results_biomarker_id_idx"; +-- ALTER TABLE "lab_results" DROP COLUMN IF EXISTS "biomarker_id"; +-- DROP TABLE IF EXISTS "biomarkers"; +-- A roll-back drops the catalog + un-links readings (which keep their legacy +-- per-row analyte/unit/reference values), the safe default — no reading lost. + +-- CreateTable +CREATE TABLE IF NOT EXISTS "biomarkers" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "unit" TEXT NOT NULL, + "lower_bound" DOUBLE PRECISION, + "upper_bound" DOUBLE PRECISION, + "context_encrypted" BYTEA, + "panel" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "biomarkers_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "biomarkers_user_id_name_key" ON "biomarkers"("user_id", "name"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "biomarkers_user_id_idx" ON "biomarkers"("user_id"); + +-- AlterTable +ALTER TABLE "lab_results" + ADD COLUMN IF NOT EXISTS "biomarker_id" TEXT; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "lab_results_biomarker_id_idx" ON "lab_results"("biomarker_id"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "biomarkers" ADD CONSTRAINT "biomarkers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "lab_results" ADD CONSTRAINT "lab_results_biomarker_id_fkey" FOREIGN KEY ("biomarker_id") REFERENCES "biomarkers"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/prisma/migrations/0171_v1181_illness_episode/migration.sql b/prisma/migrations/0171_v1181_illness_episode/migration.sql new file mode 100644 index 000000000..60ec415ee --- /dev/null +++ b/prisma/migrations/0171_v1181_illness_episode/migration.sql @@ -0,0 +1,203 @@ +-- v1.19.0 — illness / condition journal. +-- +-- A CONDITION journal (not a flu log): any illness/condition kept for +-- oneself, with a retrospective lens. Retrospective + correlated, NEVER a +-- predictor/diagnoser. Born-gated by the module system — the gate is +-- app-layer, so there is no enable flag in the schema. +-- +-- Tables (the daily timeline mirrors the Cycle three-table shape): +-- +-- * `illness_episodes` — one episode. `illness_type` widens beyond an +-- infection log (infection / allergy / injury / mental-health / +-- autoimmune / chronic / other). `illness_lifecycle` +-- (ACUTE / CHRONIC_ONGOING / RECURRING / FLARE) distinguishes a bout from +-- an ongoing chronic condition; CHRONIC_ONGOING carries no resolved_at +-- and is excluded from the recovery-gap math. `parent_condition_id` is a +-- self-FK (ON DELETE SET NULL) so a flare/recurrence threads under one +-- parent condition. `note_encrypted` is a Bytes column (the AES-256-GCM +-- `*Encrypted` convention). Soft-deleted. +-- +-- * `illness_day_logs` — one row per day of an episode (mirrors +-- cycle_day_logs): a coarse 0–3 `functional_impact` slider, an optional +-- `fever_c` reading (queryable plaintext, the basal_body_temp_c +-- precedent), an encrypted day note, the day's symptom links. Unique per +-- (episode_id, date). Soft-deleted. +-- +-- * `illness_symptoms` — a seeded, icon-bearing, labelKey-i18n catalog +-- (Jackson / WURSS-derived; the cycle_symptoms precedent). Eight +-- high-signal cold/illness symptoms, scored 0–3 on the link. +-- +-- * `illness_symptom_links` — the per-day-log join, composite PK +-- (day_log_id, symptom_id), 0–3 severity, cascade on both sides. +-- +-- Additive; no existing row touched. The catalog seed uses deterministic +-- ids + ON CONFLICT ("key") DO NOTHING so re-running is a no-op (the +-- cycle/mood-tag seed precedent). Keep in sync with the app-side taxonomy +-- module if either changes. +-- +-- Idempotent guards (`IF NOT EXISTS` / `DO $$`) so reruns are safe on prod. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "illness_symptom_links"; +-- DROP TABLE IF EXISTS "illness_symptoms"; +-- DROP TABLE IF EXISTS "illness_day_logs"; +-- DROP TABLE IF EXISTS "illness_episodes"; +-- DROP TYPE IF EXISTS "illness_lifecycle"; +-- DROP TYPE IF EXISTS "illness_type"; +-- A roll-back drops the journal wholesale — no other domain depends on it. + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "illness_type" AS ENUM ('INFECTION', 'ALLERGY', 'INJURY', 'MENTAL_HEALTH', 'AUTOIMMUNE', 'CHRONIC', 'OTHER'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "illness_lifecycle" AS ENUM ('ACUTE', 'CHRONIC_ONGOING', 'RECURRING', 'FLARE'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "illness_episodes" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "type" "illness_type" NOT NULL, + "lifecycle" "illness_lifecycle" NOT NULL DEFAULT 'ACUTE', + "onset_at" TIMESTAMP(3) NOT NULL, + "resolved_at" TIMESTAMP(3), + "parent_condition_id" TEXT, + "note_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "illness_episodes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "illness_day_logs" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "episode_id" TEXT NOT NULL, + "date" TEXT NOT NULL, + "functional_impact" INTEGER, + "fever_c" DOUBLE PRECISION, + "note_encrypted" BYTEA, + "tz" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "illness_day_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "illness_symptoms" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label_key" TEXT NOT NULL, + "icon" TEXT, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "illness_symptoms_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "illness_symptom_links" ( + "day_log_id" TEXT NOT NULL, + "symptom_id" TEXT NOT NULL, + "severity" INTEGER, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "illness_symptom_links_pkey" PRIMARY KEY ("day_log_id","symptom_id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_episodes_user_id_deleted_at_idx" ON "illness_episodes"("user_id", "deleted_at"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_episodes_user_id_onset_at_idx" ON "illness_episodes"("user_id", "onset_at"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_episodes_parent_condition_id_idx" ON "illness_episodes"("parent_condition_id"); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "illness_day_logs_episode_id_date_key" ON "illness_day_logs"("episode_id", "date"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_day_logs_user_id_date_idx" ON "illness_day_logs"("user_id", "date"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_day_logs_episode_id_idx" ON "illness_day_logs"("episode_id"); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "illness_symptoms_key_key" ON "illness_symptoms"("key"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_symptoms_sort_order_idx" ON "illness_symptoms"("sort_order"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "illness_symptom_links_symptom_id_idx" ON "illness_symptom_links"("symptom_id"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_episodes" ADD CONSTRAINT "illness_episodes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_episodes" ADD CONSTRAINT "illness_episodes_parent_condition_id_fkey" FOREIGN KEY ("parent_condition_id") REFERENCES "illness_episodes"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_day_logs" ADD CONSTRAINT "illness_day_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_day_logs" ADD CONSTRAINT "illness_day_logs_episode_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "illness_episodes"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_symptom_links" ADD CONSTRAINT "illness_symptom_links_day_log_id_fkey" FOREIGN KEY ("day_log_id") REFERENCES "illness_day_logs"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "illness_symptom_links" ADD CONSTRAINT "illness_symptom_links_symptom_id_fkey" FOREIGN KEY ("symptom_id") REFERENCES "illness_symptoms"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- ─── Seed the default illness-symptom catalog ──────────────────────── +-- Jackson/WURSS-derived high-signal cold/illness symptoms, scored 0–3 on +-- the link. Idempotent: deterministic ids + ON CONFLICT ("key") DO NOTHING +-- (the cycle_symptoms seed precedent 0129). Keep in sync with the app-side +-- illness symptom taxonomy if either changes. +INSERT INTO "illness_symptoms" ("id", "key", "label_key", "icon", "sort_order", "is_active") VALUES + ('is_runny_nose', 'runny_nose', 'illness.symptom.runnyNose', 'Droplets', 0, true), + ('is_stuffy_nose', 'stuffy_nose', 'illness.symptom.stuffyNose', 'Wind', 1, true), + ('is_sneezing', 'sneezing', 'illness.symptom.sneezing', 'Waves', 2, true), + ('is_sore_throat', 'sore_throat', 'illness.symptom.soreThroat', 'Flame', 3, true), + ('is_cough', 'cough', 'illness.symptom.cough', 'Megaphone', 4, true), + ('is_headache', 'headache', 'illness.symptom.headache', 'Brain', 5, true), + ('is_body_aches', 'body_aches', 'illness.symptom.bodyAches', 'PersonStanding', 6, true), + ('is_fatigue', 'fatigue', 'illness.symptom.fatigue', 'BatteryLow', 7, true) +ON CONFLICT ("key") DO NOTHING; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c07d244d..83e335d28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -588,6 +588,14 @@ model User { // that pairs with the Vorsorge "annual blood panel" reminder so a result // has somewhere to land. Soft-deleted. labResults LabResult[] + // v1.19.0 — user-scoped biomarker catalog (canonical name/unit/range per + // marker); LabResult rows link to a Biomarker for the resolved range/unit. + biomarkers Biomarker[] + // v1.19.0 — illness / condition journal (born-gated by the module system). + // IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the + // per-day timeline mirroring the CycleDayLog shape. + illnessEpisodes IllnessEpisode[] + illnessDayLogs IllnessDayLog[] // v1.7.0 profile — optional patient-identity fields surfaced on the // health-record export cover (PDF) and the FHIR `Patient` resource. @@ -3255,6 +3263,19 @@ model MeasurementReminder { /// First-due anchor; the resolver falls back to `lastSatisfiedAt ?? createdAt` /// when NULL. anchorDate DateTime? @map("anchor_date") + /// Optional course-window end. NULL = open-ended cadence (the existing + /// behaviour). Non-NULL bounds a finite cadence: the recurrence engine + /// (`medication.endsOn` precedent) stops producing occurrences past this + /// instant, so a Coach-suggested "measure BP morning+evening for a week" + /// self-expires. Additive; every existing reminder keeps `NULL` and stays + /// open-ended. + endsOn DateTime? @map("ends_on") + /// Who produced this reminder. `VORSORGE` = the user created it through the + /// Vorsorge surface (the existing, default behaviour). `COACH` = the AI + /// coach minted it from an evidence-based cadence suggestion, posted to the + /// SAME `POST /api/measurement-reminders` endpoint — one engine, not two. + /// Lets the UI label provenance and lets Coach dedupe its own suggestions. + origin ReminderOrigin @default(VORSORGE) /// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour. /// DST-safe windowing is applied at the cron via the existing TZ helpers. notifyHour Int @default(9) @map("notify_hour") @@ -3280,6 +3301,16 @@ model MeasurementReminder { @@map("measurement_reminders") } +/// Provenance of a MeasurementReminder. `VORSORGE` = user-created through the +/// Vorsorge surface (default); `COACH` = minted by the AI coach from a cadence +/// suggestion. Both feed the same engine / endpoint / cron. +enum ReminderOrigin { + VORSORGE + COACH + + @@map("reminder_origin") +} + // ─── Lab results ───────────────────────────────────────────── /// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual @@ -3304,6 +3335,14 @@ model LabResult { /// When the sample was taken (UTC instant; wall-clock anchoring is the /// reader's concern per the schema's UTC-only convention). takenAt DateTime @map("taken_at") + /// v1.19.0 — optional link to the user-scoped Biomarker catalog. NULL on + /// every existing row; a backfill job grouping by `lower(analyte)` will + /// populate it later (this migration only adds the column + relation). Once + /// linked, server reads resolve the canonical unit + reference bounds from + /// the Biomarker, ending the per-row free-text divergence ("LDL" vs "ldl" + /// vs "LDL-C", `mg/dL` vs `mg/dl`). The legacy `analyte` / `unit` / + /// `reference*` columns stay as historical truth for one release. + biomarkerId String? @map("biomarker_id") /// Provenance: "MANUAL" today; import paths set their own source string. source String @default("MANUAL") /// Optional free-text note, AES-256-GCM encrypted at rest via @@ -3314,13 +3353,55 @@ model LabResult { /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull) @@index([userId, analyte, takenAt]) @@index([userId, deletedAt]) + @@index([biomarkerId]) @@map("lab_results") } +// ─── Biomarker catalog ─────────────────────────────────────── + +/// v1.19.0 — user-scoped catalog of blood/lab biomarkers. Defining a marker +/// once (canonical name, unit, reference bounds, optional context note) and +/// recording LabResult values against it ends the per-row free-text +/// divergence the lean v1.17.1 LabResult model invites (re-typing analyte / +/// unit / both bounds on every reading). The reference range + unit are +/// authoritative on the catalog row, not on the entry; server reads resolve +/// from here and the resolved values flow into the DTO (iOS renders, never +/// recomputes). Unique per user + name so there is never two "LDL" +/// definitions. +model Biomarker { + id String @id @default(cuid()) + userId String @map("user_id") + /// Canonical display name, e.g. "LDL Cholesterol". + name String + /// Canonical unit for every reading against this marker, e.g. "mg/dL". + unit String + /// Optional reference-range bounds. The lab/sex/age-dependent "normal" + /// window; the server's range verdict is computed against these. + lowerBound Float? @map("lower_bound") + upperBound Float? @map("upper_bound") + /// Optional per-marker context ("what this means"), AES-256-GCM encrypted + /// at rest via `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per + /// convention). + contextEncrypted Bytes? @map("context_encrypted") + /// Optional panel grouping moved off the per-entry row, e.g. + /// "Großes Blutbild". NULL = standalone marker. + panel String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + results LabResult[] + + @@unique([userId, name]) + @@index([userId]) + @@map("biomarkers") +} + // ─── User Achievements ─────────────────────────────────────── model UserAchievement { @@ -3935,6 +4016,33 @@ enum CervixOpening { @@map("cervix_opening") } +/// Broad condition class for an IllnessEpisode (v1.19.0). Widened beyond an +/// infection log so the journal covers any condition the user keeps for +/// themselves. +enum IllnessType { + INFECTION + ALLERGY + INJURY + MENTAL_HEALTH + AUTOIMMUNE + CHRONIC + OTHER + + @@map("illness_type") +} + +/// Where an IllnessEpisode sits in its lifecycle (v1.19.0). `CHRONIC_ONGOING` +/// has no "recovered" date and is excluded from the recovery-gap math; +/// `FLARE` rows hang off a parent condition via `parentConditionId`. +enum IllnessLifecycle { + ACUTE + CHRONIC_ONGOING + RECURRING + FLARE + + @@map("illness_lifecycle") +} + /// Which method produced a cached CyclePrediction. `CALENDAR` is the /// median+MAD variability predictor; `SYMPTOTHERMAL` folds the /// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the @@ -4227,6 +4335,141 @@ model CycleSymptomLink { @@map("cycle_symptom_links") } +// ─── Illness / condition journal (v1.19.0) ──────────────────── +// +// A CONDITION journal, not a flu log: any illness/condition kept for +// oneself, with a retrospective lens ("how did it announce itself?", +// what dropped, recovery time). Retrospective + correlated, NEVER a +// predictor/diagnoser. Born-gated by the module system (the gate is +// app-layer; there is no enable flag in the schema). The daily timeline +// mirrors the Cycle three-table shape (DayLog + Symptom catalog + Link). + +/// One illness/condition episode. `lifecycle` distinguishes an acute +/// bout from an ongoing chronic condition, a recurring one, or a flare +/// hanging off a parent condition (`parentConditionId`). CHRONIC_ONGOING +/// has no "recovered" date and is excluded from the recovery-gap math. +model IllnessEpisode { + id String @id @default(cuid()) + userId String @map("user_id") + /// User-facing label for the episode, e.g. "Erkältung", "Heuschnupfen". + label String + /// Broad condition class. Widened beyond an infection log to cover the + /// condition journal's scope. + type IllnessType + /// Where this episode sits in its lifecycle. FLARE rows reference a + /// parent condition via `parentConditionId`. + lifecycle IllnessLifecycle @default(ACUTE) + /// When the episode began (UTC instant; wall-clock anchoring is the + /// reader's concern per the schema's UTC-only convention). + onsetAt DateTime @map("onset_at") + /// When the episode resolved. NULL = still ongoing. CHRONIC_ONGOING + /// episodes stay NULL by design. + resolvedAt DateTime? @map("resolved_at") + /// Self-FK for flares: a FLARE/RECURRING bout hangs off the parent + /// condition episode so the history threads under one condition. + parentConditionId String? @map("parent_condition_id") + /// Optional free-text note, AES-256-GCM encrypted at rest via + /// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per + /// convention). + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// Parent condition for a flare/recurrence (self-relation). + parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) + flares IllnessEpisode[] @relation("IllnessFlares") + dayLogs IllnessDayLog[] + + @@index([userId, deletedAt]) + @@index([userId, onsetAt]) + @@index([parentConditionId]) + @@map("illness_episodes") +} + +/// One row per day of an illness episode (mirrors CycleDayLog): the +/// day's free-text note (encrypted), a coarse functional-impact slider, +/// an optional fever reading, plus the symptom links. Soft-deleted. +model IllnessDayLog { + id String @id @default(cuid()) + userId String @map("user_id") + episodeId String @map("episode_id") + /// YYYY-MM-DD — anchored to `tz` when non-null, else legacy + /// Europe/Berlin (the CycleDayLog / MoodEntry date convention). + date String + /// Coarse self-rated functional impact for the day (0 = no impact / + /// fully functional … 3 = bedbound). NULL = not logged. A slider, not a + /// symptom — kept on the day row like CycleDayLog's per-day signs. + functionalImpact Int? @map("functional_impact") + /// Optional body temperature for the day, in Celsius. Queryable + /// plaintext (the CycleDayLog.basalBodyTempC precedent): the fever + /// signal feeds the retrospective curve. NULL = not measured. + feverC Float? @map("fever_c") + /// AES-256-GCM ciphertext envelope of the day's free-text note (always + /// encrypted — the `*Encrypted` Bytes convention). + noteEncrypted Bytes? @map("note_encrypted") + /// Optional timezone the `date` is anchored to (the CycleDayLog + /// precedent). + tz String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade) + symptomLinks IllnessSymptomLink[] + + @@unique([episodeId, date]) + @@index([userId, date]) + @@index([episodeId]) + @@map("illness_day_logs") +} + +/// Seeded catalog of illness symptoms (Jackson / WURSS-derived). Mirrors +/// CycleSymptom: a global, icon-bearing, labelKey-i18n, isActive/sortOrder +/// catalog joined per day. Severity is captured 0–3 on the link. +model IllnessSymptom { + id String @id @default(cuid()) + /// Stable machine key (e.g. `cough`). Unique across the catalog so the + /// seed migration can upsert idempotently. + key String @unique + /// i18n message key for the symptom label (`illness.symptom.`). + labelKey String @map("label_key") + /// Lucide icon name for the symptom chip. + icon String? + sortOrder Int @default(0) @map("sort_order") + /// Soft-hide a symptom without deleting its historical links. + isActive Boolean @default(true) @map("is_active") + + links IllnessSymptomLink[] + + @@index([sortOrder]) + @@map("illness_symptoms") +} + +/// Join: which symptoms an illness day-log carries, with a 0–3 severity. +/// Composite PK keeps the pairing unique; cascade on both sides so +/// deleting a day-log (or a catalog symptom) cleans up its links. +model IllnessSymptomLink { + dayLogId String @map("day_log_id") + symptomId String @map("symptom_id") + /// 0–3 severity for this symptom on this day (the Jackson/WURSS scale: + /// 0 = none, 1 = mild, 2 = moderate, 3 = severe). NULL = a plain + /// presence link. + severity Int? @map("severity") + createdAt DateTime @default(now()) @map("created_at") + + dayLog IllnessDayLog @relation(fields: [dayLogId], references: [id], onDelete: Cascade) + symptom IllnessSymptom @relation(fields: [symptomId], references: [id], onDelete: Cascade) + + @@id([dayLogId, symptomId]) + @@index([symptomId]) + @@map("illness_symptom_links") +} + /// One cached forecast per user, regenerated debounced / /// stale-while-revalidate (the InsightNarrative + v1.8.7 assessment /// cache pattern), NOT per-read. Derived — not soft-deleted. 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 04/48] 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 05/48] 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 06/48] 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< From e9037a0d9298933e32d2e92a93d22225375d27cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:05:51 +0200 Subject: [PATCH 07/48] feat(labs): biomarker catalog with server-resolved reference ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a user-scoped Biomarker catalog so a marker is defined once — name, unit, lower/upper bound, optional encrypted context, panel — and every later reading just links it. New /api/biomarkers CRUD plus a structured /api/labs create path: a biomarkerId resolves the unit + reference range server-side, so the list and detail DTOs carry the resolved values and the client never recomputes the range. Lab reads include the linked marker and fall back to the legacy free-text fields for unlinked rows. Add a lab-result restore endpoint for the delete-Undo affordance, a shared serialiser, a static common-panel seed list, and tests for catalog validation, structured-entry resolution, and the context codec. --- src/app/api/biomarkers/[id]/route.ts | 190 ++++++++++++++++++ src/app/api/biomarkers/route.ts | 151 ++++++++++++++ src/app/api/labs/[id]/route.ts | 95 ++++----- src/app/api/labs/restore/route.ts | 73 +++++++ src/app/api/labs/route.ts | 149 ++++++++------ .../labs/__tests__/biomarker-store.test.ts | 35 ++++ src/lib/labs/__tests__/serialise.test.ts | 96 +++++++++ src/lib/labs/biomarker-catalog.ts | 87 ++++++++ src/lib/labs/biomarker-store.ts | 30 +++ src/lib/labs/format-value.ts | 13 ++ src/lib/labs/serialise.ts | 150 ++++++++++++++ src/lib/query-keys/labs.ts | 7 + .../validations/__tests__/biomarkers.test.ts | 78 +++++++ src/lib/validations/__tests__/labs.test.ts | 26 +++ src/lib/validations/biomarkers.ts | 89 ++++++++ src/lib/validations/labs.ts | 29 ++- 16 files changed, 1194 insertions(+), 104 deletions(-) create mode 100644 src/app/api/biomarkers/[id]/route.ts create mode 100644 src/app/api/biomarkers/route.ts create mode 100644 src/app/api/labs/restore/route.ts create mode 100644 src/lib/labs/__tests__/biomarker-store.test.ts create mode 100644 src/lib/labs/__tests__/serialise.test.ts create mode 100644 src/lib/labs/biomarker-catalog.ts create mode 100644 src/lib/labs/biomarker-store.ts create mode 100644 src/lib/labs/format-value.ts create mode 100644 src/lib/labs/serialise.ts create mode 100644 src/lib/validations/__tests__/biomarkers.test.ts create mode 100644 src/lib/validations/biomarkers.ts diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts new file mode 100644 index 000000000..48be5a921 --- /dev/null +++ b/src/app/api/biomarkers/[id]/route.ts @@ -0,0 +1,190 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { + decryptContextFromBytes, + encryptContextToBytes, +} from "@/lib/labs/biomarker-store"; +import { annotate } from "@/lib/logging/context"; +import { updateBiomarkerSchema } from "@/lib/validations/biomarkers"; + +/** + * v1.18.1 — single Biomarker resource (`/api/biomarkers/{id}`). + * + * PUT applies a partial edit (`data` built field-by-field; an explicit `null` + * clears `context` / `panel` / a bound, an omitted key leaves it untouched). + * DELETE hard-deletes the catalog definition — the `onDelete: SetNull` FK on + * `LabResult.biomarkerId` unlinks every reading without losing it (the row + * keeps its legacy `analyte` / `unit` historical fields). Cross-user rows + * surface as 404 (existence sealed). + */ + +type RouteParams = { params: Promise<{ id: string }> }; + +function serialiseBiomarker(row: { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + contextEncrypted: Uint8Array | null; + createdAt: Date; + updatedAt: Date; +}) { + return { + id: row.id, + name: row.name, + unit: row.unit, + lowerBound: row.lowerBound, + upperBound: row.upperBound, + panel: row.panel, + hasContext: row.contextEncrypted !== null, + context: row.contextEncrypted + ? decryptContextFromBytes(row.contextEncrypted) + : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const row = await prisma.biomarker.findFirst({ where: { id } }); + if (!row || row.userId !== user.id) { + return apiError("Biomarker not found", 404); + } + + annotate({ + action: { name: "labs.biomarker.get" }, + meta: { biomarkerId: id }, + }); + + return apiSuccess(serialiseBiomarker(row)); + }, +); + +export const PUT = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.biomarker.findFirst({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return apiError("Biomarker not found", 404); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = updateBiomarkerSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "labs.biomarker.update.validation-failed" }, + meta: { issue_count: parsed.error.issues.length, biomarkerId: id }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "labs.biomarker.update.validation-failed", + details: JSON.stringify({ issues: auditIssues, biomarkerId: id }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const d = parsed.data; + + // A rename must not collide with another of the caller's markers. + if (d.name !== undefined && d.name !== existing.name) { + const clash = await prisma.biomarker.findFirst({ + where: { userId: user.id, name: d.name }, + select: { id: true }, + }); + if (clash) { + return apiError("A biomarker with this name already exists", 409); + } + } + + // Build `data` field-by-field. `undefined` → leave the column untouched; + // an explicit `null` on `context` / `panel` / a bound clears it. + const data: Record = {}; + if (d.name !== undefined) data.name = d.name; + if (d.unit !== undefined) data.unit = d.unit; + if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound; + if (d.upperBound !== undefined) data.upperBound = d.upperBound; + if (d.panel !== undefined) data.panel = d.panel; + if (d.context !== undefined) { + data.contextEncrypted = d.context + ? encryptContextToBytes(d.context) + : null; + } + + const updated = await prisma.biomarker.update({ where: { id }, data }); + + await auditLog("biomarker.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { biomarkerId: id }, + }); + + annotate({ + action: { name: "labs.biomarker.update" }, + meta: { biomarkerId: id }, + }); + + return apiSuccess(serialiseBiomarker(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.biomarker.findFirst({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return apiError("Biomarker not found", 404); + } + + // Hard delete — the `onDelete: SetNull` FK unlinks every reading rather + // than cascading it away. The readings keep their legacy `analyte` / + // `unit` so the history survives the catalog edit. + await prisma.biomarker.delete({ where: { id } }); + + await auditLog("biomarker.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { biomarkerId: id }, + }); + + annotate({ + action: { name: "labs.biomarker.delete" }, + meta: { biomarkerId: id }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts new file mode 100644 index 000000000..61a5b2f14 --- /dev/null +++ b/src/app/api/biomarkers/route.ts @@ -0,0 +1,151 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { + decryptContextFromBytes, + encryptContextToBytes, +} from "@/lib/labs/biomarker-store"; +import { annotate } from "@/lib/logging/context"; +import { createBiomarkerSchema } from "@/lib/validations/biomarkers"; + +/** + * v1.18.1 — user-scoped Biomarker catalog (`/api/biomarkers`). + * + * The catalog is the Labs feature's primary object: a marker is defined ONCE + * (name, unit, reference bounds, optional context) and every later reading + * just picks it. GET lists the caller's markers; POST defines a new one. + * `userId` is always narrowed from the session — never a body field — and the + * write `data` object is built field-by-field (no mass assignment). The + * optional context note is AES-256-GCM encrypted into the `contextEncrypted` + * Bytes column before write. The `@@unique([userId, name])` index means there + * is never a second "LDL" definition for one user. + */ + +// The encrypted context bytes are never echoed back; `hasContext` flags +// presence and the single-resource GET returns the decrypted text. +function serialiseBiomarker(row: { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + contextEncrypted: Uint8Array | null; + createdAt: Date; + updatedAt: Date; +}) { + return { + id: row.id, + name: row.name, + unit: row.unit, + lowerBound: row.lowerBound, + upperBound: row.upperBound, + panel: row.panel, + hasContext: row.contextEncrypted !== null, + context: row.contextEncrypted + ? decryptContextFromBytes(row.contextEncrypted) + : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const rows = await prisma.biomarker.findMany({ + where: { userId: user.id }, + orderBy: [{ name: "asc" }], + }); + + annotate({ + action: { name: "labs.biomarker.list" }, + meta: { total: rows.length }, + }); + + return apiSuccess({ biomarkers: rows.map(serialiseBiomarker) }); +}); + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker)); + +async function postBiomarker(request: NextRequest) { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createBiomarkerSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "labs.biomarker.create.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "labs.biomarker.create.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { name, unit, lowerBound, upperBound, context, panel } = parsed.data; + + // Reject a duplicate name up front so the client sees a clean 409 rather + // than a Prisma unique-constraint 500. The `@@unique([userId, name])` index + // is the structural backstop. + const existing = await prisma.biomarker.findFirst({ + where: { userId: user.id, name }, + select: { id: true }, + }); + if (existing) { + return apiError("A biomarker with this name already exists", 409); + } + + // Field-by-field assignment — never spread `parsed.data`. + const created = await prisma.biomarker.create({ + data: { + userId: user.id, + name, + unit, + lowerBound: lowerBound ?? null, + upperBound: upperBound ?? null, + panel: panel ?? null, + contextEncrypted: context ? encryptContextToBytes(context) : null, + }, + }); + + await auditLog("biomarker.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { biomarkerId: created.id }, + }); + + annotate({ + action: { name: "labs.biomarker.create" }, + meta: { biomarkerId: created.id }, + }); + + return apiSuccess(serialiseBiomarker(created), 201); +} diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index 829f88498..9463efa45 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -11,12 +11,14 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { + type ResolvedBiomarker, + serialiseLabResult, + serialiseLabResultDetail, +} from "@/lib/labs/serialise"; import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; -import { - classifyReferenceRange, - updateLabResultSchema, -} from "@/lib/validations/labs"; +import { updateLabResultSchema } from "@/lib/validations/labs"; /** * v1.17.1 — single lab-result resource (`/api/labs/{id}`). @@ -26,10 +28,36 @@ import { * (`data` built field-by-field; an explicit `null` clears `panel` / `note` / * a reference bound, an omitted key leaves it untouched). DELETE soft-deletes * by stamping `deletedAt`. Cross-user rows surface as 404 (existence sealed). + * + * v1.18.1 — when the row links a `Biomarker`, the response resolves the + * canonical name / unit / range from the catalog (server-authoritative). The + * legacy per-row free-text fields stay editable for an unlinked row. */ type RouteParams = { params: Promise<{ id: string }> }; +const biomarkerSelect = { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, +} as const; + +function toResolved( + bm: { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + } | null, +): ResolvedBiomarker | null { + return bm ?? null; +} + export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -37,6 +65,7 @@ export const GET = apiHandler( const row = await prisma.labResult.findFirst({ where: { id, deletedAt: null }, + include: { biomarker: { select: biomarkerSelect } }, }); if (!row || row.userId !== user.id) { return apiError("Lab result not found", 404); @@ -44,27 +73,13 @@ export const GET = apiHandler( annotate({ action: { name: "labs.get" }, meta: { labResultId: id } }); - return apiSuccess({ - id: row.id, - panel: row.panel, - analyte: row.analyte, - value: row.value, - unit: row.unit, - referenceLow: row.referenceLow, - referenceHigh: row.referenceHigh, - takenAt: row.takenAt.toISOString(), - source: row.source, - note: row.noteEncrypted - ? decryptNoteFromBytes(row.noteEncrypted) - : null, - rangeStatus: classifyReferenceRange( - row.value, - row.referenceLow, - row.referenceHigh, + return apiSuccess( + serialiseLabResultDetail( + row, + toResolved(row.biomarker), + row.noteEncrypted ? decryptNoteFromBytes(row.noteEncrypted) : null, ), - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }); + ); }, ); @@ -112,6 +127,8 @@ export const PUT = apiHandler( // Build `data` field-by-field. `undefined` → leave the column untouched; // an explicit `null` on `panel` / `note` / a reference bound clears it. + // A reading linked to a catalog marker is edited via VALUE / takenAt / + // note here; its name / unit / range stay resolved from the biomarker. const data: Record = {}; if (d.panel !== undefined) data.panel = d.panel; if (d.analyte !== undefined) data.analyte = d.analyte; @@ -124,7 +141,11 @@ export const PUT = apiHandler( data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null; } - const updated = await prisma.labResult.update({ where: { id }, data }); + const updated = await prisma.labResult.update({ + where: { id }, + data, + include: { biomarker: { select: biomarkerSelect } }, + }); await auditLog("labResult.update", { userId: user.id, @@ -134,25 +155,9 @@ export const PUT = apiHandler( annotate({ action: { name: "labs.update" }, meta: { labResultId: id } }); - return apiSuccess({ - id: updated.id, - panel: updated.panel, - analyte: updated.analyte, - value: updated.value, - unit: updated.unit, - referenceLow: updated.referenceLow, - referenceHigh: updated.referenceHigh, - takenAt: updated.takenAt.toISOString(), - source: updated.source, - hasNote: updated.noteEncrypted !== null, - rangeStatus: classifyReferenceRange( - updated.value, - updated.referenceLow, - updated.referenceHigh, - ), - createdAt: updated.createdAt.toISOString(), - updatedAt: updated.updatedAt.toISOString(), - }); + return apiSuccess( + serialiseLabResult(updated, toResolved(updated.biomarker)), + ); }, ); @@ -178,7 +183,7 @@ export const DELETE = apiHandler( await auditLog("labResult.delete", { userId: user.id, ipAddress: getClientIp(request), - details: { labResultId: id, analyte: existing.analyte }, + details: { labResultId: id }, }); annotate({ action: { name: "labs.delete" }, meta: { labResultId: id } }); diff --git a/src/app/api/labs/restore/route.ts b/src/app/api/labs/restore/route.ts new file mode 100644 index 000000000..07d0bc409 --- /dev/null +++ b/src/app/api/labs/restore/route.ts @@ -0,0 +1,73 @@ +/** + * `POST /api/labs/restore` — un-tombstone for the lab-history delete-Undo + * affordance (v1.18.1). + * + * Body: `{ ids: string[] }` — 1..200 lab-result ids, scoped to the caller. + * + * Clears `deletedAt` on every owned, currently-tombstoned row in one + * `updateMany`, so the reading re-surfaces in normal reads. A forged / + * foreign / not-deleted id is a silent no-op (never a 404 existence leak): + * the mutation `where` pins `userId` so it only ever touches the caller's + * rows. Mirrors `/api/measurements/restore`. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const MAX_IDS_PER_BATCH = 200; +const RATE_LIMIT_MAX = 60; +const RATE_LIMIT_WINDOW_MS = 60 * 1000; + +const restoreSchema = z.object({ + ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH), +}); + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore)); + +async function postRestore(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `labs:restore:${user.id}`, + RATE_LIMIT_MAX, + RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many restore requests, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = restoreSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "labs.restore.invalid", + }); + } + + const { ids } = parsed.data; + + // `userId`-scoped + `deletedAt != null`: a foreign / live id is skipped. + const { count } = await prisma.labResult.updateMany({ + where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } }, + data: { deletedAt: null }, + }); + + annotate({ action: { name: "labs.restore" }, meta: { restored: count } }); + + return apiSuccess({ restored: count }); +} diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index a80ff3f41..cc9395b14 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { + apiError, apiSuccess, getClientIp, returnAllZodIssues, @@ -11,10 +12,13 @@ import { import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; +import { + type ResolvedBiomarker, + serialiseLabResult, +} from "@/lib/labs/serialise"; import { encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; import { - classifyReferenceRange, createLabResultSchema, listLabResultsSchema, } from "@/lib/validations/labs"; @@ -22,49 +26,31 @@ import { /** * v1.17.1 — structured lab-result store (`/api/labs`). * - * GET lists the caller's live results with optional analyte / panel / date - * filters. POST records a single reading. `userId` is always narrowed from - * the session — never a body field — and the write `data` object is built - * field-by-field (no mass assignment). The free-text note, when present, is - * AES-256-GCM encrypted into the `noteEncrypted` Bytes column before write. + * GET lists the caller's live results with optional biomarker / analyte / + * panel / date filters. POST records a single reading. `userId` is always + * narrowed from the session — never a body field — and the write `data` + * object is built field-by-field (no mass assignment). The free-text note, + * when present, is AES-256-GCM encrypted into the `noteEncrypted` Bytes + * column before write. + * + * v1.18.1 — structured entry: when the body carries a `biomarkerId`, the row + * links the user-scoped catalog marker and the response resolves its unit + + * reference range FROM the biomarker (server-authoritative). The web + iOS + * clients render the resolved DTO and never recompute. */ -// The serialised row never echoes the encrypted note bytes back; the badge -// status is computed server-side so the client renders a coherent, neutral -// in/out-of-range verdict without re-deriving the rule. -function serialiseLabResult(row: { - id: string; - panel: string | null; - analyte: string; - value: number; - unit: string; - referenceLow: number | null; - referenceHigh: number | null; - takenAt: Date; - source: string; - noteEncrypted: Uint8Array | null; - createdAt: Date; - updatedAt: Date; -}) { - return { - id: row.id, - panel: row.panel, - analyte: row.analyte, - value: row.value, - unit: row.unit, - referenceLow: row.referenceLow, - referenceHigh: row.referenceHigh, - takenAt: row.takenAt.toISOString(), - source: row.source, - hasNote: row.noteEncrypted !== null, - rangeStatus: classifyReferenceRange( - row.value, - row.referenceLow, - row.referenceHigh, - ), - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; +/** Map the joined biomarker (or null) into the resolver's shape. */ +function toResolved( + bm: { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + } | null, +): ResolvedBiomarker | null { + return bm ?? null; } export const GET = apiHandler(async (request: NextRequest) => { @@ -80,11 +66,13 @@ export const GET = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const { analyte, panel, from, to, limit, offset, sortDir } = parsed.data; + const { biomarkerId, analyte, panel, from, to, limit, offset, sortDir } = + parsed.data; const where = { userId: user.id, deletedAt: null, + ...(biomarkerId && { biomarkerId }), ...(analyte && { analyte }), ...(panel && { panel }), ...(from || to @@ -103,6 +91,18 @@ export const GET = apiHandler(async (request: NextRequest) => { orderBy: { takenAt: sortDir }, take: limit, skip: offset, + include: { + biomarker: { + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + }, + }, + }, }), prisma.labResult.count({ where }), ]); @@ -113,7 +113,9 @@ export const GET = apiHandler(async (request: NextRequest) => { }); return apiSuccess({ - results: rows.map(serialiseLabResult), + results: rows.map((row) => + serialiseLabResult(row, toResolved(row.biomarker)), + ), meta: { total, limit, offset }, }); }); @@ -153,20 +155,55 @@ async function postLabResult(request: NextRequest) { return returnAllZodIssues(parsed.error, 422); } - const { panel, analyte, value, unit, referenceLow, referenceHigh, takenAt, note } = - parsed.data; + const { + biomarkerId, + panel, + analyte, + value, + unit, + referenceLow, + referenceHigh, + takenAt, + note, + } = parsed.data; - // Field-by-field assignment — never spread `parsed.data`. `source` is - // hardcoded "MANUAL" for this user-facing path; import paths set their own. + // Structured-entry path: resolve the catalog marker (and verify ownership) + // so the row's name + unit derive from it. A forged / foreign id is a 404. + let biomarker: ResolvedBiomarker | null = null; + if (biomarkerId) { + const found = await prisma.biomarker.findFirst({ + where: { id: biomarkerId, userId: user.id }, + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + }, + }); + if (!found) { + return apiError("Biomarker not found", 404); + } + biomarker = found; + } + + // Field-by-field assignment — never spread `parsed.data`. With a catalog + // link the row stamps the resolved name/unit/range as historical truth + // (so a later catalog edit does not silently rewrite a past reading) AND + // keeps the FK; reads resolve the CURRENT catalog values via `serialise`. const created = await prisma.labResult.create({ data: { userId: user.id, - panel: panel ?? null, - analyte, + biomarkerId: biomarker?.id ?? null, + panel: biomarker ? biomarker.panel : (panel ?? null), + analyte: biomarker ? biomarker.name : (analyte as string), value, - unit, - referenceLow: referenceLow ?? null, - referenceHigh: referenceHigh ?? null, + unit: biomarker ? biomarker.unit : (unit as string), + referenceLow: biomarker ? biomarker.lowerBound : (referenceLow ?? null), + referenceHigh: biomarker + ? biomarker.upperBound + : (referenceHigh ?? null), takenAt, source: "MANUAL", noteEncrypted: note ? encryptNoteToBytes(note) : null, @@ -176,13 +213,13 @@ async function postLabResult(request: NextRequest) { await auditLog("labResult.create", { userId: user.id, ipAddress: getClientIp(request), - details: { labResultId: created.id, analyte: created.analyte }, + details: { labResultId: created.id }, }); annotate({ action: { name: "labs.create" }, - meta: { labResultId: created.id }, + meta: { labResultId: created.id, structured: biomarker !== null }, }); - return apiSuccess(serialiseLabResult(created), 201); + return apiSuccess(serialiseLabResult(created, biomarker), 201); } diff --git a/src/lib/labs/__tests__/biomarker-store.test.ts b/src/lib/labs/__tests__/biomarker-store.test.ts new file mode 100644 index 000000000..fd8b849af --- /dev/null +++ b/src/lib/labs/__tests__/biomarker-store.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { _resetCryptoCacheForTests } from "@/lib/crypto"; + +import { + decryptContextFromBytes, + encryptContextToBytes, +} from "../biomarker-store"; + +const KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +describe("biomarker context codec", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", KEY); + _resetCryptoCacheForTests(); + }); + + it("round-trips a UTF-8 context note through Bytes", () => { + const plain = "Nüchtern · morgens · Lipidpanel"; + const bytes = encryptContextToBytes(plain); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(decryptContextFromBytes(bytes)).toBe(plain); + }); + + it("does not store the plaintext verbatim in the bytes", () => { + const plain = "secret context"; + const bytes = encryptContextToBytes(plain); + const asString = Buffer.from(bytes).toString("utf8"); + expect(asString).not.toContain(plain); + }); +}); diff --git a/src/lib/labs/__tests__/serialise.test.ts b/src/lib/labs/__tests__/serialise.test.ts new file mode 100644 index 000000000..d8aa335ad --- /dev/null +++ b/src/lib/labs/__tests__/serialise.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveLabFields, + serialiseLabResult, + serialiseLabResultDetail, + type LabRow, + type ResolvedBiomarker, +} from "../serialise"; + +const baseRow: LabRow = { + id: "lr_1", + panel: "Legacy panel", + analyte: "ldl", // lowercase legacy spelling + value: 130, + unit: "mg/dl", // lowercase legacy unit + referenceLow: null, + referenceHigh: 100, // stale per-row bound + takenAt: new Date("2026-01-01T08:00:00.000Z"), + source: "MANUAL", + biomarkerId: "bm_1", + noteEncrypted: null, + createdAt: new Date("2026-01-01T08:00:00.000Z"), + updatedAt: new Date("2026-01-01T08:00:00.000Z"), +}; + +const biomarker: ResolvedBiomarker = { + id: "bm_1", + name: "LDL Cholesterol", + unit: "mg/dL", + lowerBound: null, + upperBound: 116, + panel: "Lipids", +}; + +describe("resolveLabFields", () => { + it("prefers the linked biomarker over the legacy row fields", () => { + const r = resolveLabFields(baseRow, biomarker); + expect(r.analyte).toBe("LDL Cholesterol"); + expect(r.unit).toBe("mg/dL"); + expect(r.referenceHigh).toBe(116); + expect(r.panel).toBe("Lipids"); + }); + + it("falls back to the row fields when unlinked", () => { + const r = resolveLabFields({ ...baseRow, biomarkerId: null }, null); + expect(r.analyte).toBe("ldl"); + expect(r.unit).toBe("mg/dl"); + expect(r.referenceHigh).toBe(100); + expect(r.panel).toBe("Legacy panel"); + }); +}); + +describe("serialiseLabResult", () => { + it("computes the verdict from the RESOLVED catalog bounds, not the stale row", () => { + // value 130 vs row's stale high 100 → would be 'above'; + // vs catalog high 116 → still 'above', but the resolved bound is what counts. + const dto = serialiseLabResult(baseRow, biomarker); + expect(dto.unit).toBe("mg/dL"); + expect(dto.referenceHigh).toBe(116); + expect(dto.rangeStatus).toBe("above"); + expect(dto.biomarkerId).toBe("bm_1"); + expect(dto.hasNote).toBe(false); + }); + + it("in-range when the resolved catalog band contains the value", () => { + const within = serialiseLabResult({ ...baseRow, value: 90 }, biomarker); + expect(within.rangeStatus).toBe("in-range"); + }); + + it("uses legacy fields and verdict when unlinked", () => { + const dto = serialiseLabResult({ ...baseRow, biomarkerId: null }, null); + expect(dto.unit).toBe("mg/dl"); + // 130 > row high 100 → above + expect(dto.rangeStatus).toBe("above"); + expect(dto.biomarkerId).toBeNull(); + }); + + it("flags an encrypted note without echoing it", () => { + const dto = serialiseLabResult( + { ...baseRow, noteEncrypted: new Uint8Array([1, 2, 3]) }, + biomarker, + ); + expect(dto.hasNote).toBe(true); + expect(dto).not.toHaveProperty("noteEncrypted"); + }); +}); + +describe("serialiseLabResultDetail", () => { + it("carries the decrypted note in place of hasNote", () => { + const dto = serialiseLabResultDetail(baseRow, biomarker, "fasting sample"); + expect(dto.note).toBe("fasting sample"); + expect(dto).not.toHaveProperty("hasNote"); + expect(dto.unit).toBe("mg/dL"); + }); +}); diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts new file mode 100644 index 000000000..05647fbd6 --- /dev/null +++ b/src/lib/labs/biomarker-catalog.ts @@ -0,0 +1,87 @@ +/** + * v1.18.1 — static seed catalog of common blood/lab biomarkers. + * + * This is NOT a DB table. It is a curated list offered at the add step + * ("pick a common marker, or create your own") so a self-hoster does not + * type a marker definition from scratch. Selecting a seed pre-fills the + * inline definition form (canonical name, unit, suggested reference bounds) + * which the user then accepts or overrides before it becomes a real, + * user-scoped `Biomarker` row. + * + * The suggested ranges are EDITABLE DEFAULTS, never gospel — reference + * windows vary by lab, sex, and age. The display name is localised via the + * `labs.catalog.` i18n key; the EN slug is the stable identity (mirrors + * the Insights slug/alias pattern). Units stay literal (mg/dL etc. are not + * translated). + * + * Source ranges: common adult fasting reference windows (US conventional + * units where the metric/SI split matters). Presented as starting points. + */ + +export interface BiomarkerSeed { + /** Stable EN identity; the `labs.catalog.` i18n key resolves the name. */ + slug: string; + /** Panel grouping key; the `labs.catalog.panel.` i18n key labels it. */ + panel: string; + /** Canonical unit for every reading against this marker. */ + unit: string; + /** Suggested lower bound (editable default), or null when open-ended. */ + lowerBound: number | null; + /** Suggested upper bound (editable default), or null when open-ended. */ + upperBound: number | null; +} + +export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [ + // Lipids + { slug: "total-cholesterol", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 200 }, + { slug: "ldl", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 116 }, + { slug: "hdl", panel: "lipids", unit: "mg/dL", lowerBound: 40, upperBound: null }, + { slug: "triglycerides", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 150 }, + // Glucose metabolism + { slug: "fasting-glucose", panel: "glucose", unit: "mg/dL", lowerBound: 70, upperBound: 100 }, + { slug: "hba1c", panel: "glucose", unit: "%", lowerBound: null, upperBound: 5.7 }, + // Thyroid + { slug: "tsh", panel: "thyroid", unit: "mIU/L", lowerBound: 0.4, upperBound: 4 }, + { slug: "ft3", panel: "thyroid", unit: "pg/mL", lowerBound: 2.3, upperBound: 4.2 }, + { slug: "ft4", panel: "thyroid", unit: "ng/dL", lowerBound: 0.8, upperBound: 1.8 }, + // Iron + { slug: "ferritin", panel: "iron", unit: "ng/mL", lowerBound: 30, upperBound: 400 }, + { slug: "transferrin-saturation", panel: "iron", unit: "%", lowerBound: 20, upperBound: 50 }, + // Vitamins + { slug: "vitamin-d", panel: "vitamins", unit: "ng/mL", lowerBound: 30, upperBound: 100 }, + { slug: "vitamin-b12", panel: "vitamins", unit: "pg/mL", lowerBound: 200, upperBound: 900 }, + { slug: "folate", panel: "vitamins", unit: "ng/mL", lowerBound: 3, upperBound: 20 }, + // Inflammation + { slug: "hs-crp", panel: "inflammation", unit: "mg/L", lowerBound: null, upperBound: 3 }, + // Renal + { slug: "creatinine", panel: "renal", unit: "mg/dL", lowerBound: 0.6, upperBound: 1.3 }, + { slug: "egfr", panel: "renal", unit: "mL/min/1.73m²", lowerBound: 90, upperBound: null }, + // Liver + { slug: "alt", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 40 }, + { slug: "ast", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 40 }, + { slug: "ggt", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 55 }, + // Electrolytes + { slug: "sodium", panel: "electrolytes", unit: "mmol/L", lowerBound: 135, upperBound: 145 }, + { slug: "potassium", panel: "electrolytes", unit: "mmol/L", lowerBound: 3.5, upperBound: 5.1 }, + // Blood count + { slug: "hemoglobin", panel: "bloodCount", unit: "g/dL", lowerBound: 12, upperBound: 17.5 }, + { slug: "hematocrit", panel: "bloodCount", unit: "%", lowerBound: 36, upperBound: 50 }, + { slug: "wbc", panel: "bloodCount", unit: "10³/µL", lowerBound: 4, upperBound: 11 }, + { slug: "platelets", panel: "bloodCount", unit: "10³/µL", lowerBound: 150, upperBound: 400 }, +] as const; + +/** The set of panel keys present in the catalog, in display order. */ +export const BIOMARKER_PANELS = [ + "lipids", + "glucose", + "thyroid", + "iron", + "vitamins", + "inflammation", + "renal", + "liver", + "electrolytes", + "bloodCount", +] as const; + +export type BiomarkerPanelKey = (typeof BIOMARKER_PANELS)[number]; diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts new file mode 100644 index 000000000..17a2b2234 --- /dev/null +++ b/src/lib/labs/biomarker-store.ts @@ -0,0 +1,30 @@ +/** + * v1.18.1 — server-side helpers for the user-scoped Biomarker catalog. + * + * The AES-256-GCM ↔ `Bytes` codec for `Biomarker.contextEncrypted`. The + * per-marker context note ("what this means") is the only sensitive column on + * the model; it shares the `encrypt()` string format (`"."`) + * every other `*Encrypted` column uses, encoded as UTF-8 bytes. + * + * Mirrors the `LabResult.noteEncrypted` codec in `./store.ts` so the two + * encrypted columns on the Labs feature share one byte layout. + */ +import { Buffer } from "node:buffer"; + +import { decrypt, encrypt } from "@/lib/crypto"; + +/** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */ +export function encryptContextToBytes( + plaintext: string, +): Uint8Array { + const ciphertext = encrypt(plaintext); + const encoded = Buffer.from(ciphertext, "utf8"); + const out = new Uint8Array(new ArrayBuffer(encoded.byteLength)); + out.set(encoded); + return out; +} + +/** Decrypt a stored `Bytes` context note back to plaintext. */ +export function decryptContextFromBytes(buf: Uint8Array): string { + return decrypt(Buffer.from(buf).toString("utf8")); +} diff --git a/src/lib/labs/format-value.ts b/src/lib/labs/format-value.ts new file mode 100644 index 000000000..768a031cb --- /dev/null +++ b/src/lib/labs/format-value.ts @@ -0,0 +1,13 @@ +/** + * v1.18.1 — shared lab-value number formatting. + * + * The list card, the detail history rows, and the chart tooltip all trim a + * lab reading the same way: whole numbers render bare, fractional values keep + * up to two decimals (no trailing-zero noise). Centralised so the three + * surfaces stay byte-identical. + */ +export function formatLabValue(value: number): string { + return Number.isInteger(value) + ? String(value) + : String(Number(value.toFixed(2))); +} diff --git a/src/lib/labs/serialise.ts b/src/lib/labs/serialise.ts new file mode 100644 index 000000000..77a8cf29c --- /dev/null +++ b/src/lib/labs/serialise.ts @@ -0,0 +1,150 @@ +/** + * v1.18.1 — server-authoritative lab-result DTO resolution. + * + * A lab row resolves its display name + unit + reference bounds in ONE place: + * + * - If the row links a `Biomarker` (`biomarkerId` set), the canonical name, + * unit, and reference bounds come FROM THE BIOMARKER. The per-row legacy + * `analyte` / `unit` / `reference*` columns are ignored — they are stale + * historical truth at that point. + * - If the row is unlinked (legacy / pre-backfill), it falls back to its own + * free-text `analyte` / `unit` / `reference*` columns. + * + * Either way the verdict (`rangeStatus`) is computed from the RESOLVED bounds + * via `classifyReferenceRange`. The DTO carries the resolved values so the web + * client AND the iOS client render the same numbers — neither recomputes the + * range or guesses the unit. This is the server-authoritative-parity rule for + * Labs. + */ +import { classifyReferenceRange } from "@/lib/labs/reference-range"; + +/** The minimal biomarker shape the resolver needs (no encrypted context). */ +export interface ResolvedBiomarker { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; +} + +/** The lab-result row shape the resolver reads (Prisma row subset). */ +export interface LabRow { + id: string; + panel: string | null; + analyte: string; + value: number; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + takenAt: Date; + source: string; + biomarkerId: string | null; + noteEncrypted: Uint8Array | null; + createdAt: Date; + updatedAt: Date; +} + +/** + * Resolve the canonical name / unit / bounds / panel for a lab row, preferring + * the linked biomarker over the legacy per-row fields. Returns a plain shape + * the list + detail serialisers both build on. + */ +export function resolveLabFields( + row: Pick< + LabRow, + | "analyte" + | "unit" + | "referenceLow" + | "referenceHigh" + | "panel" + | "biomarkerId" + >, + biomarker: ResolvedBiomarker | null | undefined, +): { + analyte: string; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + panel: string | null; +} { + if (biomarker) { + return { + analyte: biomarker.name, + unit: biomarker.unit, + referenceLow: biomarker.lowerBound, + referenceHigh: biomarker.upperBound, + panel: biomarker.panel, + }; + } + return { + analyte: row.analyte, + unit: row.unit, + referenceLow: row.referenceLow, + referenceHigh: row.referenceHigh, + panel: row.panel, + }; +} + +/** + * Serialise a lab row to the list DTO — never echoes the encrypted note bytes + * (only the `hasNote` flag) and resolves name/unit/range server-side. + */ +export function serialiseLabResult( + row: LabRow, + biomarker?: ResolvedBiomarker | null, +) { + const resolved = resolveLabFields(row, biomarker); + return { + id: row.id, + biomarkerId: row.biomarkerId, + panel: resolved.panel, + analyte: resolved.analyte, + value: row.value, + unit: resolved.unit, + referenceLow: resolved.referenceLow, + referenceHigh: resolved.referenceHigh, + takenAt: row.takenAt.toISOString(), + source: row.source, + hasNote: row.noteEncrypted !== null, + rangeStatus: classifyReferenceRange( + row.value, + resolved.referenceLow, + resolved.referenceHigh, + ), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +/** + * Serialise a lab row to the detail DTO — same as the list DTO but carries the + * decrypted `note` instead of the `hasNote` flag. + */ +export function serialiseLabResultDetail( + row: LabRow, + biomarker: ResolvedBiomarker | null | undefined, + note: string | null, +) { + const resolved = resolveLabFields(row, biomarker); + return { + id: row.id, + biomarkerId: row.biomarkerId, + panel: resolved.panel, + analyte: resolved.analyte, + value: row.value, + unit: resolved.unit, + referenceLow: resolved.referenceLow, + referenceHigh: resolved.referenceHigh, + takenAt: row.takenAt.toISOString(), + source: row.source, + note, + rangeStatus: classifyReferenceRange( + row.value, + resolved.referenceLow, + resolved.referenceHigh, + ), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index 8f47ac820..82095873e 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -9,6 +9,7 @@ export const labKeys = { labResults: () => ["lab-results"] as const, labResultsList: (params: { + biomarkerId?: string | undefined; analyte: string | undefined; panel: string | undefined; from: string | undefined; @@ -19,6 +20,7 @@ export const labKeys = { [ "lab-results", "list", + params.biomarkerId ?? null, params.analyte ?? null, params.panel ?? null, params.from ?? null, @@ -26,4 +28,9 @@ export const labKeys = { params.page, params.sortDir, ] as const, + + // v1.18.1 — user-scoped Biomarker catalog. The list feeds the picker and + // the manager; both invalidate `biomarkers()` after a mutation. + biomarkers: () => ["biomarkers"] as const, + biomarkerDetail: (id: string) => ["biomarkers", "detail", id] as const, }; diff --git a/src/lib/validations/__tests__/biomarkers.test.ts b/src/lib/validations/__tests__/biomarkers.test.ts new file mode 100644 index 000000000..c45c140d9 --- /dev/null +++ b/src/lib/validations/__tests__/biomarkers.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + createBiomarkerSchema, + updateBiomarkerSchema, +} from "../biomarkers"; + +describe("createBiomarkerSchema", () => { + it("accepts a minimal marker (name + unit only)", () => { + const r = createBiomarkerSchema.safeParse({ name: "LDL", unit: "mg/dL" }); + expect(r.success).toBe(true); + }); + + it("accepts both bounds when low <= high", () => { + const r = createBiomarkerSchema.safeParse({ + name: "Fasting glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 100, + }); + expect(r.success).toBe(true); + }); + + it("accepts an open-ended upper bound (lower omitted)", () => { + const r = createBiomarkerSchema.safeParse({ + name: "LDL", + unit: "mg/dL", + upperBound: 116, + }); + expect(r.success).toBe(true); + }); + + it("rejects a transposed range (low > high)", () => { + const r = createBiomarkerSchema.safeParse({ + name: "LDL", + unit: "mg/dL", + lowerBound: 200, + upperBound: 100, + }); + expect(r.success).toBe(false); + }); + + it("rejects an empty name", () => { + const r = createBiomarkerSchema.safeParse({ name: " ", unit: "mg/dL" }); + expect(r.success).toBe(false); + }); + + it("normalises an empty context to undefined", () => { + const r = createBiomarkerSchema.safeParse({ + name: "TSH", + unit: "mIU/L", + context: "", + }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.context).toBeUndefined(); + }); +}); + +describe("updateBiomarkerSchema", () => { + it("accepts a partial edit", () => { + const r = updateBiomarkerSchema.safeParse({ unit: "mmol/L" }); + expect(r.success).toBe(true); + }); + + it("accepts an explicit null to clear a bound", () => { + const r = updateBiomarkerSchema.safeParse({ lowerBound: null }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.lowerBound).toBeNull(); + }); + + it("still rejects a transposed range on update", () => { + const r = updateBiomarkerSchema.safeParse({ + lowerBound: 10, + upperBound: 5, + }); + expect(r.success).toBe(false); + }); +}); diff --git a/src/lib/validations/__tests__/labs.test.ts b/src/lib/validations/__tests__/labs.test.ts index 712ce8328..3f090b01e 100644 --- a/src/lib/validations/__tests__/labs.test.ts +++ b/src/lib/validations/__tests__/labs.test.ts @@ -75,6 +75,32 @@ describe("createLabResultSchema", () => { expect(r.success).toBe(true); }); + it("accepts a structured entry (biomarkerId, no analyte/unit)", () => { + const r = createLabResultSchema.safeParse({ + biomarkerId: "bm_123", + value: 110, + takenAt: RECENT, + }); + expect(r.success).toBe(true); + }); + + it("rejects an entry with neither biomarkerId nor analyte", () => { + const r = createLabResultSchema.safeParse({ + value: 110, + takenAt: RECENT, + }); + expect(r.success).toBe(false); + }); + + it("rejects a free-text entry missing the unit", () => { + const r = createLabResultSchema.safeParse({ + analyte: "LDL", + value: 110, + takenAt: RECENT, + }); + expect(r.success).toBe(false); + }); + it("normalises an empty-string panel to undefined", () => { const r = createLabResultSchema.safeParse({ analyte: "TSH", diff --git a/src/lib/validations/biomarkers.ts b/src/lib/validations/biomarkers.ts new file mode 100644 index 000000000..23a9e4ebb --- /dev/null +++ b/src/lib/validations/biomarkers.ts @@ -0,0 +1,89 @@ +/** + * v1.18.1 — user-scoped Biomarker catalog validation. + * + * A `Biomarker` defines a lab marker ONCE: canonical name, unit, reference + * bounds, and an optional context note. Recording a value later is just + * picking the biomarker — the unit and reference range are resolved + * server-side from the catalog row, never re-entered (ending the per-row + * free-text divergence: "LDL" vs "ldl", `mg/dL` vs `mg/dl`). + * + * The reference window is the lab/sex/age-dependent "normal" range; the + * server's neutral range verdict (`classifyReferenceRange`) is computed + * against these bounds. Both are independently optional (a marker may report + * only an upper bound, e.g. LDL < 116); when both are present the lower must + * not exceed the upper. + */ +import { z } from "zod/v4"; + +const requiredText = (max: number) => z.string().trim().min(1).max(max); + +const lowerBound = z.number().finite().optional(); +const upperBound = z.number().finite().optional(); + +const optionalContext = z + .string() + .trim() + .max(2000) + .optional() + // An empty / whitespace-only context normalises to "no context" so a + // blank field clears rather than stores an empty string. + .transform((v) => (v && v.length > 0 ? v : undefined)); + +const optionalPanel = z + .string() + .trim() + .min(1) + .max(120) + .optional() + .or(z.literal("").transform(() => undefined)); + +export const createBiomarkerSchema = z + .object({ + name: requiredText(120), + unit: requiredText(40), + lowerBound, + upperBound, + context: optionalContext, + panel: optionalPanel, + }) + .refine( + (d) => + d.lowerBound === undefined || + d.upperBound === undefined || + d.lowerBound <= d.upperBound, + { + message: "lowerBound must not exceed upperBound", + path: ["lowerBound"], + }, + ); + +export type CreateBiomarkerInput = z.infer; + +/** + * Update schema: every field optional (partial edit). `context` / `panel` / + * a bound accept an explicit `null` to clear the stored value — distinct from + * an omitted key, which leaves the column untouched. + */ +export const updateBiomarkerSchema = z + .object({ + name: requiredText(120).optional(), + unit: requiredText(40).optional(), + lowerBound: lowerBound.or(z.null()), + upperBound: upperBound.or(z.null()), + context: optionalContext.or(z.null()), + panel: optionalPanel.or(z.null()), + }) + .refine( + (d) => + d.lowerBound === undefined || + d.lowerBound === null || + d.upperBound === undefined || + d.upperBound === null || + d.lowerBound <= d.upperBound, + { + message: "lowerBound must not exceed upperBound", + path: ["lowerBound"], + }, + ); + +export type UpdateBiomarkerInput = z.infer; diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index 3a28ee7b2..1bad16de2 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -68,17 +68,38 @@ const optionalNote = z const referenceLow = z.number().finite().optional(); const referenceHigh = z.number().finite().optional(); +/** + * v1.18.1 — optional link to a user-scoped `Biomarker` (the catalog row). + * When set, the structured-entry path resolves the unit + reference range + * from the biomarker server-side, so `analyte` / `unit` may be omitted (the + * route fills them from the catalog row). A free-text reading without a + * catalog link stays valid (quick-capture / legacy path). + */ +const biomarkerId = z.string().trim().min(1).max(64).optional(); + export const createLabResultSchema = z .object({ + biomarkerId, panel: optionalPanel, - analyte: requiredText(120), + analyte: requiredText(120).optional(), value: z.number().finite(), - unit: requiredText(40), + unit: requiredText(40).optional(), referenceLow, referenceHigh, takenAt: takenAtField, note: optionalNote, }) + // Either a catalog link OR a free-text analyte must be present. + .refine((d) => d.biomarkerId !== undefined || d.analyte !== undefined, { + message: "Either biomarkerId or analyte is required", + path: ["analyte"], + }) + // Without a catalog link the unit is required (the catalog otherwise + // supplies it server-side). + .refine((d) => d.biomarkerId !== undefined || d.unit !== undefined, { + message: "unit is required when no biomarkerId is given", + path: ["unit"], + }) .refine( (d) => d.referenceLow === undefined || @@ -123,8 +144,10 @@ export const updateLabResultSchema = z export type UpdateLabResultInput = z.infer; -/** List query: filter by analyte (exact) + date range, paginate. */ +/** List query: filter by biomarker / analyte (exact) + date range, paginate. */ export const listLabResultsSchema = z.object({ + // v1.18.1 — filter to one catalog marker's readings (the detail chart feed). + biomarkerId: z.string().trim().min(1).max(64).optional(), analyte: z.string().trim().min(1).max(120).optional(), panel: z.string().trim().min(1).max(120).optional(), from: z From e4722fb9a112356917ccae12d4487a3d2903eda5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:05:58 +0200 Subject: [PATCH 08/48] feat(labs): backfill job linking legacy readings to the catalog Add a one-shot pg-boss job that heals the per-row free-text divergence: for each user it groups un-linked live readings by lower(analyte), mints (or reuses) one Biomarker per group from the most-recent report's spelling/unit/range, and stamps biomarkerId on every row. Registered in allQueues with a boot-time discovery enqueue; idempotent across reboots (a completed pass drops the user from the discovery set). --- .../__tests__/lab-biomarker-backfill.test.ts | 63 +++++ src/lib/jobs/lab-biomarker-backfill.ts | 217 ++++++++++++++++++ src/lib/jobs/reminder-worker.ts | 58 +++++ 3 files changed, 338 insertions(+) create mode 100644 src/lib/jobs/__tests__/lab-biomarker-backfill.test.ts create mode 100644 src/lib/jobs/lab-biomarker-backfill.ts diff --git a/src/lib/jobs/__tests__/lab-biomarker-backfill.test.ts b/src/lib/jobs/__tests__/lab-biomarker-backfill.test.ts new file mode 100644 index 000000000..463b9a0aa --- /dev/null +++ b/src/lib/jobs/__tests__/lab-biomarker-backfill.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { groupRowsByAnalyte } from "../lab-biomarker-backfill"; + +function row( + id: string, + analyte: string, + unit: string, + takenAt: string, + opts: { low?: number | null; high?: number | null; panel?: string | null } = {}, +) { + return { + id, + analyte, + unit, + referenceLow: opts.low ?? null, + referenceHigh: opts.high ?? null, + panel: opts.panel ?? null, + takenAt: new Date(takenAt), + }; +} + +describe("groupRowsByAnalyte", () => { + it("collapses case/spacing variants of the same analyte into one group", () => { + const groups = groupRowsByAnalyte([ + row("a", "LDL", "mg/dL", "2026-01-01T00:00:00Z"), + row("b", "ldl", "mg/dl", "2026-02-01T00:00:00Z"), + row("c", " LDL ", "mg/dL", "2026-03-01T00:00:00Z"), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].ids.sort()).toEqual(["a", "b", "c"]); + }); + + it("uses the most-recently-taken reading for the canonical name/unit/range", () => { + // Same analyte key ("ldl"), case + unit + range drift across reports — + // the newest report's spelling/unit/range wins. + const groups = groupRowsByAnalyte([ + row("old", "ldl", "mg/dl", "2026-01-01T00:00:00Z", { high: 100 }), + row("new", "LDL", "mg/dL", "2026-06-01T00:00:00Z", { high: 116 }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].name).toBe("LDL"); + expect(groups[0].unit).toBe("mg/dL"); + expect(groups[0].referenceHigh).toBe(116); + }); + + it("keeps distinct analytes in separate groups", () => { + const groups = groupRowsByAnalyte([ + row("a", "LDL", "mg/dL", "2026-01-01T00:00:00Z"), + row("b", "HDL", "mg/dL", "2026-01-01T00:00:00Z"), + ]); + expect(groups).toHaveLength(2); + }); + + it("skips a row with a blank analyte", () => { + const groups = groupRowsByAnalyte([ + row("a", " ", "mg/dL", "2026-01-01T00:00:00Z"), + row("b", "LDL", "mg/dL", "2026-01-01T00:00:00Z"), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].name).toBe("LDL"); + }); +}); diff --git a/src/lib/jobs/lab-biomarker-backfill.ts b/src/lib/jobs/lab-biomarker-backfill.ts new file mode 100644 index 000000000..a9cd8d6bb --- /dev/null +++ b/src/lib/jobs/lab-biomarker-backfill.ts @@ -0,0 +1,217 @@ +/** + * v1.18.1 — one-shot backfill that links legacy `LabResult` rows to a + * user-scoped `Biomarker` catalog entry. + * + * Before the catalog existed, every lab reading carried its own free-text + * `analyte` / `unit` / `reference*`. Recording "LDL" three times forked the + * "same" marker into "LDL" / "ldl" / "LDL-C" with possibly diverging units + * and ranges. This pass heals that at the source: for each user, it groups + * every un-linked live reading by `lower(analyte)`, creates ONE `Biomarker` + * per group (using the most-recently-taken reading's spelling / unit / + * reference bounds — "last lab report wins"), and stamps `biomarkerId` on + * every row in the group. + * + * Idempotent across reboots: the discovery predicate is "the user has at + * least one live reading with `biomarkerId IS NULL`". A completed pass leaves + * every reading linked, so the user drops out of the discovery set. A reboot + * mid-pass re-runs from scratch — `Biomarker` creation upserts on + * `(userId, name)` and the row-link `updateMany` is `biomarkerId: null`-gated, + * so the result converges. A reading whose marker already exists (e.g. the + * user defined it by hand) re-uses that row rather than minting a duplicate. + * + * Modelled on `sleep-timeline-backfill.ts` / `whoop-backfill.ts`. The queue + * name MUST be registered in `allQueues` in `src/lib/jobs/reminder-worker.ts` + * or pg-boss never provisions it and the boot enqueue silently never drains + * (the v1.4.37 dead-queue class). + */ +import { prisma } from "@/lib/db"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { annotate } from "@/lib/logging/context"; + +export const LAB_BIOMARKER_BACKFILL_QUEUE = "lab-biomarker-backfill"; + +/** + * Serial concurrency — the per-user pass is a short burst of grouped writes; + * concurrency-1 keeps it off the request pool, matching the other backfill + * queues. + */ +export const LAB_BIOMARKER_BACKFILL_CONCURRENCY = 1; + +export interface LabBiomarkerBackfillPayload { + userId: string; + enqueuedAt: string; +} + +/** A single legacy reading the grouping reads. */ +interface LegacyRow { + id: string; + analyte: string; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + panel: string | null; + takenAt: Date; +} + +interface AnalyteGroup { + /** Canonical spelling = the most-recently-taken reading's `analyte`. */ + name: string; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + panel: string | null; + ids: string[]; +} + +/** + * Reduce the user's un-linked rows into one group per `lower(analyte)`. The + * canonical name / unit / range come from the most-recently-TAKEN reading in + * each group ("last lab report wins"); every row id rides into the group so a + * single `updateMany` can link them. Exported for unit testing. + */ +export function groupRowsByAnalyte(rows: LegacyRow[]): AnalyteGroup[] { + const byKey = new Map(); + for (const r of rows) { + const key = r.analyte.trim().toLowerCase(); + if (!key) continue; + const bucket = byKey.get(key); + if (bucket) bucket.push(r); + else byKey.set(key, [r]); + } + + const groups: AnalyteGroup[] = []; + for (const bucket of byKey.values()) { + // Newest reading defines the canonical name / unit / range. + const newest = bucket.reduce((a, b) => + b.takenAt.getTime() >= a.takenAt.getTime() ? b : a, + ); + groups.push({ + name: newest.analyte.trim(), + unit: newest.unit, + referenceLow: newest.referenceLow, + referenceHigh: newest.referenceHigh, + panel: newest.panel, + ids: bucket.map((r) => r.id), + }); + } + return groups; +} + +/** + * Backfill one user: group un-linked live readings, create/reuse a Biomarker + * per group, link the rows. Returns the count of markers touched + rows + * linked. + */ +export async function runLabBiomarkerBackfillForUser( + userId: string, +): Promise<{ markers: number; linked: number }> { + const rows = await prisma.labResult.findMany({ + where: { userId, deletedAt: null, biomarkerId: null }, + select: { + id: true, + analyte: true, + unit: true, + referenceLow: true, + referenceHigh: true, + panel: true, + takenAt: true, + }, + }); + + if (rows.length === 0) { + return { markers: 0, linked: 0 }; + } + + const groups = groupRowsByAnalyte(rows); + + let markers = 0; + let linked = 0; + for (const group of groups) { + // Re-use a hand-defined marker of the same name if one exists; otherwise + // mint it. `(userId, name)` is unique, so a concurrent re-run converges. + const biomarker = await prisma.biomarker.upsert({ + where: { userId_name: { userId, name: group.name } }, + // An existing marker keeps its own (possibly user-tuned) unit / range. + update: {}, + create: { + userId, + name: group.name, + unit: group.unit, + lowerBound: group.referenceLow, + upperBound: group.referenceHigh, + panel: group.panel, + }, + }); + markers += 1; + + const { count } = await prisma.labResult.updateMany({ + where: { id: { in: group.ids }, userId, biomarkerId: null }, + data: { biomarkerId: biomarker.id }, + }); + linked += count; + } + + annotate({ + action: { + name: "labs.biomarker.backfill.complete", + details: { markers, linked }, + }, + }); + return { markers, linked }; +} + +/** + * Boot-time discovery. Finds every user holding at least one un-linked live + * lab reading and enqueues one job each. Idempotent across reboots: a + * completed pass links every reading, dropping the user from the discovery + * set. `singletonKey` coalesces duplicate sends. + * + * Best-effort: errors come back through the result value so worker boot never + * fails because of a backfill miss. + */ +export async function enqueueBootTimeLabBiomarkerBackfill(): Promise<{ + enqueued: number; + skipped: number; + error: string | null; +}> { + const boss = getGlobalBoss(); + if (!boss) { + return { enqueued: 0, skipped: 0, error: null }; + } + + try { + const rows = await prisma.labResult.findMany({ + where: { deletedAt: null, biomarkerId: null }, + select: { userId: true }, + distinct: ["userId"], + }); + + if (rows.length === 0) { + return { enqueued: 0, skipped: 0, error: null }; + } + + let enqueued = 0; + let skipped = 0; + for (const { userId } of rows) { + const payload: LabBiomarkerBackfillPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + const jobId = await boss.send(LAB_BIOMARKER_BACKFILL_QUEUE, payload, { + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + singletonKey: `lab-biomarker-backfill|${userId}`, + }); + if (jobId) enqueued += 1; + else skipped += 1; + } + return { enqueued, skipped, error: null }; + } catch (err) { + return { + enqueued: 0, + skipped: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index db6b4d5b2..672614d04 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -28,6 +28,13 @@ import { enqueueBootTimeSleepTimelineBackfill, type SleepTimelineBackfillPayload, } from "@/lib/jobs/sleep-timeline-backfill"; +import { + LAB_BIOMARKER_BACKFILL_QUEUE, + LAB_BIOMARKER_BACKFILL_CONCURRENCY, + runLabBiomarkerBackfillForUser, + enqueueBootTimeLabBiomarkerBackfill, + type LabBiomarkerBackfillPayload, +} from "@/lib/jobs/lab-biomarker-backfill"; import { reportWorkerError } from "@/lib/jobs/report-worker-error"; import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status"; import { setGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -656,6 +663,11 @@ export async function startReminderWorker() { // pg-boss never provisions it and the boot enqueue silently never drains // (the v1.4.37 dead-queue class). SLEEP_TIMELINE_BACKFILL_QUEUE, + // v1.18.1 — one-shot backfill that links legacy free-text lab readings to + // a user-scoped Biomarker catalog entry (group by `lower(analyte)`). The + // queue MUST be registered here or pg-boss never provisions it and the + // boot enqueue silently never drains (the v1.4.37 dead-queue class). + LAB_BIOMARKER_BACKFILL_QUEUE, // v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook, no OAuth, no // backfill queue — the hourly window walks the recent SGV set). The queue // MUST be registered here or pg-boss never provisions it and the schedule @@ -1114,6 +1126,25 @@ export async function startReminderWorker() { } }, ); + // v1.18.1 — one-shot lab-biomarker backfill. The boot enqueue below sends + // one job per user holding un-linked free-text lab readings; this handler + // groups them by `lower(analyte)`, mints/reuses a Biomarker per group, and + // links the rows. Idempotent — a re-run links only what is still un-linked. + await boss.work( + LAB_BIOMARKER_BACKFILL_QUEUE, + { localConcurrency: LAB_BIOMARKER_BACKFILL_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId } = job.data; + const { markers, linked } = + await runLabBiomarkerBackfillForUser(userId); + workerLog( + "info", + `[lab-biomarker-backfill] user=${userId} markers=${markers} linked=${linked}`, + ); + } + }, + ); // v1.17.0 — Nightscout CGM poll-cohort sync. The hourly cron tick (no // `userId`) walks every configured instance; one user's unreachable host is // warned, not fatal. @@ -1977,6 +2008,33 @@ export async function startReminderWorker() { ); } + // v1.18.1 — fire-and-forget boot discovery for the one-shot lab-biomarker + // backfill. Finds every user holding an un-linked live lab reading and + // enqueues one job each. Idempotent across reboots: a completed pass links + // every reading, dropping the user from the discovery set. Errors come back + // through the helper's result value — worker boot never fails on a miss. + try { + const { enqueued, skipped, error } = + await enqueueBootTimeLabBiomarkerBackfill(); + if (error) { + workerLog( + "error", + `[lab-biomarker-backfill] boot discovery failed: ${error}`, + ); + } else { + workerLog( + "info", + `[lab-biomarker-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + "[lab-biomarker-backfill] boot discovery threw an unexpected error", + err, + ); + } + // v1.7.0 — fire-and-forget boot discovery for the daily-mean // consolidation pass. Finds every user holding live per-sample // high-frequency mean-type rows and enqueues one job per account. From 04c95e5ba1ba7d93febf22b9ad5526f180e406ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:06:08 +0200 Subject: [PATCH 09/48] feat(labs): structured entry, per-biomarker chart, and edit/delete UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the Labs surface to match the rest of the app: - Add a biomarker manager on the Labs page (define/edit/delete markers, seed from a common panel) — the catalog lives with Labs, not Settings. - Replace the free-text add form with a structured one: pick a biomarker, enter the value against its known unit/range; '+ define new' inline. - New /labs/[biomarkerId] detail page with a proper dashboard-style chart (Recharts line + shaded reference band + dashed bounds, range tabs, tooltip) replacing the 72x20 sparkline, plus the reading history with full edit/amend/delete and a delete-Undo toast. - List cards group by linked marker and deep-link to the detail chart. The reference band uses a muted token fill, never an alarm colour; the neutral ReferenceRangeBadge is reused for the in/out-of-range verdict. --- src/app/labs/[biomarkerId]/page.tsx | 51 ++++ src/app/labs/page.tsx | 41 ++- src/components/labs/biomarker-form.tsx | 281 ++++++++++++++++++ src/components/labs/biomarker-manager.tsx | 177 +++++++++++ src/components/labs/lab-biomarker-chart.tsx | 224 ++++++++++++++ src/components/labs/lab-biomarker-detail.tsx | 181 ++++++++++++ src/components/labs/lab-form.tsx | 295 +++++++++++-------- src/components/labs/lab-history-list.tsx | 271 +++++++++++++++++ src/components/labs/lab-list.tsx | 196 ++++++------ src/components/labs/types.ts | 25 ++ 10 files changed, 1514 insertions(+), 228 deletions(-) create mode 100644 src/app/labs/[biomarkerId]/page.tsx create mode 100644 src/components/labs/biomarker-form.tsx create mode 100644 src/components/labs/biomarker-manager.tsx create mode 100644 src/components/labs/lab-biomarker-chart.tsx create mode 100644 src/components/labs/lab-biomarker-detail.tsx create mode 100644 src/components/labs/lab-history-list.tsx diff --git a/src/app/labs/[biomarkerId]/page.tsx b/src/app/labs/[biomarkerId]/page.tsx new file mode 100644 index 000000000..29189ca39 --- /dev/null +++ b/src/app/labs/[biomarkerId]/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +/** + * v1.18.1 — per-biomarker detail page (`/labs/[biomarkerId]`). + * + * Owns auth + the back link and hands the marker id to + * ``, which carries the heading, the dashboard-style + * chart with the reference band, and the editable reading history. + */ +import { use, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { LabBiomarkerDetail } from "@/components/labs/lab-biomarker-detail"; +import { BackLink } from "@/components/ui/back-link"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; + +export default function LabBiomarkerDetailPage({ + params, +}: { + params: Promise<{ biomarkerId: string }>; +}) { + const { biomarkerId } = use(params); + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + const router = useRouter(); + const { t } = useTranslations(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/auth/login"); + } + }, [isLoading, isAuthenticated, router]); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 2030d0510..d0b685f1c 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -3,8 +3,9 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, Plus } from "lucide-react"; +import { FlaskConical, Loader2, Plus } from "lucide-react"; +import { BiomarkerManager } from "@/components/labs/biomarker-manager"; import { LabForm } from "@/components/labs/lab-form"; import { LabList } from "@/components/labs/lab-list"; import { Button } from "@/components/ui/button"; @@ -23,6 +24,7 @@ export default function LabsPage() { const queryClient = useQueryClient(); const { t } = useTranslations(); const [dialogOpen, setDialogOpen] = useState(false); + const [manageOpen, setManageOpen] = useState(false); const refreshVisible = useCallback( () => queryClient.invalidateQueries({ type: "active" }), @@ -30,7 +32,7 @@ export default function LabsPage() { ); const pull = usePullToRefresh({ onRefresh: refreshVisible, - disabled: dialogOpen, + disabled: dialogOpen || manageOpen, }); useEffect(() => { @@ -59,13 +61,25 @@ export default function LabsPage() { {t("labs.subtitle")}

- +
+ + +
+ + + + setDialogOpen(true)} /> ); diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx new file mode 100644 index 000000000..e814ef717 --- /dev/null +++ b/src/components/labs/biomarker-form.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { + BIOMARKER_CATALOG, + BIOMARKER_PANELS, +} from "@/lib/labs/biomarker-catalog"; +import { useTranslations } from "@/lib/i18n/context"; + +import type { BiomarkerDto } from "./types"; + +const CONTEXT_MAX_LENGTH = 2000; + +/** Parse a free-text decimal that may use a comma separator. */ +function parseDecimal(raw: string): number | null { + const trimmed = raw.trim().replace(",", "."); + if (trimmed === "") return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; +} + +interface BiomarkerFormProps { + /** When set the form edits this marker; otherwise it defines a new one. */ + existing?: BiomarkerDto; + onSuccess?: (saved: BiomarkerDto) => void; + onCancel?: () => void; +} + +/** + * v1.18.1 — define or edit a Biomarker catalog entry. + * + * A marker is the Labs feature's reusable definition: name, unit, reference + * bounds (set ONCE, never re-entered per reading), and an optional context + * note. When defining a new marker the user can seed the fields from the + * common-panel catalog (a ` + + + + + {BIOMARKER_PANELS.map((panelKey) => { + const items = BIOMARKER_CATALOG.filter( + (s) => s.panel === panelKey, + ); + if (items.length === 0) return null; + return ( + + + {t(`labs.catalog.panel.${panelKey}`)} + + {items.map((s) => ( + + {t(`labs.catalog.${s.slug}`)} + + ))} + + ); + })} + + +

+ {t("labs.biomarker.form.seedHint")} +

+ + ) : null} + +
+
+ + setName(e.target.value)} + placeholder={t("labs.biomarker.form.namePlaceholder")} + maxLength={120} + autoFocus + required + /> +
+
+ + setUnit(e.target.value)} + placeholder={t("labs.biomarker.form.unitPlaceholder")} + maxLength={40} + required + /> +
+
+ + setPanel(e.target.value)} + placeholder={t("labs.biomarker.form.panelPlaceholder")} + maxLength={120} + /> +
+
+ + setLower(e.target.value)} + placeholder={t("labs.biomarker.form.lowerPlaceholder")} + /> +
+
+ + setUpper(e.target.value)} + placeholder={t("labs.biomarker.form.upperPlaceholder")} + /> +
+
+ +