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) => ( - - {t(`measurementReminders.types.${type}`)} - + {TYPE_GROUPS.map(({ group, types }) => ( + + {types.map((type) => ( + + {t(`measurementReminders.types.${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")}
+ {t("labs.biomarker.form.seedHint")} +
+ {t("labs.biomarker.form.rangeHint")} +
+ {error} +
+ {t("labs.biomarker.managerDescription")} +
+ {t("labs.biomarker.loadError")} +
+ {range + ? `${t("labs.referenceLabel")} ${range} ${marker.unit}` + : t("labs.biomarker.noRange")} + {marker.panel ? ` · ${marker.panel}` : ""} +
+ {t("labs.chart.empty")} +
+ {t("labs.loadError")} +
+ {marker.unit} + {marker.panel ? ` · ${marker.panel}` : ""} +
{marker.context}
+ {t("labs.form.noBiomarkersHint")} +
- {t("labs.form.referenceHint")} -
+ {t("labs.referenceLabel")} {referenceText} {selected.unit} +
- {error} -
+ {formatDateShort(r.takenAt, true)} + {r.hasNote ? ` · ${t("labs.hasNote")}` : ""} +
+ {editError} +
+ {t("illness.onsetOn", { date: fmt.dateShort(new Date(episode.onsetAt)) })} + {episode.resolvedAt + ? ` · ${t("illness.recoveredOn", { + date: fmt.dateShort(new Date(episode.resolvedAt)), + })}` + : ""} +
+ {t("illness.subtitle")} +
{t("illness.disclaimer")}
{t("common.loading")}
{t("illness.empty.title")}
+ {t("illness.empty.body")} +
+ {t("illness.sheet.saveError")} +
+ {t("illness.new.saveError")} +
+ + {t( + settled === "accepted" + ? "coach.reminderSuggestion.accepted" + : settled === "stopped" + ? "coach.reminderSuggestion.stopped" + : "coach.reminderSuggestion.dismissed", + )} +
+ {t("coach.settings.reminderSuggestionsDescription")} +
+ {t("admin.section.backups.description")}{" "} + {/* External docs link — `noopener noreferrer` because this + leaves the admin shell. */} + + + {t("admin.section.backups.docsLink")} + +
- {t("admin.section.backups.description")}{" "} - {/* External docs link — Phase E will publish the matching page on - the docs site. `noopener noreferrer` because this leaves the - admin shell. */} - - - {t("admin.section.backups.docsLink")} - -
{t("admin.deleteAllData")}
- {t("admin.deleteAllDescription")} -
- {wipeMsg} -
+ {wipeMsg} +
- {t("settings.about.tourReplayHint")} -
+ {t("settings.about.tourReplayHint")} +
+ {tourFeedback.text} +
- {tourFeedback.text} -
+ {showPhase && dayOfCycle != null + ? t("cycle.sheet.phaseDayHeader", { + day: dayOfCycle, + phase: t(`cycle.phase.${phase}`), + }) + : dayOfCycle != null + ? t("cycle.ring.dayOfCycle", { day: dayOfCycle }) + : t("cycle.phaseEducation.stillLearning")} +
+ {t("cycle.sheet.bbtHint")} +
{label}
+ {explainer} +
- {t("settings.sections.about.description")} -
- {t("settings.sections.account.description")} -
- {t("onboarding.tour.restartHint")} -
- {t(tourFeedback.key)} -
- {t("settings.sections.advanced.description")} -
+ {t("onboarding.tour.restartHint")} +
+ {t(feedback.key)} +
- {t("settings.sections.ai.description")} -
- {t("settings.sections.api.description")} -
- {t("settings.sections.coach.description")} -
- {t("settings.sections.dashboard.description")} -
- {t("settings.sections.export.description")} -
- {t("settings.sections.gesundheitsakte.description")} -
- {t("settings.sections.insights.description")} -
- {t("settings.sections.integrations.description")} -
- {t("settings.sections.layout.description")} -
- {t("settings.sections.medications.description")} -
- {t("settings.sections.modules.description")} -
- {t("settings.sections.mood.description")} -
- {t("settings.sections.notifications.description")} -
- {t("settings.sections.notifications.channelsHint")}{" "} - - {t("settings.sections.notifications.channelsHintLink")} - - {" · "} - - {t("settings.sections.notifications.inboxLink")} - -
- {t("settings.sections.sources.description")} -
{t("settings.sections.sources.integrationsHint")}{" "} - + {t("settings.sections.thresholds.title")} - - {t("settings.sections.thresholds.description")} - {/* Per-metric threshold ranges. */} From 0338f18b50d791d07892297d2b7e9487fbf433a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:53:16 +0200 Subject: [PATCH 23/48] refactor(worker): extract reminder-worker boot lifecycle into a focused module Move the SIGTERM/SIGINT graceful-shutdown binding and the three fire-and-forget boot maintenance passes (legacy mood-log secret rotation, orphan ImportJob reconcile, integration-status null probe) out of startReminderWorker into src/lib/jobs/reminder/worker-lifecycle.ts. Behaviour-preserving: the relocated logic is unchanged, and none of the pg-boss queue declarations, the allQueues array, the schedules table, the boss.work bindings, or the boot-discovery enqueues moved. Those stay co-located in reminder-worker.ts so the source-text registration guards (the dead-queue catch) keep verifying every queue is registered and bound. The full jobs guard suite stays green. --- src/lib/jobs/reminder-worker.ts | 94 ++++-------------- src/lib/jobs/reminder/worker-lifecycle.ts | 111 ++++++++++++++++++++++ 2 files changed, 128 insertions(+), 77 deletions(-) create mode 100644 src/lib/jobs/reminder/worker-lifecycle.ts diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 120f934fc..7bfd591b9 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -124,7 +124,6 @@ import { APPLE_HEALTH_IMPORT_QUEUE, APPLE_HEALTH_IMPORT_CONCURRENCY, handleAppleHealthImport, - reconcileOrphanImportJobs, type AppleHealthImportPayload, } from "@/lib/jobs/apple-health-import-worker"; import { @@ -183,8 +182,6 @@ import { enqueueBootTimeIntakeSlotDedup, type IntakeSlotDedupPayload, } from "@/lib/medications/intake-slot-dedup"; -import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret"; -import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe"; import { withBackgroundEvent } from "@/lib/logging/background"; import { assertSubsystemEnabled } from "@/lib/process-type"; import { @@ -193,6 +190,12 @@ import { RESTORE_DRILL_QUEUE, } from "@/lib/jobs/restore-drill"; import { DATABASE_URL, getWorkerPrisma, workerLog } from "./reminder/shared"; +import { + registerShutdownHandlers, + rotateLegacyMoodLogSecretsAtBoot, + reconcileImportJobsAtBoot, + probeIntegrationStatusAtBoot, +} from "./reminder/worker-lifecycle"; import { ReminderCheckPayload, handleReminderCheck, @@ -563,56 +566,12 @@ export async function startReminderWorker() { setGlobalBoss(boss); markWorkerStarted(); - // V3 audit STILL-V2-C-2: encrypt-at-rest one-shot migration. Rotates - // any rows that still hold a plaintext mood_log_webhook_secret to the - // AES-256-GCM envelope. Idempotent — encrypted rows are skipped. - try { - const p = getWorkerPrisma(); - const rotated = await rotateLegacyMoodLogSecrets({ - findLegacy: () => - p.user.findMany({ - where: { moodLogWebhookSecret: { not: null } }, - select: { id: true, moodLogWebhookSecret: true }, - }), - rotate: async (id, encryptedSecret) => { - await p.user.update({ - where: { id }, - data: { moodLogWebhookSecret: encryptedSecret }, - }); - }, - }); - if (rotated > 0) { - workerLog( - "error", - `moodlog-secret-migration: rotated ${rotated} legacy plaintext secret(s)`, - ); - } - } catch (err) { - workerLog("error", `moodlog-secret-migration failed: ${err}`); - } + // V3 audit STILL-V2-C-2: encrypt-at-rest one-shot migration for any + // plaintext mood_log_webhook_secret rows. Idempotent. + await rotateLegacyMoodLogSecretsAtBoot(); - // Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT (sent by - // Docker Compose `docker stop`, Kubernetes pod termination, Coolify - // redeploys). Without this, pending handlers were force-killed and could - // either be lost or replayed on next start. We only register the listeners - // once — re-entering startReminderWorker (e.g. on hot-reload in dev) is - // a no-op for the handlers because they capture `boss` by closure and the - // first signal stops everything. - let shutdownInProgress = false; - const onSignal = async (signal: "SIGTERM" | "SIGINT") => { - if (shutdownInProgress) return; - shutdownInProgress = true; - workerLog("error", `received ${signal}, draining boss`); - try { - // graceful=true waits for active handlers to finish, then closes the - // pg connection pool. timeout cap so a stuck handler can't block deploys. - await boss.stop({ graceful: true, timeout: 30_000 }); - } catch (err) { - workerLog("error", "boss.stop failed during shutdown", err); - } - }; - process.once("SIGTERM", () => void onSignal("SIGTERM")); - process.once("SIGINT", () => void onSignal("SIGINT")); + // Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT. + registerShutdownHandlers(boss); // pg-boss v12 requires explicit queue creation before scheduling const allQueues = [ @@ -834,32 +793,13 @@ export async function startReminderWorker() { await boss.createQueue(q); } - // v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when - // the worker last shut down. Flips orphaned rows to `failed` so the - // operator can re-upload without leaving the polling endpoint - // stuck on `parsing` / `upserting`. - try { - await reconcileOrphanImportJobs(); - } catch (err) { - workerLog("error", "Failed to reconcile orphan ImportJob rows", err); - } + // v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when the + // worker last shut down, flipping orphaned rows to `failed`. + await reconcileImportJobsAtBoot(); - // v1.4.48 M1 — boot probe for legacy `integration_statuses` rows - // that still carry `consecutive_failures_by_kind = NULL`. After - // v1.4.47 dropped the single-column fallback, such rows alert two - // strikes later than they did pre-upgrade. The probe is a single - // count query + Wide-Event warning if any survive; fire-and-forget - // so a probe failure never blocks worker boot. - try { - await withBackgroundEvent( - "worker.boot.integration_status_null_probe", - async () => { - await probeIntegrationStatusNullBuckets(getWorkerPrisma()); - }, - ); - } catch (err) { - workerLog("error", "integration-status-null-probe failed", err); - } + // v1.4.48 M1 — boot probe for legacy `integration_statuses` rows that still + // carry `consecutive_failures_by_kind = NULL`. + await probeIntegrationStatusAtBoot(); // v1.15.20 — retry policy for the LLM-bound insight queues. A transient // failure (provider hiccup, pool exhaustion) used to fail the nightly tick diff --git a/src/lib/jobs/reminder/worker-lifecycle.ts b/src/lib/jobs/reminder/worker-lifecycle.ts new file mode 100644 index 000000000..9b54fb85b --- /dev/null +++ b/src/lib/jobs/reminder/worker-lifecycle.ts @@ -0,0 +1,111 @@ +/** + * Reminder-worker boot lifecycle helpers. + * + * These run once per worker process around the queue/schedule/handler wiring + * in `reminder-worker.ts`: a graceful-shutdown signal binding plus three + * fire-and-forget boot maintenance passes. None of them touch the pg-boss + * queue declarations, the `allQueues` array, the `schedules` table, or the + * `boss.work` bindings — those stay co-located in `reminder-worker.ts` so the + * source-text registration guards (the v1.4.37 dead-queue catch) keep working. + */ +import type { PgBoss } from "pg-boss"; + +import { reconcileOrphanImportJobs } from "@/lib/jobs/apple-health-import-worker"; +import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret"; +import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe"; +import { withBackgroundEvent } from "@/lib/logging/background"; + +import { getWorkerPrisma, workerLog } from "./shared"; + +/** + * Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT (sent by Docker + * Compose `docker stop`, Kubernetes pod termination, Coolify redeploys). + * Without this, pending handlers were force-killed and could either be lost + * or replayed on next start. The listeners register once — re-entering the + * worker boot (e.g. on hot-reload in dev) is a no-op for the handlers because + * they capture `boss` by closure and the first signal stops everything. + */ +export function registerShutdownHandlers(boss: PgBoss): void { + let shutdownInProgress = false; + const onSignal = async (signal: "SIGTERM" | "SIGINT") => { + if (shutdownInProgress) return; + shutdownInProgress = true; + workerLog("error", `received ${signal}, draining boss`); + try { + // graceful=true waits for active handlers to finish, then closes the + // pg connection pool. timeout cap so a stuck handler can't block deploys. + await boss.stop({ graceful: true, timeout: 30_000 }); + } catch (err) { + workerLog("error", "boss.stop failed during shutdown", err); + } + }; + process.once("SIGTERM", () => void onSignal("SIGTERM")); + process.once("SIGINT", () => void onSignal("SIGINT")); +} + +/** + * V3 audit STILL-V2-C-2: encrypt-at-rest one-shot migration. Rotates any rows + * that still hold a plaintext mood_log_webhook_secret to the AES-256-GCM + * envelope. Idempotent — encrypted rows are skipped. Never throws; a failure + * is logged and boot continues. + */ +export async function rotateLegacyMoodLogSecretsAtBoot(): Promise { + try { + const p = getWorkerPrisma(); + const rotated = await rotateLegacyMoodLogSecrets({ + findLegacy: () => + p.user.findMany({ + where: { moodLogWebhookSecret: { not: null } }, + select: { id: true, moodLogWebhookSecret: true }, + }), + rotate: async (id, encryptedSecret) => { + await p.user.update({ + where: { id }, + data: { moodLogWebhookSecret: encryptedSecret }, + }); + }, + }); + if (rotated > 0) { + workerLog( + "error", + `moodlog-secret-migration: rotated ${rotated} legacy plaintext secret(s)`, + ); + } + } catch (err) { + workerLog("error", `moodlog-secret-migration failed: ${err}`); + } +} + +/** + * v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when the worker + * last shut down. Flips orphaned rows to `failed` so the operator can + * re-upload without leaving the polling endpoint stuck on `parsing` / + * `upserting`. Never throws. + */ +export async function reconcileImportJobsAtBoot(): Promise { + try { + await reconcileOrphanImportJobs(); + } catch (err) { + workerLog("error", "Failed to reconcile orphan ImportJob rows", err); + } +} + +/** + * v1.4.48 M1 — boot probe for legacy `integration_statuses` rows that still + * carry `consecutive_failures_by_kind = NULL`. After v1.4.47 dropped the + * single-column fallback, such rows alert two strikes later than they did + * pre-upgrade. The probe is a single count query + Wide-Event warning if any + * survive; fire-and-forget so a probe failure never blocks worker boot. + */ +export async function probeIntegrationStatusAtBoot(): Promise { + try { + await withBackgroundEvent( + "worker.boot.integration_status_null_probe", + async () => { + await probeIntegrationStatusNullBuckets(getWorkerPrisma()); + }, + ); + } catch (err) { + workerLog("error", "integration-status-null-probe failed", err); + } +} From e51fb49b896ebae356395912745a6e6e2b63a1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:54:08 +0200 Subject: [PATCH 24/48] refactor(coach): rework the coach card layout for balance and breathing room Rebuild the coach conversation surface so it uses the full width without feeling cramped: - Widen the inline history rail (260px -> 300px on lg, 340px on xl) and bump rail typography + padding off the cramped 10/11px scale so the conversation list and source toggles read at a comfortable size. - Align the dividers across columns: the header, the history-rail heading, and the centre rail-tray strip now share one fixed h-14 band with identical borders, so the lines land on the same row instead of the restless two-height split. - Redesign the header leading cluster: the surface-toggle sits in its own group behind a hairline divider rather than crowding the avatar, which steps up to size-9. - Give the full-page coach card more vertical room and a soft shadow so it fills the standalone route instead of leaving a wasted band below. - Even the coach FAB corner inset so the right gap matches the bottom gap at each breakpoint. --- src/app/coach/page.tsx | 9 +++++++- .../__tests__/layout-coach-fab.test.tsx | 9 +++++--- .../coach-panel/coach-conversation.tsx | 17 +++++++++++--- .../coach-panel/coach-drawer-body.tsx | 22 +++++++++++++++---- .../insights/coach-panel/history-rail.tsx | 18 ++++++++------- .../insights/coach-panel/sources-rail.tsx | 22 ++++++++++--------- src/components/insights/layout-coach-fab.tsx | 13 +++++++---- 7 files changed, 77 insertions(+), 33 deletions(-) diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 77e3efe36..23363d12e 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -61,7 +61,14 @@ export default function CoachPage() { return ( ", () => { // Fixed bottom-right, above the mobile bottom-nav band. The plain // desktop offset kicks in at `md:` — the same breakpoint where the // bottom-nav hides (`md:hidden`) — so the FAB never floats mid-air - // in the 768-1023px band. The right inset mirrors the desktop - // bottom inset so the corner gap is even. + // in the 768-1023px band. v1.18.1 (C5) — the corner inset is + // symmetric: right equals bottom at each breakpoint (`right-6` + // mobile-band / `md:right-8` desktop mirrors `md:bottom-8`). expect(html).toContain("fixed right-6"); + expect(html).toContain("md:right-8"); expect(html).toContain( "bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]", ); - expect(html).toContain("md:bottom-6"); + expect(html).toContain("md:bottom-8"); + expect(html).not.toContain("md:bottom-6"); expect(html).not.toContain("lg:bottom-6"); }); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 81869d47f..abbeb801f 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -291,12 +291,23 @@ export function CoachConversation({ > - {leadingHeaderActions} + {leadingHeaderActions ? ( + + {leadingHeaderActions} + + ) : null} diff --git a/src/components/insights/coach-panel/coach-drawer-body.tsx b/src/components/insights/coach-panel/coach-drawer-body.tsx index f8ba32ce7..a4fc9a33a 100644 --- a/src/components/insights/coach-panel/coach-drawer-body.tsx +++ b/src/components/insights/coach-panel/coach-drawer-body.tsx @@ -53,9 +53,13 @@ export function CoachDrawerBody({ return ( @@ -65,7 +69,13 @@ export function CoachDrawerBody({ data-slot="coach-drawer-history" className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col" > - + {/* v1.18.1 (W-COACH-UI C4) — the rail heading and the centre + column's rail-tray strip share one fixed `h-14` header band + with identical `border-b`, so the two dividers land on the + same horizontal line across the columns (the asymmetry the + maintainer flagged: a `p-3` text heading vs a `py-2` button + strip put the lines at different heights). */} + {t("insights.coach.historyTitle")} {historyRail} @@ -80,10 +90,14 @@ export function CoachDrawerBody({ {/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the absolute overlay into a sub-header strip so the buttons sit at a 44 px tap target and never overlay the first - message bubble. */} + message bubble. + v1.18.1 (W-COACH-UI C4) — fixed `h-14` so the strip's + bottom border aligns with the history rail heading divider + in the adjacent column (one continuous line, no restless + two-height split). */} {/* v1.4.33 — promote the rail label from a `` to a real `` so the drawer carries a semantic outline on desktop @@ -78,7 +80,7 @@ export function HistoryRail({ (the rail still has to stand alone on desktop). */} setFilter(e.target.value)} placeholder={t("insights.coach.historySearchPlaceholder")} data-slot="coach-history-search" - className="h-8 pl-7 text-xs" + className="h-9 pl-7 text-sm" /> {t("common.loading")} ) : filtered.length === 0 ? ( {t("insights.coach.historyEmpty")} @@ -128,8 +130,8 @@ export function HistoryRail({ data-slot="coach-history-item" data-active={isActive ? "true" : undefined} className={cn( - "group flex items-center gap-1.5 rounded-md px-2 py-1.5", - "text-xs transition-colors", + "group flex items-center gap-1.5 rounded-lg px-2.5 py-2", + "text-sm transition-colors", isActive ? "bg-dracula-purple/15 text-foreground" : "text-muted-foreground hover:bg-muted/40 hover:text-foreground", @@ -142,7 +144,7 @@ export function HistoryRail({ data-slot="coach-history-select" > {c.title} - + {formatRelativeTime(c.updatedAt, t)} diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index a7280373c..2720f07eb 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -105,7 +105,7 @@ export function SourcesRail({ className }: SourcesRailProps) { // column width: the switch rows used to push past the 280/320 px // container and paint a horizontal scrollbar under the rail. className={cn( - "flex h-full min-h-0 min-w-0 flex-col gap-3 overflow-x-hidden p-3", + "flex h-full min-h-0 min-w-0 flex-col gap-3 overflow-x-hidden p-3.5", className, )} > @@ -113,8 +113,10 @@ export function SourcesRail({ className }: SourcesRailProps) { data-slot="coach-sources-rail-heading" // No horizontal inset — the heading sits flush with the row // boxes below it (headings and their content share one left - // edge across the Coach surfaces). - className="text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase" + // edge across the Coach surfaces). v1.18.1 (W-COACH-UI C1/C4) — + // typography matched to the history-rail heading so the two rails + // read as one family. + className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium tracking-wide uppercase" > {t("insights.coach.sourcesTitle")} @@ -126,7 +128,7 @@ export function SourcesRail({ className }: SourcesRailProps) { {t("insights.coach.windowLabel")} @@ -139,13 +141,13 @@ export function SourcesRail({ className }: SourcesRailProps) { id="coach-sources-window-select" data-slot="coach-sources-window-trigger" size="default" - className="h-9 text-xs" + className="h-9 text-sm" > {WINDOW_OPTIONS.map((w) => ( - + {t(`insights.coach.window.${w}`)} ))} @@ -172,19 +174,19 @@ export function SourcesRail({ className }: SourcesRailProps) { data-active={checked ? "true" : "false"} className={cn( "border-border/60 bg-muted/30 flex items-center gap-2.5", - "rounded-md border px-2.5", - "min-h-11 py-1.5", + "rounded-lg border px-3", + "min-h-11 py-2", !checked && "opacity-60", )} > {t(`insights.coach.cluster.${cluster}.label`)} - + {t("insights.coach.sourcesMemberCount", { count: memberCount, })} diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 144045537..c20b985ad 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -157,10 +157,15 @@ export function LayoutCoachFab() { // safe-area inset on mobile; plain bottom offset once the // bottom-nav hides (`md:hidden` on the nav, so `md:` here — // not `lg:` — keeps the FAB from floating mid-air 768-1023px). - // The right inset mirrors the desktop bottom inset so the - // corner gap is even. - "fixed right-6 z-40 size-14 rounded-full shadow-lg", - "bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-6", + // v1.18.1 (W-COACH-UI C5) — the corner inset is now symmetric: + // the right inset equals the bottom inset at each breakpoint + // (`right-6`/`bottom-6` mobile-band, `md:right-8`/`md:bottom-8` + // desktop). The FAB was hugging the right edge (`right-6`) + // while the desktop bottom sat at the same `6`, but it read as + // off-centre against the larger mobile bottom band; pulling it + // a touch further left + matching both offsets evens the gap. + "fixed right-6 z-40 size-14 rounded-full shadow-lg md:right-8", + "bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-8", // Dark glyph on the purple/pink gradient — white sat at // ≈2.3:1 against the gradient midpoint; the background token // reads ≈6.5:1. From 651aa65c28c48a229a8caca2c043616d568a904b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:54:25 +0200 Subject: [PATCH 25/48] fix(dashboard): flatten the hero band to match the tile + chart surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily-verdict band wore a gradient surface and a health-score ring with a drop-shadow bloom, a green-band breathing pulse, a conic sheen, and a sweep entrance. It read louder than the chart cards and tiles it sits among. Drop the gradient: the band now wears the same bg-card + border + radius + padding as , and the dose chip sits on a neutral muted surface (no card tint, blur, or shadow). Add a flat treatment to that suppresses the bloom, pulse, sheen, and entrance sweep, and pass it on the hero ring only. The wellness strip and anatomy view keep the full reveal. Reduced-motion handling is unchanged. Information is untouched — only the visual treatment changes. --- src/app/globals.css | 5 ++++ .../hero/__tests__/dashboard-hero.test.tsx | 27 ++++++++++++------- .../dashboard/hero/dashboard-hero.tsx | 20 ++++++++------ .../insights/derived/score-ring.tsx | 22 +++++++++++---- 4 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 46b5f1dc9..417fb9536 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -705,6 +705,11 @@ 0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent) ); } +/* Flat 2-D arc — no bloom, no pulse. The dashboard hero ring uses this so it + reads as calmly as the chart + tile surfaces around it. */ +.wellness-ring-arc--flat { + filter: none; +} .wellness-ring-arc--pulse { /* Breathe a few times to celebrate a strong score, then settle — an infinite loop would repaint the (non-compositable) drop-shadow filter diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 30ee06eb4..451b8af0a 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -361,7 +361,7 @@ describe(" — verdict variants", () => { }); describe(" — dose row", () => { - it("prints the day tally with the v1.16.6 card chrome and the Pill icon", () => { + it("prints the day tally with the neutral chip chrome and the Pill icon", () => { const html = render( baseSnapshot({ medsToday: medsToday({ scheduledToday: 3, takenToday: 1 }), @@ -372,15 +372,13 @@ describe(" — dose row", () => { /]*data-slot="dashboard-hero-doses"[^>]*>/, ); expect(doseRow).not.toBeNull(); - for (const cls of [ - "bg-card/65", - "border-border/60", - "rounded-xl", - "shadow-sm", - "backdrop-blur-sm", - ]) { + // v1.18.1: chip sits on a neutral muted surface over the plain card — + // no translucent card tint, no blur, no shadow (the gradient is gone). + for (const cls of ["bg-muted/50", "border-border/60", "rounded-xl"]) { expect(doseRow![0]).toContain(cls); } + expect(doseRow![0]).not.toContain("backdrop-blur"); + expect(doseRow![0]).not.toContain("shadow-sm"); }); it("all doses resolved (taken + skipped) → 'Alle Dosen für heute erledigt'", () => { @@ -581,8 +579,17 @@ describe(" — source pins", () => { expect(src).toMatch(/resolveDashboardVerdict\(snapshot,\s*new Date\(\)\)/); }); - it("uses the hero-gradient chrome WITHOUT glow-purple", () => { - expect(src).toContain("hero-gradient"); + it("wears the plain card surface — no gradient, no glow", () => { + // v1.18.1: the band drops the gradient hero treatment and sits on the + // same `bg-card` + border + radius as the surrounding chart cards. + expect(src).toContain("bg-card"); + expect(src).toContain("border-border"); + expect(src).toContain("rounded-xl"); + expect(src).not.toContain("hero-gradient"); expect(src).not.toContain("glow-purple"); }); + + it("renders the score ring flat (no bloom / pulse / sheen / sweep)", () => { + expect(src).toMatch(/]*\n(?:[^>]*\n)*?\s*flat\b/); + }); }); diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index 6daeb2566..4e3096396 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -15,8 +15,10 @@ * band's anchor, larger and heavier than the verdict so the * personalised line is not drowned by the content below it), the * verdict sentence with its single CTA button, and the dose row; - * - right column: the shared `` at `sm` geometry. A null - * score renders the ring's provisional state at the identical + * - right column: the shared `` at `sm` geometry, in its + * `flat` treatment (no bloom / pulse / sheen / sweep) so the dial + * sits as calmly as the surrounding chart cards. A null score + * renders the ring's provisional state at the identical * 120 px footprint, so the column never collapses. The provisional * label is honest about WHY the score is null: when the snapshot * already carries score inputs (weight / BP summaries, mood @@ -220,11 +222,12 @@ export function DashboardHero({ `). No + // gradient, no glow — it reads as one of the dashboard tiles. + "bg-card border-border relative isolate overflow-hidden rounded-xl border", + "min-h-[8.75rem] p-4 md:min-h-[9.5rem] md:p-6", )} > @@ -272,7 +275,7 @@ export function DashboardHero({ - {/* Conic specular sheen — sweeps the arc once on the strip reveal. */} - + {/* Conic specular sheen — sweeps the arc once on the strip reveal. + Omitted entirely in the flat treatment (dashboard hero). */} + {flat ? null : } {/* Centred number as real DOM text — design tokens + tabular-nums. */} Date: Tue, 16 Jun 2026 21:56:54 +0200 Subject: [PATCH 26/48] docs(openapi): publish the illness episode + day-log CRUD contract Register /api/illness/episodes (list + create), the {id} resource (read + edit + soft-delete), the {id}/resolve verb, and the {id}/day-logs upsert in the OpenAPI route table, and add the Illness tag. Request bodies + queries reuse the runtime Zod schemas from validations/illness so the wire contract stays single-source; the response shapes mirror the server-authoritative episode + day-log DTOs. Regenerated docs/api/openapi.yaml; openapi:check is in sync. --- docs/api/openapi.yaml | 622 ++++++++++++++++++++++++++++++ src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes/illness.ts | 329 ++++++++++++++++ src/lib/openapi/routes/index.ts | 2 + 4 files changed, 954 insertions(+) create mode 100644 src/lib/openapi/routes/illness.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cb1029861..f66bd3171 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -41,6 +41,7 @@ tags: - name: Cycle - name: MeasurementReminders - name: Labs + - name: Illness - name: Admin - name: Meta paths: @@ -6167,6 +6168,373 @@ paths: "404": *a9 "422": *a3 "429": *a4 + /api/illness/episodes: + get: + tags: + - Illness + summary: List illness episodes (v1.18.1) + description: Returns the caller's live (non-deleted) illness/condition episodes, newest-first by onset. + `includeResolved=false` hides episodes that already carry a `resolvedAt`. Born-gated. + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + - in: query + name: includeResolved + schema: + anyOf: + - type: string + const: "true" + - type: string + const: "false" + responses: + "200": + description: The caller's illness episodes. + content: + application/json: + schema: + $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Illness + summary: Open an illness episode (v1.18.1) + description: Creates one episode for the caller. `onsetAt` defaults to 'now' when omitted; the optional note is + AES-256-GCM encrypted before write. A `parentConditionId` must reference an owned, live episode. Audits as + `illness.episode.create`. Born-gated. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 120 + type: + type: string + enum: + - INFECTION + - ALLERGY + - INJURY + - MENTAL_HEALTH + - AUTOIMMUNE + - CHRONIC + - OTHER + lifecycle: + default: ACUTE + type: string + enum: + - ACUTE + - CHRONIC_ONGOING + - RECURRING + - FLARE + onsetAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + resolvedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + parentConditionId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + required: + - label + - type + responses: + "201": + description: Episode created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/illness/episodes/{id}: + get: + tags: + - Illness + summary: Read a single illness episode (v1.18.1) + description: Returns the episode including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s. Born-gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The episode. + content: + application/json: + schema: + $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" + "401": *a1 + "404": &a11 + description: Illness episode not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + patch: + tags: + - Illness + summary: Edit an illness episode (v1.18.1) + description: Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and + never the episode itself). Audits as `illness.episode.update`. Owner-scoped + born-gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 120 + type: + type: string + enum: + - INFECTION + - ALLERGY + - INJURY + - MENTAL_HEALTH + - AUTOIMMUNE + - CHRONIC + - OTHER + lifecycle: + type: string + enum: + - ACUTE + - CHRONIC_ONGOING + - RECURRING + - FLARE + onsetAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + resolvedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + parentConditionId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + additionalProperties: false + responses: + "200": + description: Episode updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 + delete: + tags: + - Illness + summary: Soft-delete an illness episode (v1.18.1) + description: Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns 204 No Content. Audits as + `illness.episode.delete`. Owner-scoped + born-gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "204": + description: Soft-deleted (no body). + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 + /api/illness/episodes/{id}/resolve: + patch: + tags: + - Illness + summary: Mark an illness episode recovered (v1.18.1) + description: Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery + date and 422s with `errorCode:"illness.episode.chronic-no-resolve"`. Audits as `illness.episode.resolve`. + Owner-scoped + born-gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + resolvedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + responses: + "200": + description: Episode resolved. + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 + /api/illness/episodes/{id}/day-logs: + get: + tags: + - Illness + summary: Read one day-log for an episode (v1.18.1) + description: Returns the day-log for the episode + `date`, or `null` when nothing is logged that day (lets the log-day + sheet pre-fill). The parent episode must be owned + live. Born-gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: date + schema: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + required: true + responses: + "200": + description: The day-log, or null. + content: + application/json: + schema: + $ref: "#/components/schemas/GetIllnessDayLogEnvelope" + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 + post: + tags: + - Illness + summary: Upsert an episode day-log (v1.18.1) + description: "Upserts the day's symptom / functional-impact / fever timeline row on `(episodeId, date)`: 201 on insert, + 200 on update. The parent episode must be owned + live; the optional note is encrypted at rest. Audits as + `illness.day-log.upsert`. Born-gated." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + functionalImpact: + anyOf: + - type: integer + minimum: 0 + maximum: 3 + - type: "null" + feverC: + anyOf: + - type: number + minimum: 30 + maximum: 45 + - type: "null" + symptoms: + maxItems: 40 + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + severity: + type: integer + minimum: 0 + maximum: 3 + required: + - key + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - date + responses: + "200": + description: Existing day-log updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertIllnessDayLogUpdatedEnvelope" + "201": + description: New day-log created. + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -17822,6 +18190,260 @@ components: - data - error additionalProperties: false + ListIllnessEpisodesEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/IllnessEpisode" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + IllnessEpisode: + type: object + properties: + id: + type: string + label: + type: string + type: + type: string + enum: + - INFECTION + - ALLERGY + - INJURY + - MENTAL_HEALTH + - AUTOIMMUNE + - CHRONIC + - OTHER + lifecycle: + type: string + enum: + - ACUTE + - CHRONIC_ONGOING + - RECURRING + - FLARE + onsetAt: + type: string + resolvedAt: + anyOf: + - type: string + - type: "null" + parentConditionId: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - label + - type + - lifecycle + - onsetAt + - resolvedAt + - parentConditionId + - note + - createdAt + - updatedAt + additionalProperties: false + description: A stored illness/condition episode. `note` is the decrypted free-text (or null on a key-rotation gap — + fail-soft, never 500). `resolvedAt` is null while the episode is open. `parentConditionId` links a + FLARE/RECURRING bout to its parent condition. + CreateIllnessEpisodeEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessEpisode" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetIllnessEpisodeEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessEpisode" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateIllnessEpisodeEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessEpisode" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ResolveIllnessEpisodeEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessEpisode" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetIllnessDayLogEnvelope: + type: object + properties: + data: + anyOf: + - $ref: "#/components/schemas/IllnessDayLog" + - type: "null" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + IllnessDayLog: + type: object + properties: + id: + type: string + episodeId: + type: string + date: + type: string + functionalImpact: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + feverC: + anyOf: + - type: number + - type: "null" + symptoms: + type: array + items: + $ref: "#/components/schemas/IllnessSymptom" + note: + anyOf: + - type: string + - type: "null" + updatedAt: + type: string + required: + - id + - episodeId + - date + - functionalImpact + - feverC + - symptoms + - note + - updatedAt + additionalProperties: false + description: A stored day-log for an episode. `date` is the `YYYY-MM-DD` it covers. `functionalImpact` (0–3) and + `feverC` are plaintext; `symptoms` flattens the link rows; `note` is the decrypted free-text (or null). + IllnessSymptom: + type: object + properties: + key: + type: string + severity: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + required: + - key + - severity + additionalProperties: false + description: "A symptom link on a day-log: the catalog `key` plus an optional 0–3 severity (`null` = a plain presence link)." + UpsertIllnessDayLogUpdatedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessDayLog" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpsertIllnessDayLogCreatedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessDayLog" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationScheduleOutput: type: object properties: diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 1b25db586..147d50b24 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -74,6 +74,7 @@ const openApiBase: Pick< { name: "Cycle" }, { name: "MeasurementReminders" }, { name: "Labs" }, + { name: "Illness" }, { name: "Admin" }, { name: "Meta" }, ], diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts new file mode 100644 index 000000000..ef28e76ca --- /dev/null +++ b/src/lib/openapi/routes/illness.ts @@ -0,0 +1,329 @@ +/** + * OpenAPI route table for the illness / condition journal (`/api/illness`). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The request + * bodies + queries reuse the runtime Zod schemas from `@/lib/validations/illness` + * so the wire contract stays single-source. Response shapes are declared here + * to mirror the server-authoritative DTOs in `@/lib/illness/dto.ts` (the routes + * serialise a decrypted `note` + flattened `symptoms` the input schemas don't + * carry; iOS renders the DTO, it never recomputes it). + * + * The journal is a CONDITION journal, retrospective-only — never a + * predictor/diagnoser. Every route is born-gated: a non-opted-in account (or + * an operator-disabled instance) 403s with `errorCode:"illness.disabled"` + * even with a valid Bearer token. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + illnessEpisodeCreateSchema, + illnessEpisodeUpdateSchema, + illnessEpisodeResolveSchema, + illnessEpisodeListQuerySchema, + illnessDayLogInputSchema, + illnessDayLogQuerySchema, + illnessTypeEnum, + illnessLifecycleEnum, +} from "@/lib/validations/illness"; + +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +illnessEpisodeCreateSchema.meta({ + id: "CreateIllnessEpisodeRequest", + description: + "Open an illness/condition episode. `label` is the user-facing name; `type` + `lifecycle` classify it. `onsetAt` defaults to 'now' server-side when omitted. `parentConditionId` threads a FLARE/RECURRING bout under a parent condition (must be an owned, live episode). The optional `note` is encrypted at rest.", +}); + +illnessEpisodeUpdateSchema.meta({ + id: "UpdateIllnessEpisodeRequest", + description: + "Partial edit of an episode; an omitted key leaves the column untouched. A `null` `resolvedAt` re-opens a resolved episode; a `null` `parentConditionId` detaches it from its parent. Rejects unknown keys.", +}); + +illnessEpisodeResolveSchema.meta({ + id: "ResolveIllnessEpisodeRequest", + description: + "Mark an episode recovered. `resolvedAt` defaults to 'now' when omitted, so the one-tap 'mark recovered' affordance can send an empty body. A CHRONIC_ONGOING episode has no recovery date by design and 422s.", +}); + +illnessEpisodeListQuerySchema.meta({ + id: "ListIllnessEpisodesQuery", + description: + "Query params for the episode history: optional `limit` (1–100, default 50) and `includeResolved` ('true' | 'false'); 'false' hides episodes that already carry a `resolvedAt`. Newest-first by `onsetAt`.", +}); + +illnessDayLogInputSchema.meta({ + id: "UpsertIllnessDayLogRequest", + description: + "Upsert one day's symptom / functional-impact / fever row for an episode (keyed on `(episodeId, date)`). `date` is a `YYYY-MM-DD` tz-anchored day. `functionalImpact` (0–3) + `feverC` are queryable plaintext; `symptoms` carry an optional 0–3 Jackson/WURSS severity per link; the optional `note` is encrypted at rest.", +}); + +illnessDayLogQuerySchema.meta({ + id: "GetIllnessDayLogQuery", + description: + "Single-day read query: `date` is a `YYYY-MM-DD` day. Returns the matching day-log or `null` when nothing is logged for that day.", +}); + +const illnessSymptom = z + .object({ + key: z.string(), + severity: z.number().int().nullable(), + }) + .meta({ + id: "IllnessSymptom", + description: + "A symptom link on a day-log: the catalog `key` plus an optional 0–3 severity (`null` = a plain presence link).", + }); + +const illnessEpisode = z + .object({ + id: z.string(), + label: z.string(), + type: illnessTypeEnum, + lifecycle: illnessLifecycleEnum, + onsetAt: z.string(), + resolvedAt: z.string().nullable(), + parentConditionId: z.string().nullable(), + note: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .meta({ + id: "IllnessEpisode", + description: + "A stored illness/condition episode. `note` is the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `resolvedAt` is null while the episode is open. `parentConditionId` links a FLARE/RECURRING bout to its parent condition.", + }); + +const illnessDayLog = z + .object({ + id: z.string(), + episodeId: z.string(), + date: z.string(), + functionalImpact: z.number().int().nullable(), + feverC: z.number().nullable(), + symptoms: z.array(illnessSymptom), + note: z.string().nullable(), + updatedAt: z.string(), + }) + .meta({ + id: "IllnessDayLog", + description: + "A stored day-log for an episode. `date` is the `YYYY-MM-DD` it covers. `functionalImpact` (0–3) and `feverC` are plaintext; `symptoms` flattens the link rows; `note` is the decrypted free-text (or null).", + }); + +const episodeNotFound = { + "404": { + description: "Illness episode not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +export const illnessPaths: NonNullable = { + "/api/illness/episodes": { + get: { + tags: ["Illness"], + summary: "List illness episodes (v1.18.1)", + description: + "Returns the caller's live (non-deleted) illness/condition episodes, newest-first by onset. `includeResolved=false` hides episodes that already carry a `resolvedAt`. Born-gated.", + requestParams: { query: illnessEpisodeListQuerySchema }, + responses: { + "200": { + description: "The caller's illness episodes.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(illnessEpisode), + "ListIllnessEpisodesEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Illness"], + summary: "Open an illness episode (v1.18.1)", + description: + "Creates one episode for the caller. `onsetAt` defaults to 'now' when omitted; the optional note is AES-256-GCM encrypted before write. A `parentConditionId` must reference an owned, live episode. Audits as `illness.episode.create`. Born-gated.", + requestBody: { + required: true, + content: { + "application/json": { schema: illnessEpisodeCreateSchema }, + }, + }, + responses: { + "201": { + description: "Episode created.", + content: { + "application/json": { + schema: dataEnvelope( + illnessEpisode, + "CreateIllnessEpisodeEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/illness/episodes/{id}": { + get: { + tags: ["Illness"], + summary: "Read a single illness episode (v1.18.1)", + description: + "Returns the episode including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s. Born-gated.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "The episode.", + content: { + "application/json": { + schema: dataEnvelope(illnessEpisode, "GetIllnessEpisodeEnvelope"), + }, + }, + }, + ...episodeNotFound, + ...stdResponses, + }, + }, + patch: { + tags: ["Illness"], + summary: "Edit an illness episode (v1.18.1)", + description: + "Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and never the episode itself). Audits as `illness.episode.update`. Owner-scoped + born-gated.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: illnessEpisodeUpdateSchema }, + }, + }, + responses: { + "200": { + description: "Episode updated.", + content: { + "application/json": { + schema: dataEnvelope( + illnessEpisode, + "UpdateIllnessEpisodeEnvelope", + ), + }, + }, + }, + ...episodeNotFound, + ...stdResponses, + }, + }, + delete: { + tags: ["Illness"], + summary: "Soft-delete an illness episode (v1.18.1)", + description: + "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns 204 No Content. Audits as `illness.episode.delete`. Owner-scoped + born-gated.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "204": { description: "Soft-deleted (no body)." }, + ...episodeNotFound, + ...stdResponses, + }, + }, + }, + "/api/illness/episodes/{id}/resolve": { + patch: { + tags: ["Illness"], + summary: "Mark an illness episode recovered (v1.18.1)", + description: + "Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery date and 422s with `errorCode:\"illness.episode.chronic-no-resolve\"`. Audits as `illness.episode.resolve`. Owner-scoped + born-gated.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: false, + content: { + "application/json": { schema: illnessEpisodeResolveSchema }, + }, + }, + responses: { + "200": { + description: "Episode resolved.", + content: { + "application/json": { + schema: dataEnvelope( + illnessEpisode, + "ResolveIllnessEpisodeEnvelope", + ), + }, + }, + }, + ...episodeNotFound, + ...stdResponses, + }, + }, + }, + "/api/illness/episodes/{id}/day-logs": { + get: { + tags: ["Illness"], + summary: "Read one day-log for an episode (v1.18.1)", + description: + "Returns the day-log for the episode + `date`, or `null` when nothing is logged that day (lets the log-day sheet pre-fill). The parent episode must be owned + live. Born-gated.", + requestParams: { + path: z.object({ id: z.string() }), + query: illnessDayLogQuerySchema, + }, + responses: { + "200": { + description: "The day-log, or null.", + content: { + "application/json": { + schema: dataEnvelope( + illnessDayLog.nullable(), + "GetIllnessDayLogEnvelope", + ), + }, + }, + }, + ...episodeNotFound, + ...stdResponses, + }, + }, + post: { + tags: ["Illness"], + summary: "Upsert an episode day-log (v1.18.1)", + description: + "Upserts the day's symptom / functional-impact / fever timeline row on `(episodeId, date)`: 201 on insert, 200 on update. The parent episode must be owned + live; the optional note is encrypted at rest. Audits as `illness.day-log.upsert`. Born-gated.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: illnessDayLogInputSchema }, + }, + }, + responses: { + "200": { + description: "Existing day-log updated.", + content: { + "application/json": { + schema: dataEnvelope( + illnessDayLog, + "UpsertIllnessDayLogUpdatedEnvelope", + ), + }, + }, + }, + "201": { + description: "New day-log created.", + content: { + "application/json": { + schema: dataEnvelope( + illnessDayLog, + "UpsertIllnessDayLogCreatedEnvelope", + ), + }, + }, + }, + ...episodeNotFound, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 87cfc3e92..7799b7c5d 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -29,6 +29,7 @@ import { cyclePaths } from "./cycle"; import { biomarkerPaths } from "./biomarkers"; import { devicePaths } from "./devices"; import { healthRecordPaths } from "./health-record"; +import { illnessPaths } from "./illness"; import { importPaths } from "./import"; import { insightsPaths } from "./insights"; import { labsPaths } from "./labs"; @@ -65,6 +66,7 @@ export const openApiPaths: NonNullable = { ...measurementReminderPaths, ...labsPaths, ...biomarkerPaths, + ...illnessPaths, }; export const openApiComponents: NonNullable = { From 745fa28c8b638fe1d435d9ef03afa2bf77b02c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 22:16:19 +0200 Subject: [PATCH 27/48] feat(illness): correlation + recovery-gap engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the retrospective correlation engine for illness episodes: a pre-onset anomaly scan (deviation in robust SDs from the user's own median±MAD baseline), the nadir / what-dropped across the active span, and the recovery-gap (physiological return-to-baseline vs the felt-better marker). CHRONIC_ONGOING is excluded from the gap. Every derived number is gated by the shared coverage/confidence model — a thin signal asserts nothing rather than showing a wrong figure. The baseline window ends strictly before the pre-onset lookback, so an episode's own days can never contaminate the baseline it is measured against. Reads are DAY-native (rollup tier with a live-SQL fallback), reusing the baseline engine's read path so the rollup-vs-live parity carries over. Surfaces: - GET /api/illness/episodes/{id}/correlation — per-episode findings (gated Derived wire shape; red-flag escalation for sustained low SpO2 / fever). - GET /api/illness/insights — cross-episode retrospective ("sick N times · typical recovery gap X days"), with the typical-gap withheld below the min-sample floor. Retrospective only — never a predictor or diagnoser. Reliability battery: golden-fixture recovery-gap math, rollup-vs-live parity, baseline-contamination guard, thin-data / coverage-gate, red-flag escalation. Born-gated + owner-scoped; server-authoritative DTO (iOS renders). i18n ×6. --- messages/de.json | 28 + messages/en.json | 28 + messages/es.json | 28 + messages/fr.json | 28 + messages/it.json | 28 + messages/pl.json | 28 + .../episodes/[id]/correlation/route.ts | 87 +++ src/app/api/illness/insights/route.ts | 107 ++++ .../__tests__/correlation-read.test.ts | 210 ++++++++ src/lib/illness/__tests__/correlation.test.ts | 294 ++++++++++ .../illness/__tests__/retrospective.test.ts | 73 +++ src/lib/illness/correlation-read.ts | 168 ++++++ src/lib/illness/correlation.ts | 508 ++++++++++++++++++ src/lib/illness/retrospective.ts | 98 ++++ src/lib/query-keys/illness.ts | 13 + src/lib/validations/illness.ts | 11 + 16 files changed, 1737 insertions(+) create mode 100644 src/app/api/illness/episodes/[id]/correlation/route.ts create mode 100644 src/app/api/illness/insights/route.ts create mode 100644 src/lib/illness/__tests__/correlation-read.test.ts create mode 100644 src/lib/illness/__tests__/correlation.test.ts create mode 100644 src/lib/illness/__tests__/retrospective.test.ts create mode 100644 src/lib/illness/correlation-read.ts create mode 100644 src/lib/illness/correlation.ts create mode 100644 src/lib/illness/retrospective.ts diff --git a/messages/de.json b/messages/de.json index cdbaa9522..630ce0ac1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6834,6 +6834,34 @@ "headache": "Kopfschmerzen", "bodyAches": "Gliederschmerzen", "fatigue": "Erschöpfung" + }, + "correlation": { + "title": "Wie es verlief", + "learning": "Noch am Lernen — erfasse ein paar weitere Tage, um hier Muster zu sehen.", + "preOnsetTitle": "Wie es sich ankündigte", + "preOnsetBody": "Werte, die vor dem Beginn von deiner Baseline abwichen.", + "nadirTitle": "Was sich änderte", + "nadirBody": "Wie weit jeder erfasste Wert von deinem typischen Bereich abwich.", + "recoveryGapTitle": "Erholungslücke", + "recoveryGapLag": "Deine Werte normalisierten sich {days} Tage, nachdem es dir besser ging.", + "recoveryGapLead": "Deine Werte normalisierten sich {days} Tage, bevor du Genesung vermerktest.", + "recoveryGapSame": "Deine Werte normalisierten sich am selben Tag, an dem es dir besser ging.", + "redFlagTitle": "Genauer ansehen", + "redFlagSpo2": "Deine Sauerstoffsättigung blieb mehrere Tage niedrig. Wenn das erneut auftritt, suche ärztliche Hilfe.", + "redFlagFever": "Du hattest anhaltend Fieber. Wenn das erneut auftritt, suche ärztliche Hilfe.", + "direction": { + "above": "über deinem Üblichen", + "below": "unter deinem Üblichen" + } + }, + "insights": { + "title": "Dein Krankheitsverlauf", + "countOne": "Du hast in diesem Zeitraum 1 Episode erfasst.", + "countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.", + "typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.", + "typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.", + "byMonth": "Wann du Episoden erfasst", + "none": "In diesem Zeitraum keine Episoden erfasst." } }, "coach": { diff --git a/messages/en.json b/messages/en.json index 7ca9be83e..1dd848fbb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6834,6 +6834,34 @@ "headache": "Headache", "bodyAches": "Body aches", "fatigue": "Fatigue" + }, + "correlation": { + "title": "How it unfolded", + "learning": "Still learning — log a few more days to see patterns here.", + "preOnsetTitle": "How it announced itself", + "preOnsetBody": "Signals that drifted from your baseline before you marked the onset.", + "nadirTitle": "What changed", + "nadirBody": "How far each tracked signal strayed from your typical range.", + "recoveryGapTitle": "Recovery gap", + "recoveryGapLag": "Your numbers settled {days} days after you felt better.", + "recoveryGapLead": "Your numbers settled {days} days before you marked recovery.", + "recoveryGapSame": "Your numbers settled the same day you felt better.", + "redFlagTitle": "Worth a closer look", + "redFlagSpo2": "Your oxygen saturation stayed low for several days. If this recurs, seek medical care.", + "redFlagFever": "You ran a sustained fever. If this recurs, seek medical care.", + "direction": { + "above": "above your usual", + "below": "below your usual" + } + }, + "insights": { + "title": "Your illness history", + "countOne": "You logged 1 episode in this period.", + "countOther": "You logged {count} episodes in this period.", + "typicalGap": "Typical recovery gap: {days} days between feeling better and your numbers settling.", + "typicalGapLearning": "A few more resolved episodes and a typical recovery gap will appear here.", + "byMonth": "When you tend to log episodes", + "none": "No episodes logged in this period." } }, "coach": { diff --git a/messages/es.json b/messages/es.json index 4edefab7d..6d909c863 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6834,6 +6834,34 @@ "headache": "Dolor de cabeza", "bodyAches": "Dolores musculares", "fatigue": "Fatiga" + }, + "correlation": { + "title": "Cómo se desarrolló", + "learning": "Aún aprendiendo: registra unos días más para ver patrones aquí.", + "preOnsetTitle": "Cómo se anunció", + "preOnsetBody": "Señales que se alejaron de tu base antes de marcar el inicio.", + "nadirTitle": "Qué cambió", + "nadirBody": "Cuánto se alejó cada señal registrada de tu rango habitual.", + "recoveryGapTitle": "Brecha de recuperación", + "recoveryGapLag": "Tus valores se estabilizaron {days} días después de sentirte mejor.", + "recoveryGapLead": "Tus valores se estabilizaron {days} días antes de marcar la recuperación.", + "recoveryGapSame": "Tus valores se estabilizaron el mismo día en que te sentiste mejor.", + "redFlagTitle": "Conviene revisarlo", + "redFlagSpo2": "Tu saturación de oxígeno se mantuvo baja varios días. Si se repite, busca atención médica.", + "redFlagFever": "Tuviste fiebre sostenida. Si se repite, busca atención médica.", + "direction": { + "above": "por encima de lo habitual", + "below": "por debajo de lo habitual" + } + }, + "insights": { + "title": "Tu historial de enfermedades", + "countOne": "Registraste 1 episodio en este período.", + "countOther": "Registraste {count} episodios en este período.", + "typicalGap": "Brecha de recuperación típica: {days} días entre sentirte mejor y la estabilización de tus valores.", + "typicalGapLearning": "Con unos pocos episodios resueltos más aparecerá aquí una brecha de recuperación típica.", + "byMonth": "Cuándo sueles registrar episodios", + "none": "Sin episodios registrados en este período." } }, "coach": { diff --git a/messages/fr.json b/messages/fr.json index e2644cd04..b86158944 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6834,6 +6834,34 @@ "headache": "Mal de tête", "bodyAches": "Courbatures", "fatigue": "Fatigue" + }, + "correlation": { + "title": "Comment cela s'est déroulé", + "learning": "Encore en apprentissage — enregistre quelques jours de plus pour voir des tendances ici.", + "preOnsetTitle": "Comment cela s'est annoncé", + "preOnsetBody": "Signaux qui se sont écartés de ta base avant le début que tu as noté.", + "nadirTitle": "Ce qui a changé", + "nadirBody": "À quel point chaque signal suivi s'est écarté de ta plage habituelle.", + "recoveryGapTitle": "Écart de récupération", + "recoveryGapLag": "Tes valeurs se sont stabilisées {days} jours après que tu te sois senti mieux.", + "recoveryGapLead": "Tes valeurs se sont stabilisées {days} jours avant que tu notes la guérison.", + "recoveryGapSame": "Tes valeurs se sont stabilisées le jour même où tu t'es senti mieux.", + "redFlagTitle": "À examiner de plus près", + "redFlagSpo2": "Ta saturation en oxygène est restée basse plusieurs jours. Si cela se reproduit, consulte un médecin.", + "redFlagFever": "Tu as eu une fièvre persistante. Si cela se reproduit, consulte un médecin.", + "direction": { + "above": "au-dessus de ton habituel", + "below": "en dessous de ton habituel" + } + }, + "insights": { + "title": "Ton historique de maladies", + "countOne": "Tu as enregistré 1 épisode sur cette période.", + "countOther": "Tu as enregistré {count} épisodes sur cette période.", + "typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de tes valeurs.", + "typicalGapLearning": "Encore quelques épisodes résolus et un écart de récupération typique apparaîtra ici.", + "byMonth": "Quand tu enregistres des épisodes", + "none": "Aucun épisode enregistré sur cette période." } }, "coach": { diff --git a/messages/it.json b/messages/it.json index e6c673f46..387f81694 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6834,6 +6834,34 @@ "headache": "Mal di testa", "bodyAches": "Dolori muscolari", "fatigue": "Affaticamento" + }, + "correlation": { + "title": "Come si è svolto", + "learning": "Ancora in apprendimento: registra qualche giorno in più per vedere i pattern qui.", + "preOnsetTitle": "Come si è annunciato", + "preOnsetBody": "Segnali che si sono allontanati dalla tua base prima dell'inizio che hai segnato.", + "nadirTitle": "Cosa è cambiato", + "nadirBody": "Quanto ogni segnale registrato si è allontanato dal tuo intervallo abituale.", + "recoveryGapTitle": "Divario di recupero", + "recoveryGapLag": "I tuoi valori si sono stabilizzati {days} giorni dopo esserti sentito meglio.", + "recoveryGapLead": "I tuoi valori si sono stabilizzati {days} giorni prima di segnare la guarigione.", + "recoveryGapSame": "I tuoi valori si sono stabilizzati lo stesso giorno in cui ti sei sentito meglio.", + "redFlagTitle": "Da guardare più da vicino", + "redFlagSpo2": "La tua saturazione di ossigeno è rimasta bassa per diversi giorni. Se si ripete, rivolgiti a un medico.", + "redFlagFever": "Hai avuto febbre persistente. Se si ripete, rivolgiti a un medico.", + "direction": { + "above": "sopra il tuo solito", + "below": "sotto il tuo solito" + } + }, + "insights": { + "title": "La tua storia di malattie", + "countOne": "Hai registrato 1 episodio in questo periodo.", + "countOther": "Hai registrato {count} episodi in questo periodo.", + "typicalGap": "Divario di recupero tipico: {days} giorni tra il sentirti meglio e la stabilizzazione dei valori.", + "typicalGapLearning": "Ancora qualche episodio risolto e qui apparirà un divario di recupero tipico.", + "byMonth": "Quando tendi a registrare episodi", + "none": "Nessun episodio registrato in questo periodo." } }, "coach": { diff --git a/messages/pl.json b/messages/pl.json index 34d735c60..a1b6d1d8c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6834,6 +6834,34 @@ "headache": "Ból głowy", "bodyAches": "Bóle ciała", "fatigue": "Zmęczenie" + }, + "correlation": { + "title": "Jak przebiegało", + "learning": "Wciąż się uczę — zapisz jeszcze kilka dni, aby zobaczyć tu wzorce.", + "preOnsetTitle": "Jak się zapowiadało", + "preOnsetBody": "Sygnały, które odbiegły od Twojej bazy przed oznaczonym początkiem.", + "nadirTitle": "Co się zmieniło", + "nadirBody": "Jak daleko każdy śledzony sygnał odbiegł od Twojego typowego zakresu.", + "recoveryGapTitle": "Luka rekonwalescencji", + "recoveryGapLag": "Twoje wartości ustabilizowały się {days} dni po tym, jak poczułeś się lepiej.", + "recoveryGapLead": "Twoje wartości ustabilizowały się {days} dni przed oznaczeniem wyzdrowienia.", + "recoveryGapSame": "Twoje wartości ustabilizowały się tego samego dnia, gdy poczułeś się lepiej.", + "redFlagTitle": "Warto przyjrzeć się bliżej", + "redFlagSpo2": "Twoje wysycenie tlenem pozostawało niskie przez kilka dni. Jeśli się powtórzy, zasięgnij pomocy medycznej.", + "redFlagFever": "Miałeś utrzymującą się gorączkę. Jeśli się powtórzy, zasięgnij pomocy medycznej.", + "direction": { + "above": "powyżej Twojej normy", + "below": "poniżej Twojej normy" + } + }, + "insights": { + "title": "Twoja historia chorób", + "countOne": "W tym okresie zapisałeś 1 epizod.", + "countOther": "W tym okresie zapisałeś {count} epizodów.", + "typicalGap": "Typowa luka rekonwalescencji: {days} dni między poprawą a ustabilizowaniem wartości.", + "typicalGapLearning": "Jeszcze kilka zakończonych epizodów i pojawi się tu typowa luka rekonwalescencji.", + "byMonth": "Kiedy zwykle zapisujesz epizody", + "none": "Brak epizodów zapisanych w tym okresie." } }, "coach": { diff --git a/src/app/api/illness/episodes/[id]/correlation/route.ts b/src/app/api/illness/episodes/[id]/correlation/route.ts new file mode 100644 index 000000000..a315f69ae --- /dev/null +++ b/src/app/api/illness/episodes/[id]/correlation/route.ts @@ -0,0 +1,87 @@ +/** + * `GET /api/illness/episodes/{id}/correlation` — the retrospective correlation + * findings for one episode: the pre-onset anomaly scan ("how did it announce + * itself"), the nadir / what-dropped, and the recovery-gap (physiological + * return-to-baseline vs the felt-better marker). + * + * Server-authoritative + coverage-gated: the response is a `Derived` flat + * wire shape (`status: "ok" | "insufficient"`). When the signal is thin the + * engine returns `insufficient` with coverage + a reason — the surface renders + * "we're still learning", never a fabricated number. The findings are derived + * from the user's OWN baseline (median ± MAD), never a population constant, and + * the baseline window is contamination-guarded (ends before the pre-onset + * lookback). Retrospective ONLY — never a predictor or diagnoser. Born-gated + + * owner-scoped. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const episode = await prisma.illnessEpisode.findUnique({ + where: { id }, + select: { + id: true, + userId: true, + deletedAt: true, + onsetAt: true, + resolvedAt: true, + lifecycle: true, + }, + }); + if (!episode || episode.userId !== user.id || episode.deletedAt !== null) { + return apiError("Episode not found", 404); + } + + const tz = user.timezone ?? DEFAULT_TIMEZONE; + const derived = await computeEpisodeCorrelation( + user.id, + { + id: episode.id, + onsetAt: episode.onsetAt, + resolvedAt: episode.resolvedAt, + lifecycle: episode.lifecycle, + }, + tz, + ); + + annotate({ + action: { + name: "illness.correlation.read", + entity_type: "illness_episode", + entity_id: id, + }, + meta: { + status: derived.status, + recovery_gap_days: + derived.status === "ok" ? derived.value.recoveryGapDays : null, + red_flags: derived.status === "ok" ? derived.value.redFlags.length : 0, + }, + }); + + return apiSuccess({ + episodeId: id, + status: derived.status, + value: derived.status === "ok" ? derived.value : null, + coverage: derived.coverage, + confidence: derived.status === "ok" ? derived.confidence : null, + provenance: derived.provenance, + reason: derived.status === "insufficient" ? derived.reason : null, + }); + }, +); diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts new file mode 100644 index 000000000..48960abdd --- /dev/null +++ b/src/app/api/illness/insights/route.ts @@ -0,0 +1,107 @@ +/** + * `GET /api/illness/insights?windowDays=365` — the cross-episode retrospective + * summary: "sick N times this year · typical recovery gap X days", with a + * recurrence-by-month tally and a per-type breakdown. + * + * RETROSPECTIVE ONLY — it summarises past episodes. It is NEVER a predictor or + * diagnoser; the recurrence figure is a count, not a forecast. Server- + * authoritative + gated: the "typical gap" is withheld below the min-sample + * floor (asserts nothing thin). Per-episode gaps come from the same coverage- + * gated correlation engine the per-episode route uses, so a thin episode + * contributes no gap rather than a wrong one. Born-gated + owner-scoped. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { illnessInsightsQuerySchema } from "@/lib/validations/illness"; +import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read"; +import { + summarizeIllnessRetrospective, + type RetrospectiveEpisode, +} from "@/lib/illness/retrospective"; +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; +import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** Cap the per-episode gap computations so a heavy history can't fan out. */ +const MAX_GAP_COMPUTATIONS = 24; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const parsed = illnessInsightsQuerySchema.safeParse({ + windowDays: new URL(request.url).searchParams.get("windowDays") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "illness.insights.invalid", + }); + } + + const windowDays = parsed.data.windowDays ?? 365; + const now = new Date(); + const since = new Date(now.getTime() - windowDays * MS_PER_DAY); + const tz = resolveUserTimezone(user.timezone ?? null); + + const rows = await prisma.illnessEpisode.findMany({ + where: { userId: user.id, deletedAt: null, onsetAt: { gte: since } }, + orderBy: { onsetAt: "desc" }, + select: { + id: true, + type: true, + lifecycle: true, + onsetAt: true, + resolvedAt: true, + }, + }); + + // Compute the recovery-gap only for resolved, non-chronic episodes (the + // only ones that CAN have a gap), bounded to MAX_GAP_COMPUTATIONS most + // recent. Each gap comes from the coverage-gated engine — a thin episode + // yields null and simply doesn't contribute to the typical-gap median. + const gapCandidates = rows + .filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING") + .slice(0, MAX_GAP_COMPUTATIONS); + const gapById = new Map(); + for (const r of gapCandidates) { + const derived = await computeEpisodeCorrelation( + user.id, + { id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle }, + tz, + now, + ); + gapById.set( + r.id, + derived.status === "ok" ? derived.value.recoveryGapDays : null, + ); + } + + const episodes: RetrospectiveEpisode[] = rows.map((r) => ({ + id: r.id, + type: r.type, + onsetDay: dayKeyForUserTz(r.onsetAt, tz), + resolved: r.resolvedAt !== null, + recoveryGapDays: gapById.get(r.id) ?? null, + lifecycle: r.lifecycle, + })); + + const summary = summarizeIllnessRetrospective(episodes); + + annotate({ + action: { name: "illness.insights.read", entity_type: "illness_episode" }, + meta: { + episode_count: summary.episodeCount, + gap_sample_size: summary.gapSampleSize, + typical_gap_days: summary.typicalRecoveryGapDays, + }, + }); + + return apiSuccess({ windowDays, ...summary }); +}); diff --git a/src/lib/illness/__tests__/correlation-read.test.ts b/src/lib/illness/__tests__/correlation-read.test.ts new file mode 100644 index 000000000..01c1625a5 --- /dev/null +++ b/src/lib/illness/__tests__/correlation-read.test.ts @@ -0,0 +1,210 @@ +/** + * v1.18.1 P3 — illness correlation READ layer + rollup-vs-live parity. + * + * The reliability anchor: the same per-day series fed to the engine must + * produce IDENTICAL findings whether it was resolved from the rollup DAY tier + * or the live-SQL fallback. We drive `computeEpisodeCorrelation` twice over the + * SAME underlying daily data — once with full rollup coverage (the DAY path), + * once with a coverage miss (the live raw-read path) — and assert the engine + * output is byte-identical (modulo the provenance `source` tag). + * + * Also pins the contamination-guard READ window: the baseline read window must + * end strictly before the pre-onset lookback (no episode-span day reaches the + * baseline query). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/* The DAY-native daily means the test universe holds for RESTING_HEART_RATE. + * Baseline window sits at ~55; the episode-span (from 01-03 onward) spikes. + * Onset 2026-01-10, lookback 7 → pre-onset starts 2026-01-03; baseline window + * is the 30 days ending 2026-01-03. */ +const DAILY: Array<{ day: string; mean: number }> = []; +{ + // 30 clean baseline days ending the day before 2026-01-03. + let cursor = Date.parse("2026-01-02T00:00:00Z"); + // Even spread around 55 so the MAD (and band) is non-zero and stable. + const offsets = [-2, -1, 0, 1, 2]; + for (let i = 0; i < 30; i++) { + DAILY.unshift({ + day: new Date(cursor).toISOString().slice(0, 10), + mean: 55 + offsets[i % offsets.length], + }); + cursor -= 24 * 60 * 60 * 1000; + } + // Episode span. + for (const [day, mean] of [ + ["2026-01-03", 56], + ["2026-01-08", 70], // pre-onset notable + ["2026-01-10", 74], + ["2026-01-12", 76], // nadir + ["2026-01-15", 64], + ["2026-01-17", 58], + ["2026-01-18", 55], + ["2026-01-19", 55], + ["2026-01-20", 55], + ] as const) { + DAILY.push({ day, mean }); + } +} + +const NOW = new Date("2026-01-21T12:00:00Z"); + +const db = vi.hoisted(() => ({ + measurement: { findMany: vi.fn() }, +})); +const rollup = vi.hoisted(() => ({ + probeRollupCoverage: vi.fn(), + readBestGranularityRollups: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ prisma: db })); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: rollup.probeRollupCoverage, +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: rollup.readBestGranularityRollups, +})); + +import { computeEpisodeCorrelation } from "../correlation-read"; +import { ILLNESS_SCAN_TYPES } from "../correlation"; + +/** + * An ACTIVE episode (no resolvedAt) so the episode-window read's `to` is + * effectively `now` — this exercises the trailing rollup DAY path in the + * covered case and the trailing live path in the miss case, the exact seam + * the parity test must pin. The baseline window stays bounded (raw both ways). + */ +const EPISODE = { + id: "ep-parity", + onsetAt: new Date("2026-01-10T08:00:00Z"), + resolvedAt: null as Date | null, + lifecycle: "ACUTE", +}; + +/** Daily rows within [from, to) as a raw-measurement findMany result. */ +function rawRowsWithin(from: Date, to: Date) { + const fromKey = from.toISOString().slice(0, 10); + const toKey = to.toISOString().slice(0, 10); + return DAILY.filter((d) => d.day >= fromKey && d.day < toKey).map((d) => ({ + value: d.mean, + measuredAt: new Date(`${d.day}T12:00:00Z`), + })); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("computeEpisodeCorrelation — rollup-vs-live parity", () => { + function onlyRhr(map: Map): Map { + return map; + } + + it("produces identical findings on the DAY path and the live path", async () => { + // ── DAY path: full coverage for RESTING_HEART_RATE, rollup serves the + // trailing (to == now) episode-window read; the bounded baseline read + // goes through measurement.findMany (to != now). ── + const dayCoverage = onlyRhr( + new Map(ILLNESS_SCAN_TYPES.map((t) => [String(t), t === "RESTING_HEART_RATE"])), + ); + rollup.probeRollupCoverage.mockResolvedValue(dayCoverage); + rollup.readBestGranularityRollups.mockImplementation( + async (_u: string, type: string, windowDays: number) => { + if (type !== "RESTING_HEART_RATE") return null; + const since = new Date(NOW.getTime() - windowDays * 24 * 60 * 60 * 1000); + const sinceKey = since.toISOString().slice(0, 10); + const rows = DAILY.filter((d) => d.day >= sinceKey).map((d) => ({ + bucketStart: new Date(`${d.day}T00:00:00Z`), + count: 1, + mean: d.mean, + sd: null, + slope: null, + r2: null, + sumValue: null, + minValue: d.mean, + maxValue: d.mean, + })); + return rows.length > 0 ? { granularity: "DAY" as const, rows } : null; + }, + ); + db.measurement.findMany.mockImplementation(async ({ where }: { where: { type: string; measuredAt: { gte: Date; lt: Date } } }) => { + if (where.type !== "RESTING_HEART_RATE") return []; + return rawRowsWithin(where.measuredAt.gte, where.measuredAt.lt); + }); + + const dayOut = await computeEpisodeCorrelation("u1", EPISODE, "UTC", NOW); + + // ── Live path: zero rollup coverage, every read falls to findMany. The + // baseline engine's live fallback groups raw rows itself for the + // trailing read; we serve the same DAILY universe for any window. ── + vi.clearAllMocks(); + const noCoverage = new Map( + ILLNESS_SCAN_TYPES.map((t) => [String(t), false]), + ); + rollup.probeRollupCoverage.mockResolvedValue(noCoverage); + rollup.readBestGranularityRollups.mockResolvedValue(null); + db.measurement.findMany.mockImplementation( + async ({ where }: { where: { type: string; measuredAt: { gte: Date; lt?: Date } } }) => { + if (where.type !== "RESTING_HEART_RATE") return []; + const from = where.measuredAt.gte; + const to = where.measuredAt.lt ?? NOW; + return rawRowsWithin(from, to); + }, + ); + + const liveOut = await computeEpisodeCorrelation("u1", EPISODE, "UTC", NOW); + + expect(dayOut.status).toBe("ok"); + expect(liveOut.status).toBe("ok"); + if (dayOut.status !== "ok" || liveOut.status !== "ok") return; + + // Same numbers regardless of read path (the parity guarantee): the + // DAY-rollup-fed series and the live-SQL series produce byte-identical + // findings. + expect(liveOut.value.recoveryGapDays).toBe(dayOut.value.recoveryGapDays); + expect(liveOut.value.nadir).toEqual(dayOut.value.nadir); + expect(liveOut.value.preOnset).toEqual(dayOut.value.preOnset); + expect(liveOut.value.returns).toEqual(dayOut.value.returns); + + // And the findings are the expected golden values. + expect(dayOut.value.nadir[0]?.day).toBe("2026-01-12"); + expect(dayOut.value.nadir[0]?.value).toBe(76); + expect(dayOut.value.preOnset[0]?.day).toBe("2026-01-08"); + // Active episode → no felt-better marker → the gap is null but the + // physiological return is still detected (01-17 is the first in-band day + // that then holds for ≥3 observed days). + expect(dayOut.value.recoveryGapDays).toBeNull(); + expect(dayOut.value.returns[0]?.returnedDay).toBe("2026-01-17"); + }); +}); + +describe("computeEpisodeCorrelation — contamination-guard read window", () => { + it("never queries baseline rows on or after the pre-onset lookback start", async () => { + rollup.probeRollupCoverage.mockResolvedValue( + new Map(ILLNESS_SCAN_TYPES.map((t) => [String(t), false])), + ); + rollup.readBestGranularityRollups.mockResolvedValue(null); + const seen: Array<{ gte: Date; lt?: Date }> = []; + db.measurement.findMany.mockImplementation( + async ({ where }: { where: { type: string; measuredAt: { gte: Date; lt?: Date } } }) => { + if (where.type === "RESTING_HEART_RATE") seen.push(where.measuredAt); + return []; + }, + ); + + await computeEpisodeCorrelation("u1", EPISODE, "UTC", NOW); + + // The pre-onset lookback starts at onset − 7 days = 2026-01-03. + const lookbackStart = Date.parse("2026-01-03T08:00:00Z"); + // The bounded baseline read (the one with a `lt`) must END at/ before + // the lookback start — no baseline query may reach into the episode span. + const boundedBaseline = seen.filter((w) => w.lt !== undefined); + expect(boundedBaseline.length).toBeGreaterThan(0); + for (const w of boundedBaseline) { + expect(w.lt!.getTime()).toBeLessThanOrEqual(lookbackStart); + } + }); +}); diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts new file mode 100644 index 000000000..5c627d887 --- /dev/null +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -0,0 +1,294 @@ +/** + * v1.18.1 P3 — illness correlation engine (the pure math). + * + * The reliability battery for `computeIllnessCorrelation`: + * - golden-fixture recovery-gap math (felt-better vs physiological return) + * - baseline-contamination guard (episode days must not poison the baseline) + * - thin-data / coverage-gate (asserts NOTHING below the floor) + * - pre-onset anomaly scan + nadir direction (adverse vs not) + * - red-flag escalation (sustained SpO2 drop / fever) + * - CHRONIC_ONGOING exclusion from the gap + * + * Pure — no DB. Series are hand-built so each finding is exactly predictable. + */ +import { describe, expect, it } from "vitest"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { + computeIllnessCorrelation, + dayDiff, + MIN_BASELINE_DAYS, + type IllnessCorrelationInput, + type VitalDayPoint, +} from "../correlation"; + +const NOW = new Date("2026-02-01T00:00:00Z"); + +/** Build a flat baseline series of `days` days at `value`, ending `endDay`. */ +function flatBaseline(value: number, days: number, endDay: string): VitalDayPoint[] { + const out: VitalDayPoint[] = []; + let cursor = Date.parse(`${endDay}T00:00:00Z`); + for (let i = 0; i < days; i++) { + out.unshift({ day: new Date(cursor).toISOString().slice(0, 10), mean: value }); + cursor -= 24 * 60 * 60 * 1000; + } + return out; +} + +/** + * A baseline whose per-day means spread evenly so the MAD (and thus the band) + * is non-zero and stable. Means cycle through `center − jitter … center + + * jitter` so the median is `center` and the MAD is ~`jitter/2`. + */ +function jitteredBaseline(center: number, jitter: number, days: number, endDay: string): VitalDayPoint[] { + const base = flatBaseline(center, days, endDay); + const offsets = [-jitter, -jitter / 2, 0, jitter / 2, jitter]; + return base.map((p, i) => ({ ...p, mean: center + offsets[i % offsets.length] })); +} + +function input(over: Partial): IllnessCorrelationInput { + return { + episodeId: "ep1", + window: { onsetDay: "2026-01-10", feltBetterDay: "2026-01-17", lifecycle: "ACUTE" }, + series: [], + source: "DAY", + now: NOW, + ...over, + }; +} + +describe("computeIllnessCorrelation — coverage gate", () => { + it("returns insufficient with no baselined vitals", () => { + const out = computeIllnessCorrelation(input({ series: [] })); + expect(out.status).toBe("insufficient"); + if (out.status === "insufficient") expect(out.reason).toBe("no_baselined_vitals"); + }); + + it("returns insufficient when a vital lacks the min baseline days", () => { + const type: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type, + baselineDays: flatBaseline(55, MIN_BASELINE_DAYS - 1, "2026-01-02"), + episodeDays: flatBaseline(55, 6, "2026-01-15"), + }, + ], + }), + ); + expect(out.status).toBe("insufficient"); + }); + + it("returns insufficient when episode coverage is below the floor", () => { + const type: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type, + baselineDays: jitteredBaseline(55, 1, 14, "2026-01-02"), + episodeDays: flatBaseline(55, 2, "2026-01-11"), // only 2 episode days + }, + ], + }), + ); + expect(out.status).toBe("insufficient"); + if (out.status === "insufficient") + expect(out.reason).toBe("insufficient_episode_coverage"); + }); +}); + +describe("computeIllnessCorrelation — golden recovery-gap", () => { + it("computes a positive gap when the body lags the felt-better marker", () => { + // RHR baseline 55, MAD-band ≈ ±4.4 (spread ≈ 2.22, NOTABLE = 2σ). Onset + // 01-10, felt better 01-17. RHR spikes to 75, stays clearly out-of-band + // (≥62) through 01-19, then re-enters the band and holds from 01-20. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-09", mean: 55 }, // pre-onset, in band + { day: "2026-01-10", mean: 70 }, + { day: "2026-01-12", mean: 75 }, // nadir + { day: "2026-01-15", mean: 68 }, + { day: "2026-01-17", mean: 64 }, // felt-better day, still out-of-band + { day: "2026-01-19", mean: 62 }, // still out-of-band + { day: "2026-01-20", mean: 55 }, // return start (in band) + { day: "2026-01-21", mean: 55 }, + { day: "2026-01-22", mean: 55 }, // stable 3 days + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + // returned 01-20, felt better 01-17 → gap +3 + expect(out.value.recoveryGapDays).toBe(dayDiff("2026-01-17", "2026-01-20")); + expect(out.value.recoveryGapDays).toBe(3); + // nadir at 01-12, value 75, adverse (RHR up = adverse) + expect(out.value.nadir[0]?.day).toBe("2026-01-12"); + expect(out.value.nadir[0]?.value).toBe(75); + expect(out.value.nadir[0]?.adverse).toBe(true); + expect(out.value.nadir[0]?.direction).toBe("above"); + }); + + it("flags a pre-onset anomaly when the body deviated before onset", () => { + const type: MeasurementType = "HEART_RATE_VARIABILITY"; + // HRV baseline 60±2. Drops to 30 two days before onset → adverse (down). + const baselineDays = jitteredBaseline(60, 2, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-08", mean: 30 }, // pre-onset crash (notable) + { day: "2026-01-10", mean: 28 }, + { day: "2026-01-12", mean: 32 }, + { day: "2026-01-14", mean: 45 }, + { day: "2026-01-15", mean: 60 }, + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.preOnset[0]?.day).toBe("2026-01-08"); + expect(out.value.preOnset[0]?.direction).toBe("below"); + expect(out.value.preOnset[0]?.adverse).toBe(true); + }); + + it("excludes CHRONIC_ONGOING from the recovery-gap", () => { + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 70 }, + { day: "2026-01-12", mean: 75 }, + { day: "2026-01-15", mean: 55 }, + { day: "2026-01-16", mean: 55 }, + { day: "2026-01-17", mean: 55 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { onsetDay: "2026-01-10", feltBetterDay: "2026-01-15", lifecycle: "CHRONIC_ONGOING" }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.recoveryGapDays).toBeNull(); + expect(out.value.returns).toHaveLength(0); + }); + + it("leaves the gap null when the vital never stably returns (still active)", () => { + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 72 }, + { day: "2026-01-12", mean: 75 }, + { day: "2026-01-14", mean: 73 }, + { day: "2026-01-16", mean: 70 }, // never returns to band + ]; + const out = computeIllnessCorrelation( + input({ + window: { onsetDay: "2026-01-10", feltBetterDay: null, lifecycle: "ACUTE" }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.recoveryGapDays).toBeNull(); + expect(out.value.returns[0]?.returnedDay).toBeNull(); + }); +}); + +describe("computeIllnessCorrelation — baseline-contamination guard", () => { + it("uses ONLY baselineDays for the band, never the episode-span values", () => { + // If episode-span spikes contaminated the band, the 75 readings would + // widen the band and the deviation would shrink. We assert the band is + // built purely from baselineDays by comparing two episodes whose + // baselineDays are identical but episodeDays differ wildly — the nadir + // deviationSd for the SAME nadir value must be identical. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + + const calm = computeIllnessCorrelation( + input({ + series: [ + { + type, + baselineDays, + episodeDays: [ + { day: "2026-01-10", mean: 56 }, + { day: "2026-01-11", mean: 75 }, // single nadir + { day: "2026-01-12", mean: 56 }, + { day: "2026-01-13", mean: 55 }, + ], + }, + ], + }), + ); + const stormy = computeIllnessCorrelation( + input({ + series: [ + { + type, + baselineDays, + episodeDays: [ + { day: "2026-01-10", mean: 90 }, + { day: "2026-01-11", mean: 75 }, // SAME nadir value as calm + { day: "2026-01-12", mean: 88 }, + { day: "2026-01-13", mean: 92 }, + ], + }, + ], + }), + ); + expect(calm.status).toBe("ok"); + expect(stormy.status).toBe("ok"); + if (calm.status !== "ok" || stormy.status !== "ok") return; + // The band center is the clean baseline median (55) in BOTH episodes — + // the wildly different episode-span values never shift it. A contaminated + // baseline (one that ingested the 75–92 readings) would pull the center + // upward and the two centers would diverge. + const calmCenter = calm.value.nadir[0]?.baselineCenter; + const stormyCenter = stormy.value.nadir[0]?.baselineCenter; + expect(calmCenter).toBe(55); + expect(stormyCenter).toBe(55); + expect(stormyCenter).toBe(calmCenter); + }); +}); + +describe("computeIllnessCorrelation — red-flag escalation", () => { + it("escalates a sustained low SpO2 run", () => { + const type: MeasurementType = "OXYGEN_SATURATION"; + const baselineDays = jitteredBaseline(98, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 91 }, + { day: "2026-01-11", mean: 90 }, + { day: "2026-01-12", mean: 89 }, // 3 consecutive ≤92 + { day: "2026-01-13", mean: 96 }, + { day: "2026-01-14", mean: 98 }, + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.redFlags).toHaveLength(1); + expect(out.value.redFlags[0].reason).toBe("sustained_low_spo2"); + expect(out.value.redFlags[0].worstValue).toBe(89); + expect(out.value.redFlags[0].days).toBe(3); + }); + + it("does not escalate a single low SpO2 day", () => { + const type: MeasurementType = "OXYGEN_SATURATION"; + const baselineDays = jitteredBaseline(98, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 91 }, // one dip only + { day: "2026-01-11", mean: 97 }, + { day: "2026-01-12", mean: 98 }, + { day: "2026-01-13", mean: 98 }, + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.redFlags).toHaveLength(0); + }); +}); diff --git a/src/lib/illness/__tests__/retrospective.test.ts b/src/lib/illness/__tests__/retrospective.test.ts new file mode 100644 index 000000000..1c143b71d --- /dev/null +++ b/src/lib/illness/__tests__/retrospective.test.ts @@ -0,0 +1,73 @@ +/** + * v1.18.1 P3 — illness retrospective summary aggregation. + * + * Asserts the count/byMonth/byType tallies, the CHRONIC_ONGOING gap exclusion, + * and the thin-data gate: the "typical recovery gap" is WITHHELD below the + * min-sample floor (never a "typical" claim from one episode). + */ +import { describe, expect, it } from "vitest"; +import { + summarizeIllnessRetrospective, + MIN_EPISODES_FOR_TYPICAL_GAP, + type RetrospectiveEpisode, +} from "../retrospective"; + +function ep(over: Partial): RetrospectiveEpisode { + return { + id: "e", + type: "INFECTION", + onsetDay: "2026-01-10", + resolved: true, + recoveryGapDays: 2, + lifecycle: "ACUTE", + ...over, + }; +} + +describe("summarizeIllnessRetrospective", () => { + it("counts episodes, resolved, by-month, by-type", () => { + const out = summarizeIllnessRetrospective([ + ep({ id: "a", onsetDay: "2026-01-05", type: "INFECTION" }), + ep({ id: "b", onsetDay: "2026-11-20", type: "INFECTION", resolved: false, recoveryGapDays: null }), + ep({ id: "c", onsetDay: "2026-11-02", type: "ALLERGY" }), + ]); + expect(out.episodeCount).toBe(3); + expect(out.resolvedCount).toBe(2); + expect(out.byMonth[1]).toBe(1); + expect(out.byMonth[11]).toBe(2); + expect(out.byType.INFECTION).toBe(2); + expect(out.byType.ALLERGY).toBe(1); + }); + + it("withholds the typical gap below the min-sample floor", () => { + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: 3 }), + ep({ id: "b", recoveryGapDays: 5 }), + ]); + expect(MIN_EPISODES_FOR_TYPICAL_GAP).toBe(3); + expect(out.gapSampleSize).toBe(2); + expect(out.typicalRecoveryGapDays).toBeNull(); + }); + + it("reports the median typical gap at/above the floor", () => { + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: 2 }), + ep({ id: "b", recoveryGapDays: 4 }), + ep({ id: "c", recoveryGapDays: 6 }), + ]); + expect(out.gapSampleSize).toBe(3); + expect(out.typicalRecoveryGapDays).toBe(4); + }); + + it("excludes CHRONIC_ONGOING and null gaps from the median", () => { + const out = summarizeIllnessRetrospective([ + ep({ id: "a", recoveryGapDays: 2 }), + ep({ id: "b", recoveryGapDays: 4 }), + ep({ id: "c", recoveryGapDays: 6 }), + ep({ id: "d", recoveryGapDays: 100, lifecycle: "CHRONIC_ONGOING" }), + ep({ id: "e", recoveryGapDays: null }), + ]); + expect(out.gapSampleSize).toBe(3); + expect(out.typicalRecoveryGapDays).toBe(4); + }); +}); diff --git a/src/lib/illness/correlation-read.ts b/src/lib/illness/correlation-read.ts new file mode 100644 index 000000000..29667cefb --- /dev/null +++ b/src/lib/illness/correlation-read.ts @@ -0,0 +1,168 @@ +/** + * Illness correlation engine — the READ layer (v1.18.1, Workstream B / P3). + * + * Resolves the per-vital per-day mean series the pure `computeIllnessCorrelation` + * engine needs, then runs the engine. Two windows per vital, and the seam + * between them is the BASELINE-CONTAMINATION GUARD: + * + * baselineDays : [onset − lookback − BASELINE_WINDOW_DAYS, onset − lookback) + * episodeDays : [onset − lookback, end] + * + * The baseline window ENDS strictly before the pre-onset lookback, so no day + * inside (or just before) the episode can poison the baseline the episode is + * measured against. This is the single most important reliability property of + * the engine — a baseline that includes the illness days would shrink every + * deviation toward zero and silently under-report the very anomaly we exist to + * surface. + * + * Reads are DAY-native (the spread invariant forbids composing a band from a + * coarser tier's `sd`): the rollup DAY tier when covered, a bounded per-type + * live-SQL fallback on a coverage miss. The `readDayMeanSeries` helper the + * baseline engine already exports does exactly this and is reused verbatim, so + * the rollup-vs-live parity the baseline tests pin carries over for free. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; +import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; +import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; +import type { Derived } from "@/lib/insights/derived/types"; +import { + BASELINE_WINDOW_DAYS, + ILLNESS_SCAN_TYPES, + PRE_ONSET_LOOKBACK_DAYS, + computeIllnessCorrelation, + type IllnessCorrelationValue, + type VitalSeries, +} from "./correlation"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** The episode shape the read layer needs (a subset of the Prisma row). */ +export interface EpisodeForCorrelation { + id: string; + onsetAt: Date; + resolvedAt: Date | null; + lifecycle: string; +} + +/** + * Resolve the per-vital series for one episode (contamination-guarded + * baseline window) and run the pure engine. Returns the same gated + * `Derived` the engine returns — `insufficient` flows straight through. + */ +export async function computeEpisodeCorrelation( + userId: string, + episode: EpisodeForCorrelation, + timezone: string | null, + now: Date = new Date(), +): Promise> { + const tz = resolveUserTimezone(timezone); + const onset = episode.onsetAt; + const end = episode.resolvedAt ?? now; + + // Window seams (UTC instants; the day keys are read in the user's tz). + const preOnsetStart = new Date(onset.getTime() - PRE_ONSET_LOOKBACK_DAYS * MS_PER_DAY); + const baselineEnd = preOnsetStart; // baseline ends where the lookback starts + const baselineStart = new Date( + baselineEnd.getTime() - BASELINE_WINDOW_DAYS * MS_PER_DAY, + ); + + const onsetDay = dayKeyForUserTz(onset, tz); + const feltBetterDay = episode.resolvedAt + ? dayKeyForUserTz(episode.resolvedAt, tz) + : null; + + // One coverage probe shared across every vital read (pool-contention + // mitigation — the same pattern the derived route uses). + const coverage = await probeRollupCoverage(userId); + + const series: VitalSeries[] = []; + let anyLive = false; + let anyDay = false; + + // We attempt every scan type. The read helper reuses the baseline engine's + // rollup-first path (live fallback on a coverage miss); an untracked vital + // returns empty points and is dropped below. Each read is a single windowed + // query, so attempting all types is bounded. + for (const type of ILLNESS_SCAN_TYPES) { + const baseline = await readWindowMeans(userId, type, baselineStart, baselineEnd, coverage, now); + const episodeWin = await readWindowMeans(userId, type, preOnsetStart, end, coverage, now); + if (baseline.points.length === 0 && episodeWin.points.length === 0) continue; + if (baseline.source === "live" || episodeWin.source === "live") anyLive = true; + if (baseline.source === "DAY" || episodeWin.source === "DAY") anyDay = true; + series.push({ + type, + baselineDays: baseline.points.map((p) => ({ day: p.day, mean: p.mean })), + episodeDays: episodeWin.points.map((p) => ({ day: p.day, mean: p.mean })), + }); + } + + const source: "DAY" | "live" | "none" = anyDay + ? "DAY" + : anyLive + ? "live" + : "none"; + + return computeIllnessCorrelation({ + episodeId: episode.id, + window: { onsetDay, feltBetterDay, lifecycle: episode.lifecycle }, + series, + source, + now, + }); +} + +/** + * Per-day means for `(userId, type)` over an explicit [from, to) window. + * Reuses the baseline engine's `readDayMeanSeries` (DAY-native rollup with a + * live fallback) for the whole-window read, then trims to the window so the + * baseline and episode windows can share the one cached read path while + * keeping their seams exact. The trim is on the local day key so the + * contamination guard holds in the user's timezone. + */ +async function readWindowMeans( + userId: string, + type: MeasurementType, + from: Date, + to: Date, + coverage: Awaited>, + now: Date, +): Promise<{ points: { day: string; mean: number }[]; source: "DAY" | "live" | "none" }> { + // The baseline helper reads a trailing window ending at `now`. We need an + // arbitrary [from, to) window, so read directly here: DAY rollup first via + // the helper for the trailing case, else a bounded raw read. To keep the + // seam exact we always do the bounded raw per-day grouping for the bounded + // window (a single indexed query), and only use the helper's rollup path + // when `to` is effectively `now` (the common active-episode case). + const toIsNow = Math.abs(to.getTime() - now.getTime()) < MS_PER_DAY; + if (toIsNow) { + const windowDays = Math.max(1, Math.ceil((to.getTime() - from.getTime()) / MS_PER_DAY)); + const resolved = await readDayMeanSeries(userId, type, windowDays, now, coverage); + const fromKey = from.toISOString().slice(0, 10); + return { + points: resolved.points.filter((p) => p.day >= fromKey), + source: resolved.source === "DAY" ? "DAY" : resolved.source === "none" ? "none" : "live", + }; + } + + const rows = await prisma.measurement.findMany({ + where: { userId, type, deletedAt: null, measuredAt: { gte: from, lt: to } }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true }, + }); + if (rows.length === 0) return { points: [], source: "none" }; + const byDay = new Map(); + for (const row of rows) { + const day = row.measuredAt.toISOString().slice(0, 10); + const acc = byDay.get(day) ?? { sum: 0, count: 0 }; + acc.sum += row.value; + acc.count += 1; + byDay.set(day, acc); + } + const points = [...byDay.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([day, acc]) => ({ day, mean: acc.sum / acc.count })); + return { points, source: "live" }; +} diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts new file mode 100644 index 000000000..af7ad174d --- /dev/null +++ b/src/lib/illness/correlation.ts @@ -0,0 +1,508 @@ +/** + * Illness correlation + recovery-gap engine (v1.18.1, Workstream B / P3). + * + * Server-authoritative, reliability-first. Given ONE illness episode this + * assembles three retrospective findings — never a prediction, never a + * diagnosis — each gated by the shared coverage/confidence model so a thin + * signal asserts NOTHING: + * + * 1. **Pre-onset anomaly scan** — the N days BEFORE onset, each tracked + * vital's deviation in robust SDs from the user's OWN baseline (median + * ± MAD, the same `computeVitalsBaseline` engine), the baseline computed + * from a window that ENDS before the pre-onset lookback so the episode's + * own physiology can never poison the baseline it is measured against. + * "How did it announce itself?" + * 2. **Nadir / what-dropped** — across the episode's active span, the + * furthest each vital strayed from baseline (the nadir day + magnitude + + * direction). "What dropped." + * 3. **Recovery-gap** — the intellectual core: physiological return-to- + * baseline (first day a previously-deviated vital re-enters its band and + * STAYS in for `RETURN_STABILITY_DAYS`) vs the felt-better marker + * (`resolvedAt`). The gap is `physiologicalReturn − feltBetter` in days: + * positive = the body lagged the feeling (you felt better before your + * numbers did), negative = numbers normalised first. CHRONIC_ONGOING is + * excluded (no recovered date). + * + * The directionality of "worse" is metric-specific (a HRV/recovery DROP and a + * resting-HR/temperature RISE both mean "stressed"); `ADVERSE_DIRECTION` + * pins it per metric. Red-flag patterns (a sustained large oxygen-saturation + * drop, a sustained fever) ESCALATE to a "seek care" flag — never reassure. + * + * Pure given its inputs: the caller does the bounded reads (baseline series + + * per-day vital means within the episode window) and passes them in, so the + * whole engine is unit-testable against golden fixtures with no DB. The route + * wires the reads; the parity test asserts the rollup-fed path and a raw-SQL + * path agree. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { buildBaselineBand, median } from "@/lib/insights/derived/baseline"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "@/lib/insights/derived/coverage"; +import type { Derived } from "@/lib/insights/derived/types"; + +/* ── tunables (documented invariants) ────────────────────────────────── */ + +/** Days before onset scanned for the "how did it announce itself" signal. */ +export const PRE_ONSET_LOOKBACK_DAYS = 7; +/** Trailing baseline window (days), ending BEFORE the pre-onset lookback. */ +export const BASELINE_WINDOW_DAYS = 30; +/** + * Minimum distinct baseline days a vital needs before we trust its band. + * Mirrors `computeVitalsBaseline`'s 7-day floor — below this the vital is + * dropped from the scan rather than asserted on a fragile band. + */ +export const MIN_BASELINE_DAYS = 7; +/** + * A deviation of ≥ this many robust SDs (k·MAD·scale units) from baseline + * counts as "notable" for the pre-onset + nadir surfaces. ~2σ-equivalent. + */ +export const NOTABLE_SD = 2; +/** + * Consecutive in-band days required to call a vital "returned to baseline" + * for the recovery-gap (one lucky in-band day is not a recovery). + */ +export const RETURN_STABILITY_DAYS = 3; +/** Minimum episode days with ANY vital coverage before the gap is asserted. */ +export const MIN_EPISODE_COVERAGE_DAYS = 4; + +/** + * Per-metric adverse direction — which way a reading moves when the body is + * under illness stress. `up` = a RISE is adverse (resting HR, temperature, + * respiratory rate, BP); `down` = a DROP is adverse (HRV, recovery, SpO2). + * Weight/glucose have no single illness-adverse direction → omitted (their + * deviation magnitude is still surfaced, direction stays neutral). + */ +export const ADVERSE_DIRECTION: Partial> = + { + RESTING_HEART_RATE: "up", + PULSE: "up", + BODY_TEMPERATURE: "up", + SKIN_TEMPERATURE: "up", + RESPIRATORY_RATE: "up", + BLOOD_PRESSURE_SYS: "up", + BLOOD_PRESSURE_DIA: "up", + HEART_RATE_VARIABILITY: "down", + RECOVERY_SCORE: "down", + OXYGEN_SATURATION: "down", + }; + +/** + * Vitals scanned by the illness engine. Superset of the typical-range set + * plus RECOVERY_SCORE (a canonical wellness signal). The caller only passes + * series for the vitals the user actually tracks; the rest are skipped. + */ +export const ILLNESS_SCAN_TYPES: MeasurementType[] = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RECOVERY_SCORE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "RESPIRATORY_RATE", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "WEIGHT", +]; + +/* ── inputs the caller resolves and passes in (pure engine) ──────────── */ + +/** A per-day mean for one vital (YYYY-MM-DD local day → mean). */ +export interface VitalDayPoint { + /** Local day key, `YYYY-MM-DD`. */ + day: string; + /** Mean reading for the day. */ + mean: number; +} + +/** + * Everything the engine needs for ONE vital: the baseline-window series + * (ENDS before the pre-onset lookback — contamination guard lives in the + * caller's read window) and the episode-window series (pre-onset lookback + * through the felt-better marker, inclusive). + */ +export interface VitalSeries { + type: MeasurementType; + /** Per-day means over the clean baseline window (no episode-span days). */ + baselineDays: VitalDayPoint[]; + /** Per-day means from `onset − lookback` through `resolvedAt` (or today). */ + episodeDays: VitalDayPoint[]; +} + +/** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */ +export interface EpisodeWindow { + /** Onset day. */ + onsetDay: string; + /** Felt-better marker (resolvedAt local day), or null when still active. */ + feltBetterDay: string | null; + /** Lifecycle — CHRONIC_ONGOING suppresses the recovery-gap. */ + lifecycle: string; +} + +export interface IllnessCorrelationInput { + episodeId: string; + window: EpisodeWindow; + /** One entry per vital the user tracks (others omitted by the caller). */ + series: VitalSeries[]; + /** Provenance source the dominant read resolved against. */ + source: "DAY" | "live" | "none"; + /** Compute time (injected for deterministic tests). */ + now?: Date; +} + +/* ── output value (the server-authoritative DTO payload) ─────────────── */ + +/** One vital's deviation finding on a given day. */ +export interface VitalDeviationFinding { + type: MeasurementType; + /** The day this finding describes (`YYYY-MM-DD`). */ + day: string; + /** The day's mean reading. */ + value: number; + /** Robust baseline center the deviation is measured from. */ + baselineCenter: number; + /** Signed deviation in robust-SD (k·MAD·scale) units; +above / −below. */ + deviationSd: number; + /** Which way the reading moved. */ + direction: "above" | "below"; + /** True when this move is the illness-adverse direction for the metric. */ + adverse: boolean; +} + +/** A vital's recovery timing within the episode. */ +export interface VitalReturnFinding { + type: MeasurementType; + /** First day the vital re-entered its band AND stayed in for the window. */ + returnedDay: string | null; + /** Days from felt-better to physiological return (signed); null if N/A. */ + gapDays: number | null; +} + +export interface IllnessCorrelationValue { + episodeId: string; + /** "How did it announce itself" — notable pre-onset deviations. */ + preOnset: VitalDeviationFinding[]; + /** "What dropped" — each vital's worst (nadir) deviation in the episode. */ + nadir: VitalDeviationFinding[]; + /** Per-vital physiological-return timing. */ + returns: VitalReturnFinding[]; + /** + * The headline recovery-gap in days (median of the per-vital gaps), or + * null when CHRONIC_ONGOING / still active / no vital returned. Positive + * = the body lagged the felt-better marker. + */ + recoveryGapDays: number | null; + /** Felt-better marker echoed for the surface. */ + feltBetterDay: string | null; + /** + * Red-flag escalation — a sustained adverse SpO2 drop or sustained fever + * during the episode. Retrospective, but the copy must escalate ("if this + * recurs, seek care"), NEVER reassure. Empty when nothing tripped. + */ + redFlags: IllnessRedFlag[]; +} + +export interface IllnessRedFlag { + type: MeasurementType; + /** A short stable reason key the i18n layer renders (not free text). */ + reason: "sustained_low_spo2" | "sustained_fever"; + /** The worst observed value during the flagged run. */ + worstValue: number; + /** Consecutive days the adverse threshold held. */ + days: number; +} + +/* ── red-flag thresholds (clinical floors, conservative) ─────────────── */ + +/** SpO2 at/below this for ≥ RED_FLAG_RUN_DAYS escalates. */ +const SPO2_RED_FLAG = 92; +/** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */ +const FEVER_RED_FLAG = 38.5; +/** Consecutive days an adverse clinical threshold must hold to escalate. */ +const RED_FLAG_RUN_DAYS = 3; + +/* ── pure helpers ─────────────────────────────────────────────────────── */ + +/** Robust band for a vital, or null below the min-baseline-days floor. */ +function bandFor(series: VitalSeries): { + center: number; + spread: number; +} | null { + if (series.baselineDays.length < MIN_BASELINE_DAYS) return null; + const band = buildBaselineBand(series.baselineDays.map((p) => p.mean)); + if (!band || band.spread <= 0) return null; + return { center: band.center, spread: band.spread }; +} + +/** Signed deviation of a value in robust-SD units (spread = k·MAD·scale). */ +function deviationSd(value: number, center: number, spread: number): number { + return (value - center) / spread; +} + +/** Build a deviation finding for one day/value against a band. */ +function finding( + type: MeasurementType, + day: string, + value: number, + center: number, + spread: number, +): VitalDeviationFinding { + const sd = deviationSd(value, center, spread); + const direction: "above" | "below" = sd >= 0 ? "above" : "below"; + const adverseDir = ADVERSE_DIRECTION[type]; + const adverse = + (adverseDir === "up" && direction === "above") || + (adverseDir === "down" && direction === "below"); + return { + type, + day, + value, + baselineCenter: center, + deviationSd: Math.round(sd * 100) / 100, + direction, + adverse, + }; +} + +/** Order day keys chronologically. */ +function byDay(a: { day: string }, b: { day: string }): number { + return a.day < b.day ? -1 : a.day > b.day ? 1 : 0; +} + +/* ── the engine ───────────────────────────────────────────────────────── */ + +/** + * Compute the retrospective correlation findings for one episode. Pure given + * its inputs — the caller resolves the reads (rollup tier with a live-SQL + * fallback) and passes the series in. Returns a gated `Derived`: below the + * coverage floor it returns `insufficient` (never a fabricated finding). + */ +export function computeIllnessCorrelation( + input: IllnessCorrelationInput, +): Derived { + const now = input.now ?? new Date(); + const computedAt = nowProvenanceTimestamp(now); + const { window } = input; + + // Only vitals with a trustworthy own-baseline band participate. + const banded = input.series + .map((s) => ({ series: s, band: bandFor(s) })) + .filter((b): b is { series: VitalSeries; band: { center: number; spread: number } } => b.band !== null); + + const inputs = banded.map((b) => String(b.series.type)); + const provenance = { + inputs, + source: input.source, + windowDays: BASELINE_WINDOW_DAYS, + computedAt, + }; + + // Coverage: how many distinct episode days carry ANY banded vital reading. + const episodeDayKeys = new Set(); + for (const b of banded) { + for (const p of b.series.episodeDays) { + if (p.day >= window.onsetDay) episodeDayKeys.add(p.day); + } + } + const coverageDays = episodeDayKeys.size; + + if (banded.length === 0 || coverageDays < MIN_EPISODE_COVERAGE_DAYS) { + const { coverage } = deriveCoverage({ + requiredInputs: 1, + presentInputs: banded.length, + historyDays: coverageDays, + missing: banded.length === 0 ? ILLNESS_SCAN_TYPES.map(String) : [], + fullHistoryDays: MIN_EPISODE_COVERAGE_DAYS, + }); + return buildInsufficient({ + coverage, + provenance, + reason: + banded.length === 0 + ? "no_baselined_vitals" + : "insufficient_episode_coverage", + }); + } + + const preOnset: VitalDeviationFinding[] = []; + const nadir: VitalDeviationFinding[] = []; + const returns: VitalReturnFinding[] = []; + const redFlags: IllnessRedFlag[] = []; + + for (const { series, band } of banded) { + const { type } = series; + const { center, spread } = band; + const episode = [...series.episodeDays].sort(byDay); + + // 1) Pre-onset scan: lookback window strictly before onset. + const pre = episode.filter((p) => p.day < window.onsetDay); + let worstPre: VitalDeviationFinding | null = null; + for (const p of pre) { + const f = finding(type, p.day, p.mean, center, spread); + if ( + Math.abs(f.deviationSd) >= NOTABLE_SD && + (worstPre === null || + Math.abs(f.deviationSd) > Math.abs(worstPre.deviationSd)) + ) { + worstPre = f; + } + } + if (worstPre) preOnset.push(worstPre); + + // 2) Nadir: worst deviation across the active span (onset → end). + const active = episode.filter((p) => p.day >= window.onsetDay); + let worstActive: VitalDeviationFinding | null = null; + for (const p of active) { + const f = finding(type, p.day, p.mean, center, spread); + if ( + Math.abs(f.deviationSd) >= NOTABLE_SD && + (worstActive === null || + Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd)) + ) { + worstActive = f; + } + } + if (worstActive) nadir.push(worstActive); + + // 3) Physiological return: first active day this vital deviated + // notably-adverse, then later re-entered the band and STAYED in for + // RETURN_STABILITY_DAYS. Only meaningful if it actually deviated. + const deviatedActive = active.some((p) => { + const f = finding(type, p.day, p.mean, center, spread); + return Math.abs(f.deviationSd) >= NOTABLE_SD; + }); + if (deviatedActive && window.lifecycle !== "CHRONIC_ONGOING") { + const returnedDay = firstStableReturn(active, center, spread); + const gapDays = + returnedDay && window.feltBetterDay + ? dayDiff(window.feltBetterDay, returnedDay) + : null; + returns.push({ type, returnedDay, gapDays }); + } + + // Red-flag escalation (clinical floors, retrospective). + const flag = detectRedFlag(type, active); + if (flag) redFlags.push(flag); + } + + // Headline gap = median of the per-vital signed gaps (robust to one vital + // recovering oddly). Null for CHRONIC_ONGOING / still active / no returns. + const gaps = returns + .map((r) => r.gapDays) + .filter((g): g is number => g !== null); + const recoveryGapDays = + window.lifecycle === "CHRONIC_ONGOING" || gaps.length === 0 + ? null + : Math.round(median(gaps)); + + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: banded.length, + historyDays: coverageDays, + missing: [], + fullHistoryDays: Math.max(MIN_EPISODE_COVERAGE_DAYS, 14), + }); + + return buildOk({ + value: { + episodeId: input.episodeId, + preOnset: preOnset.sort((a, b) => Math.abs(b.deviationSd) - Math.abs(a.deviationSd)), + nadir: nadir.sort((a, b) => Math.abs(b.deviationSd) - Math.abs(a.deviationSd)), + returns, + recoveryGapDays, + feltBetterDay: window.feltBetterDay, + redFlags, + }, + coverage, + confidence, + provenance, + }); +} + +/** + * First day in `active` (chronological) where the vital is in-band AND stays + * in-band for the next `RETURN_STABILITY_DAYS` observed days. Null when it + * never settles. "In-band" = |deviation| < NOTABLE_SD. + */ +function firstStableReturn( + active: VitalDayPoint[], + center: number, + spread: number, +): string | null { + const inBand = active.map((p) => ({ + day: p.day, + in: Math.abs(deviationSd(p.mean, center, spread)) < NOTABLE_SD, + })); + for (let i = 0; i < inBand.length; i++) { + if (!inBand[i].in) continue; + let run = 0; + for (let j = i; j < inBand.length && inBand[j].in; j++) run++; + if (run >= RETURN_STABILITY_DAYS) return inBand[i].day; + } + return null; +} + +/** + * Detect a retrospective red flag: a sustained adverse SpO2 drop or a + * sustained fever during the active span. Conservative clinical floors; + * escalation copy lives in the i18n layer. + */ +function detectRedFlag( + type: MeasurementType, + active: VitalDayPoint[], +): IllnessRedFlag | null { + const sorted = [...active].sort(byDay); + if (type === "OXYGEN_SATURATION") { + return runFlag(sorted, (v) => v <= SPO2_RED_FLAG, "sustained_low_spo2", type, "min"); + } + if (type === "BODY_TEMPERATURE") { + return runFlag(sorted, (v) => v >= FEVER_RED_FLAG, "sustained_fever", type, "max"); + } + return null; +} + +/** Longest consecutive run matching `predicate`; flag when ≥ RED_FLAG_RUN_DAYS. */ +function runFlag( + sorted: VitalDayPoint[], + predicate: (v: number) => boolean, + reason: IllnessRedFlag["reason"], + type: MeasurementType, + worst: "min" | "max", +): IllnessRedFlag | null { + let bestRun = 0; + let bestWorst: number | null = null; + let run = 0; + let runWorst: number | null = null; + for (const p of sorted) { + if (predicate(p.mean)) { + run++; + runWorst = + runWorst === null + ? p.mean + : worst === "min" + ? Math.min(runWorst, p.mean) + : Math.max(runWorst, p.mean); + if (run > bestRun) { + bestRun = run; + bestWorst = runWorst; + } + } else { + run = 0; + runWorst = null; + } + } + if (bestRun >= RED_FLAG_RUN_DAYS && bestWorst !== null) { + return { type, reason, worstValue: bestWorst, days: bestRun }; + } + return null; +} + +/** Whole-day signed difference `to − from` for two `YYYY-MM-DD` keys. */ +export function dayDiff(from: string, to: string): number { + const a = Date.parse(`${from}T00:00:00Z`); + const b = Date.parse(`${to}T00:00:00Z`); + return Math.round((b - a) / (24 * 60 * 60 * 1000)); +} diff --git a/src/lib/illness/retrospective.ts b/src/lib/illness/retrospective.ts new file mode 100644 index 000000000..08ab725f3 --- /dev/null +++ b/src/lib/illness/retrospective.ts @@ -0,0 +1,98 @@ +/** + * Illness retrospective summary (v1.18.1, Workstream B / P3). + * + * The look-back surface: "sick N times this year · typical recovery gap X + * days". RETROSPECTIVE ONLY — it summarises what already happened. It is + * NEVER a predictor or diagnoser; the recurrence figure is a count of past + * episodes, not a forecast, and the copy layer must frame it as such. + * + * Pure given a list of resolved episode summaries + their computed gaps. The + * caller resolves the per-episode recovery-gap (via the correlation engine, + * which is itself coverage-gated) and passes the numbers in; this layer only + * aggregates. The aggregate is itself gated — with too few resolved episodes + * to say anything honest, it reports the count but withholds the "typical + * gap" (asserts nothing thin). + */ +import { median } from "@/lib/insights/derived/baseline"; + +/** Minimum resolved-with-gap episodes before a "typical gap" is asserted. */ +export const MIN_EPISODES_FOR_TYPICAL_GAP = 3; + +/** One episode's contribution to the summary. */ +export interface RetrospectiveEpisode { + id: string; + type: string; + /** Onset local day `YYYY-MM-DD` (for the recurrence-month tally). */ + onsetDay: string; + /** Whether the episode is resolved (has a felt-better marker). */ + resolved: boolean; + /** The computed recovery-gap in days, or null when ungated/unavailable. */ + recoveryGapDays: number | null; + /** Lifecycle — CHRONIC_ONGOING never contributes a gap. */ + lifecycle: string; +} + +export interface IllnessRetrospectiveSummary { + /** Episodes whose onset falls in the requested window (e.g. trailing year). */ + episodeCount: number; + /** Of those, how many are resolved. */ + resolvedCount: number; + /** + * Typical (median) recovery-gap in days across episodes with a computed + * gap — null when fewer than `MIN_EPISODES_FOR_TYPICAL_GAP` qualify. A + * positive gap means the body typically lagged the felt-better marker. + */ + typicalRecoveryGapDays: number | null; + /** How many episodes backed the typical-gap figure (transparency). */ + gapSampleSize: number; + /** + * Recurrence tally by calendar month (1–12) → count, retrospective only. + * Drives "you tend to log illnesses in November" as an OBSERVATION, never + * a prediction. Empty when nothing falls in the window. + */ + byMonth: Record; + /** Per-type episode counts in the window (retrospective breakdown). */ + byType: Record; +} + +/** + * Aggregate the per-episode figures into the retrospective summary. Pure. The + * typical-gap is withheld (null) below the min-sample floor so a single + * episode never becomes a "typical" claim. + */ +export function summarizeIllnessRetrospective( + episodes: RetrospectiveEpisode[], +): IllnessRetrospectiveSummary { + const byMonth: Record = {}; + const byType: Record = {}; + let resolvedCount = 0; + const gaps: number[] = []; + + for (const e of episodes) { + const month = Number(e.onsetDay.slice(5, 7)); + if (month >= 1 && month <= 12) byMonth[month] = (byMonth[month] ?? 0) + 1; + byType[e.type] = (byType[e.type] ?? 0) + 1; + if (e.resolved) resolvedCount++; + if ( + e.lifecycle !== "CHRONIC_ONGOING" && + e.recoveryGapDays !== null && + Number.isFinite(e.recoveryGapDays) + ) { + gaps.push(e.recoveryGapDays); + } + } + + const typicalRecoveryGapDays = + gaps.length >= MIN_EPISODES_FOR_TYPICAL_GAP + ? Math.round(median(gaps)) + : null; + + return { + episodeCount: episodes.length, + resolvedCount, + typicalRecoveryGapDays, + gapSampleSize: gaps.length, + byMonth, + byType, + }; +} diff --git a/src/lib/query-keys/illness.ts b/src/lib/query-keys/illness.ts index 9c2276915..1b1d9ee80 100644 --- a/src/lib/query-keys/illness.ts +++ b/src/lib/query-keys/illness.ts @@ -18,4 +18,17 @@ export const illnessKeys = { illnessEpisode: (id: string) => ["illness", "episode", id] as const, illnessDayLog: (episodeId: string, date: string) => ["illness", "day-log", episodeId, date] as const, + /** + * v1.18.1 P3 — the per-episode retrospective correlation findings + * (pre-onset scan, nadir, recovery-gap). Server-authoritative; the read + * is gated, so the surface pattern-matches `status` rather than recomputing. + */ + illnessCorrelation: (id: string) => + ["illness", "correlation", id] as const, + /** + * v1.18.1 P3 — the cross-episode retrospective summary ("sick N times · + * typical recovery gap X days") over a trailing window in days. + */ + illnessInsights: (windowDays: number) => + ["illness", "insights", windowDays] as const, }; diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index b97de9018..5af3fc615 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -133,3 +133,14 @@ export type IllnessDayLogInput = z.infer; /** The single-day read query: `GET .../day-logs?date=YYYY-MM-DD`. */ export const illnessDayLogQuerySchema = z.object({ date: dateString }); + +/* ── P3 retrospective insight window ─────────────────────────────────── */ + +/** + * The retrospective-insight window query: `GET /api/illness/insights? + * windowDays=365`. Bounded 30..1095 days; defaults to a trailing year. + * Retrospective only — the engine summarises past episodes, never forecasts. + */ +export const illnessInsightsQuerySchema = z.object({ + windowDays: z.coerce.number().int().min(30).max(1095).optional(), +}); From c59cf0b652db58538c2e74576643a04886477db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 22:25:50 +0200 Subject: [PATCH 28/48] =?UTF-8?q?feat(illness):=20Rest=20Mode=20=E2=80=94?= =?UTF-8?q?=20annotate=20score/recovery/streaks=20during=20an=20active=20e?= =?UTF-8?q?pisode=20+=20FHIR/doctor-report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An active illness/condition episode annotates, it never penalises: the measured number is untouched, only the narrative changes. - rest-mode.ts: one server-authoritative resolver (module-gated, fail-soft) for "is the account in Rest Mode + which episodes back it". iOS mirrors the resolved context, never recomputes the suppression. - health-score: /api/analytics attaches a value-free Rest Mode annotation; computeHealthScore stays pure (score never adjusted). - recovery-score: computeRecoveryScore carries the Rest Mode context as of the scored day; the persisted blend is unchanged. - coach-nudge: pause cadence-nudges during an active episode (new skippedDuringIllness counter, gate sits before the trigger search). - achievements: bridgeFrozenStreakGaps freezes (does not break) every day-streak across illness days; never invents a streak, a non-illness gap still breaks. - coincident-deviation: reframe a fired flag as illness-explained; vital numbers untouched. Surfaced as a neutral line on the insights card. - coach snapshot: illness history + active illness as load-bearing context so the coach knows about Rest Mode (no decrypted note reaches the prompt). - FHIR: illness episodes map to Condition (generic disease root, label on code.text, patient-reported/unconfirmed) + a bounding Encounter. - doctor-report: module-gated illness section in the aggregator + PDF table (retrospective, neutral palette); FHIR Condition section in the bundle. i18n x6 for the report + reframe copy. Tests: score-annotation-not- penalisation, streak-freeze, coach-nudge pause, FHIR Condition/Encounter mapping, doctor-report inclusion, Rest Mode resolver. typecheck/lint/test green, openapi in sync, knip clean. --- messages/de.json | 12 +- messages/en.json | 12 +- messages/es.json | 12 +- messages/fr.json | 12 +- messages/it.json | 12 +- messages/pl.json | 12 +- src/app/api/analytics/route.ts | 16 ++ .../health-record/__tests__/route.test.ts | 2 + .../__tests__/hidden-redaction.test.ts | 2 + .../achievements/__tests__/ios-format.test.ts | 2 + .../__tests__/module-gate.test.ts | 2 + .../api/gamification/achievements/route.ts | 69 ++++++++- .../coincident-deviation-card.test.tsx | 33 ++++- .../insights/coincident-deviation-card.tsx | 8 +- .../__tests__/doctor-report-pdf-core.test.ts | 59 ++++++++ .../coach/__tests__/snapshot-budget.test.ts | 1 + .../__tests__/snapshot-new-metrics.test.ts | 1 + src/lib/ai/coach/__tests__/snapshot.test.ts | 2 + src/lib/ai/coach/illness-snapshot.ts | 84 +++++++++++ src/lib/ai/coach/snapshot.ts | 18 +++ src/lib/analytics/health-score.ts | 21 +++ src/lib/doctor-report-data.ts | 58 ++++++++ src/lib/doctor-report-pdf-core.ts | 59 ++++++++ src/lib/fhir/__tests__/build-bundle.test.ts | 102 +++++++++++++ src/lib/fhir/build-bundle.ts | 27 ++++ src/lib/fhir/resources.ts | 115 +++++++++++++++ src/lib/fhir/types.ts | 41 ++++++ .../__tests__/achievements.test.ts | 41 ++++++ src/lib/gamification/achievements.ts | 62 ++++++++ src/lib/illness/__tests__/rest-mode.test.ts | 117 +++++++++++++++ src/lib/illness/rest-mode.ts | 137 ++++++++++++++++++ .../insights/__tests__/recovery-score.test.ts | 55 +++++++ .../__tests__/coincident-deviation.test.ts | 71 +++++++++ .../insights/derived/coincident-deviation.ts | 19 +++ src/lib/insights/recovery-score.ts | 15 +- src/lib/jobs/__tests__/coach-nudge.test.ts | 43 ++++++ src/lib/jobs/coach-nudge.ts | 22 +++ 37 files changed, 1352 insertions(+), 24 deletions(-) create mode 100644 src/lib/ai/coach/illness-snapshot.ts create mode 100644 src/lib/illness/__tests__/rest-mode.test.ts create mode 100644 src/lib/illness/rest-mode.ts diff --git a/messages/de.json b/messages/de.json index a0665ff79..a52288d12 100644 --- a/messages/de.json +++ b/messages/de.json @@ -204,7 +204,14 @@ "eA1c": "Geschätzter HbA1c", "cv": "Variabilität (VK%)", "spotCaveat": "Dies sind Schätzwerte aus Einzelmessungen, kein Profil eines kontinuierlichen Messgeräts — als Tendenz und nicht als klinisches Urteil zu lesen." - } + }, + "illnessTitle": "Erkrankungen", + "illnessOngoing": "Andauernd", + "illnessColCondition": "Erkrankung", + "illnessColType": "Art", + "illnessColLifecycle": "Verlauf", + "illnessColOnset": "Beginn", + "illnessColResolved": "Abgeklungen" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -3462,7 +3469,8 @@ "watchVital": "Außerhalb des Bereichs: {vital}. Oft nur einmalig — behalte es in den nächsten Tagen im Blick.", "firedHeadline": "{count} deiner Vitalwerte liegen heute außerhalb ihres persönlichen Bereichs.", "factors": "Mögliche Faktoren — nie eine Ursache: ein hartes Training, schlechter Schlaf, Alkohol, Höhe, Stress oder Krankheit. Das ist ein Hinweis aus deinen eigenen Baselines, keine Diagnose.", - "building": "Deine persönlichen Baselines entstehen — erfasse ein paar Tage mehr, dann erscheint dies." + "building": "Deine persönlichen Baselines entstehen — erfasse ein paar Tage mehr, dann erscheint dies.", + "illnessExplained": "Diese Abweichungen hängen wahrscheinlich mit der erfassten Erkrankung zusammen." }, "trajectory": { "cardTitle": "Kurzfrist-Projektion", diff --git a/messages/en.json b/messages/en.json index 6f6bb9e82..21c263420 100644 --- a/messages/en.json +++ b/messages/en.json @@ -204,7 +204,14 @@ "eA1c": "Estimated A1C", "cv": "Variability (CV%)", "spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict." - } + }, + "illnessTitle": "Conditions & illnesses", + "illnessOngoing": "Ongoing", + "illnessColCondition": "Condition", + "illnessColType": "Type", + "illnessColLifecycle": "Course", + "illnessColOnset": "Onset", + "illnessColResolved": "Resolved" }, "errorBoundary": { "title": "Something went wrong", @@ -3462,7 +3469,8 @@ "watchVital": "Outside range: {vital}. Often just a one-off — worth a glance over the next few days.", "firedHeadline": "{count} of your vitals are outside their personal range today.", "factors": "Possible factors — never a cause: a hard workout, poor sleep, alcohol, altitude, stress, or illness. This is awareness from your own baselines, not a diagnosis.", - "building": "Building your personal baselines — track a few more days and this will appear." + "building": "Building your personal baselines — track a few more days and this will appear.", + "illnessExplained": "These shifts likely reflect the condition you logged." }, "trajectory": { "cardTitle": "Short-horizon projection", diff --git a/messages/es.json b/messages/es.json index 7fb8cfb38..fdc0aa518 100644 --- a/messages/es.json +++ b/messages/es.json @@ -204,7 +204,14 @@ "eA1c": "A1C estimada", "cv": "Variabilidad (CV%)", "spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de monitor continuo — léelas como una tendencia, no como un veredicto clínico." - } + }, + "illnessTitle": "Afecciones y enfermedades", + "illnessOngoing": "En curso", + "illnessColCondition": "Afección", + "illnessColType": "Tipo", + "illnessColLifecycle": "Curso", + "illnessColOnset": "Inicio", + "illnessColResolved": "Resuelta" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -3462,7 +3469,8 @@ "watchVital": "Fuera de rango: {vital}. A menudo es algo puntual; conviene vigilarlo los próximos días.", "firedHeadline": "Hoy, {count} de tus constantes están fuera de su rango personal.", "factors": "Posibles factores, nunca una causa: un entrenamiento intenso, dormir mal, alcohol, altitud, estrés o una enfermedad. Es un aviso a partir de tus propias bases, no un diagnóstico.", - "building": "Construyendo tus bases personales: registra unos días más y esto aparecerá." + "building": "Construyendo tus bases personales: registra unos días más y esto aparecerá.", + "illnessExplained": "Es probable que estas variaciones reflejen la afección que registraste." }, "trajectory": { "cardTitle": "Proyección a corto plazo", diff --git a/messages/fr.json b/messages/fr.json index f49bfae54..ca423c5b9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -204,7 +204,14 @@ "eA1c": "HbA1c estimée", "cv": "Variabilité (CV%)", "spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, et non un profil de capteur continu — à lire comme une tendance, pas comme un verdict clinique." - } + }, + "illnessTitle": "Affections et maladies", + "illnessOngoing": "En cours", + "illnessColCondition": "Affection", + "illnessColType": "Type", + "illnessColLifecycle": "Évolution", + "illnessColOnset": "Début", + "illnessColResolved": "Résolue" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -3462,7 +3469,8 @@ "watchVital": "Hors plage : {vital}. Souvent ponctuel — à surveiller ces prochains jours.", "firedHeadline": "Aujourd’hui, {count} de vos constantes sont hors de leur plage personnelle.", "factors": "Facteurs possibles — jamais une cause : un entraînement intense, un mauvais sommeil, l’alcool, l’altitude, le stress ou une maladie. C’est un repère issu de vos propres références, pas un diagnostic.", - "building": "Construction de vos références personnelles — enregistrez quelques jours de plus et cela apparaîtra." + "building": "Construction de vos références personnelles — enregistrez quelques jours de plus et cela apparaîtra.", + "illnessExplained": "Ces écarts reflètent probablement l’affection que vous avez consignée." }, "trajectory": { "cardTitle": "Projection à court terme", diff --git a/messages/it.json b/messages/it.json index 71172582a..fdfaf6a6c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -204,7 +204,14 @@ "eA1c": "A1C stimata", "cv": "Variabilità (CV%)", "spotCaveat": "Sono stime da misurazioni occasionali, non un profilo da monitor continuo — vanno lette come una tendenza, non come un verdetto clinico." - } + }, + "illnessTitle": "Condizioni e malattie", + "illnessOngoing": "In corso", + "illnessColCondition": "Condizione", + "illnessColType": "Tipo", + "illnessColLifecycle": "Decorso", + "illnessColOnset": "Inizio", + "illnessColResolved": "Risolta" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -3462,7 +3469,8 @@ "watchVital": "Fuori intervallo: {vital}. Spesso è un episodio isolato — tienilo d’occhio nei prossimi giorni.", "firedHeadline": "Oggi {count} dei tuoi parametri vitali sono fuori dal loro intervallo personale.", "factors": "Possibili fattori — mai una causa: un allenamento intenso, poco sonno, alcol, altitudine, stress o una malattia. È un avviso basato sui tuoi parametri, non una diagnosi.", - "building": "Stiamo costruendo i tuoi riferimenti personali — registra ancora qualche giorno e comparirà." + "building": "Stiamo costruendo i tuoi riferimenti personali — registra ancora qualche giorno e comparirà.", + "illnessExplained": "Questi scostamenti riflettono probabilmente la condizione che hai registrato." }, "trajectory": { "cardTitle": "Proiezione a breve termine", diff --git a/messages/pl.json b/messages/pl.json index 66756a704..d4689d7b5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -204,7 +204,14 @@ "eA1c": "Szacowane HbA1c", "cv": "Zmienność (CV%)", "spotCaveat": "To szacunki z pojedynczych pomiarów, nie profil z monitora ciągłego — czytaj je jako kierunek zmian, a nie kliniczny werdykt." - } + }, + "illnessTitle": "Schorzenia i choroby", + "illnessOngoing": "Trwająca", + "illnessColCondition": "Schorzenie", + "illnessColType": "Typ", + "illnessColLifecycle": "Przebieg", + "illnessColOnset": "Początek", + "illnessColResolved": "Ustąpiła" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -3462,7 +3469,8 @@ "watchVital": "Poza zakresem: {vital}. Często to jednorazowy odczyt — warto obserwować przez kilka dni.", "firedHeadline": "Dziś {count} Twoich parametrów życiowych jest poza osobistym zakresem.", "factors": "Możliwe czynniki — nigdy przyczyna: intensywny trening, słaby sen, alkohol, wysokość, stres lub choroba. To wskazówka z Twoich własnych baz, nie diagnoza.", - "building": "Budujemy Twoje osobiste bazy — zarejestruj jeszcze kilka dni, a to się pojawi." + "building": "Budujemy Twoje osobiste bazy — zarejestruj jeszcze kilka dni, a to się pojawi.", + "illnessExplained": "Te odchylenia prawdopodobnie wynikają z zapisanego schorzenia." }, "trajectory": { "cardTitle": "Krótkoterminowy rzut", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 749a7beed..2f9756c28 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -18,6 +18,7 @@ import { } from "@/lib/analytics/bp-in-target-fast-path"; import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { resolveRestMode } from "@/lib/illness/rest-mode"; import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; @@ -444,6 +445,21 @@ async function buildAnalyticsResponse(user: AuthedUser) { moodEnabled, }); + // v1.18.1 P4 — Rest Mode annotation. When an illness/condition episode is + // active we frame the score (not penalise it): the number above is left + // exactly as computed and we attach a value-free "you were unwell" context. + // Resolved server-side so iOS mirrors the same fact, never recomputing it. + if (healthScore) { + const restMode = await resolveRestMode(user.id); + healthScore.restMode = restMode.active + ? { + active: true, + since: restMode.since, + episodeCount: restMode.episodeCount, + } + : null; + } + return { summaries: results, bmi, diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 661a4fa47..67a9ba6eb 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => ({ medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, labResult: { findMany: vi.fn() }, + illnessEpisode: { findMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn() }, }, })); @@ -98,6 +99,7 @@ beforeEach(() => { ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); // First findUnique = aggregator profile select; second = route KVNR select. vi.mocked(prisma.user.findUnique).mockResolvedValue({ username: "sample", diff --git a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts index 0b419743c..0504280dd 100644 --- a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts +++ b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({ createMany: vi.fn(), }, userHealthProfile: { findUnique: vi.fn() }, + illnessEpisode: { findMany: vi.fn() }, }, })); @@ -95,6 +96,7 @@ beforeEach(() => { count: 0, } as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( null as never, ); diff --git a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts index 313813feb..3393acc51 100644 --- a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts +++ b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({ createMany: vi.fn(), }, userHealthProfile: { findUnique: vi.fn() }, + illnessEpisode: { findMany: vi.fn() }, }, })); @@ -81,6 +82,7 @@ beforeEach(() => { count: 0, } as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( null as never, ); diff --git a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts index 16d5465d1..9d88c4648 100644 --- a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts +++ b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts @@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({ createMany: vi.fn(), }, userHealthProfile: { findUnique: vi.fn() }, + illnessEpisode: { findMany: vi.fn() }, }, })); @@ -87,6 +88,7 @@ beforeEach(() => { count: 0, } as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( null as never, ); diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index 2b1821386..09c27ee6a 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -9,6 +9,7 @@ import { ACHIEVEMENT_DEFINITIONS, GAMIFICATION_ROLLOUT_AT, applyDiscoveryFilter, + bridgeFrozenStreakGaps, calculateLongestStreak, evaluateAchievementsWithCompletionDates, moduleForMetric, @@ -90,6 +91,29 @@ function maxDate(a: Date, b: Date): Date { return a > b ? a : b; } +/** + * v1.18.1 P4 — every Berlin-local day key covered by an illness episode + * (onset day through the resolved day, or through `now` while ongoing), + * inclusive on both ends. These are the days a streak is allowed to lapse + * across without breaking (the Rest Mode freeze). Uses the same + * `toBerlinDayKey` day anchoring every other streak series uses, so the + * frozen days line up with the qualifying days exactly. + */ +function collectIllnessDayKeys( + episodes: Array<{ onsetAt: Date; resolvedAt: Date | null }>, + now: Date, +): Set { + const frozen = new Set(); + for (const ep of episodes) { + const startSerial = dayKeyToSerial(toBerlinDayKey(ep.onsetAt)); + const endSerial = dayKeyToSerial(toBerlinDayKey(ep.resolvedAt ?? now)); + for (let serial = startSerial; serial <= endSerial; serial++) { + frozen.add(serialToDayKey(serial)); + } + } + return frozen; +} + function dayKeyToSerial(dayKey: string): number { const [year, month, day] = dayKey.split("-").map(Number); return Date.UTC(year, month - 1, day) / DAY_MS; @@ -612,6 +636,7 @@ async function buildAchievementsResult( moodEntries, sleepMeasurements, healthProfile, + illnessEpisodes, ] = await Promise.all([ prisma.measurement.findMany({ where: { @@ -737,8 +762,33 @@ async function buildAchievementsResult( updatedAt: true, }, }), + // v1.18.1 P4 — illness episodes over the window, for the streak-freeze. + // Gated on the illness module so a non-illness account does no read and + // never freezes anything. Both ongoing and already-resolved episodes are + // read: a past illness should not retroactively break a historical + // streak, just as an active one should not break the current run. + moduleMap.illness !== false + ? prisma.illnessEpisode.findMany({ + where: { + userId, + deletedAt: null, + onsetAt: { lte: now }, + OR: [{ resolvedAt: null }, { resolvedAt: { gte: startDate } }], + }, + select: { onsetAt: true, resolvedAt: true }, + }) + : Promise.resolve([]), ]); + // v1.18.1 P4 — Rest Mode streak-freeze. Build the set of Berlin-local day + // keys covered by an illness episode (onset → resolved, or → now while + // ongoing). A day-streak that lapses only across these days is bridged + // rather than broken (see `bridgeFrozenStreakGaps`): being unwell pauses a + // streak, it does not fail it. + const frozenIllnessDayKeys = collectIllnessDayKeys(illnessEpisodes, now); + const freeze = (dayKeys: string[]): string[] => + bridgeFrozenStreakGaps(dayKeys, frozenIllnessDayKeys); + const schedulesByMedicationId = new Map( medications.map((med) => [med.id, med.schedules]), ); @@ -822,22 +872,25 @@ async function buildAchievementsResult( totalTakenIntakes: takenIntakeDates.length, overIntakeCount: intakeIssueMetrics.overIntakeCount, skippedIntakeCount: intakeIssueMetrics.skippedIntakeCount, - bmiGreenStreak: calculateLongestStreak(healthSeries.bmi.dayKeys), - bpGreenStreak: calculateLongestStreak(healthSeries.bp.dayKeys), - pulseGreenStreak: calculateLongestStreak(healthSeries.pulse.dayKeys), - onTimePerfectDayStreak: calculateLongestStreak(onTimeSeries.dayKeys), - compliance80DayStreak: calculateLongestStreak(complianceSeries.dayKeys), + // v1.18.1 P4 — every day-streak runs through `freeze(...)` so an active + // or past illness episode bridges (does not break) the run across the + // days the user was unwell. + bmiGreenStreak: calculateLongestStreak(freeze(healthSeries.bmi.dayKeys)), + bpGreenStreak: calculateLongestStreak(freeze(healthSeries.bp.dayKeys)), + pulseGreenStreak: calculateLongestStreak(freeze(healthSeries.pulse.dayKeys)), + onTimePerfectDayStreak: calculateLongestStreak(freeze(onTimeSeries.dayKeys)), + compliance80DayStreak: calculateLongestStreak(freeze(complianceSeries.dayKeys)), passkeyCreatedCount: passkeyCreatedDates.length, passkeyLoginCount: passkeyLoginDates.length, passwordLoginCount: passwordLoginDates.length, - loginDayStreak: calculateLongestStreak(loginDaySeries.dayKeys), + loginDayStreak: calculateLongestStreak(freeze(loginDaySeries.dayKeys)), bugReportCount: bugReportDates.length, ...expansionValues, // v1.16.1 — care-routine metrics. - missFreeDayStreak: calculateLongestStreak(missFreeSeries.dayKeys), + missFreeDayStreak: calculateLongestStreak(freeze(missFreeSeries.dayKeys)), measurementConsistencyWeeks: weeklyConsistency.longestRunWeeks, selfContextCompleteCount: selfContextComplete, - sleepLogDayStreak: calculateLongestStreak(sleepSeries.dayKeys), + sleepLogDayStreak: calculateLongestStreak(freeze(sleepSeries.dayKeys)), }; const completionDates: Partial> = {}; diff --git a/src/components/insights/__tests__/coincident-deviation-card.test.tsx b/src/components/insights/__tests__/coincident-deviation-card.test.tsx index 9b64d26e0..ace97f219 100644 --- a/src/components/insights/__tests__/coincident-deviation-card.test.tsx +++ b/src/components/insights/__tests__/coincident-deviation-card.test.tsx @@ -41,13 +41,16 @@ function deviation( } function ok( - value: CoincidentDeviationValue, + value: Omit & + Partial>, band: "high" | "medium" | "low" | "draft" = "high", ): Resp { return { metric: "COINCIDENT_DEVIATION", status: "ok", - value, + // v1.18.1 P4 — default the Rest Mode reframe flag off so the existing + // fixtures stay valid; the reframe test passes `illnessExplained: true`. + value: { illnessExplained: false, ...value }, coverage: { requiredInputs: 5, presentInputs: 5, @@ -202,6 +205,32 @@ describe("", () => { expect(html).not.toContain('role="alert"'); }); + it("reframes a fired flag as illness-explained during Rest Mode (no extra alarm)", () => { + mock( + ok({ + fired: true, + day: "2026-06-03", + illnessExplained: true, + vitals: [ + deviation("RESTING_HEART_RATE", true), + deviation("RESPIRATORY_RATE", true), + ], + contributing: [ + deviation("RESTING_HEART_RATE", true), + deviation("RESPIRATORY_RATE", true), + ], + }), + ); + const html = render(); + expect(html).toContain('data-state="fired"'); + // The reframe line replaces the open-ended "possible factors" copy. + expect(html).toContain("the condition you logged"); + expect(html).not.toContain("Possible factors — never a cause"); + // Annotate, don't escalate: no new destructive colour beyond the amber the + // fired state already carries. + expect(html).not.toContain("text-destructive"); + }); + it("softens a fired flag to the watch tone when history is thin", () => { mock( ok( diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx index dea43da1d..d7ddcbd24 100644 --- a/src/components/insights/coincident-deviation-card.tsx +++ b/src/components/insights/coincident-deviation-card.tsx @@ -278,7 +278,13 @@ export function CoincidentDeviationCard({ className="text-muted-foreground text-xs leading-snug" data-slot="coincident-factors" > - {t("insights.derived.coincident.factors")} + {/* v1.18.1 P4 — Rest Mode reframe. When an illness episode is active + the deviations have a known explanation, so the card states it + plainly instead of leaving "possible factors" open. Neutral copy, + no extra colour. */} + {v.illnessExplained + ? t("insights.derived.coincident.illnessExplained") + : t("insights.derived.coincident.factors")} ); diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 68cc6494f..22cc7601f 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -488,3 +488,62 @@ describe("doctor-report clinical glucose panel", () => { expect(text).not.toContain("Glucose overview (clinical)"); }); }); + +// v1.18.1 P4 — illness / condition episodes section. Retrospective + factual: +// label, type, course, onset, resolved. Present only when the aggregator +// populated `illnessEpisodes` (module on + episode in window). +describe("doctor-report illness section", () => { + it("prints the conditions table with label, type, and dates", async () => { + const data = makeData({ + illnessEpisodes: [ + { + label: "Erkältung", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: "2026-04-01T00:00:00.000Z", + resolvedAt: "2026-04-10T00:00:00.000Z", + }, + ], + }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).toContain("Conditions & illnesses"); + expect(text).toContain("Erkältung"); + }); + + it("marks an ongoing (unresolved) episode rather than a blank cell", async () => { + const data = makeData({ + illnessEpisodes: [ + { + label: "Heuschnupfen", + type: "ALLERGY", + lifecycle: "RECURRING", + onsetAt: "2026-04-01T00:00:00.000Z", + resolvedAt: null, + }, + ], + }); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).toContain("Heuschnupfen"); + expect(text).toContain("Ongoing"); + }); + + it("omits the section entirely when there are no episodes", async () => { + const bytes = renderDoctorReportPdfBytes(makeData(), { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).not.toContain("Conditions & illnesses"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts index 96820f0d4..f7df0b8ed 100644 --- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts @@ -15,6 +15,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, workout: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, + illnessEpisode: { findMany: vi.fn(async () => []) }, }, })); diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index 99aa27a74..ca4d9653e 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, workout: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, + illnessEpisode: { findMany: vi.fn(async () => []) }, }, })); diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 102241c92..4000f8ca9 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -15,6 +15,8 @@ vi.mock("@/lib/db", () => ({ medicationIntakeEvent: { findMany: vi.fn() }, medication: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, + // v1.18.1 P4 — the illness context block reads active + recent episodes. + illnessEpisode: { findMany: vi.fn(async () => []) }, }, })); diff --git a/src/lib/ai/coach/illness-snapshot.ts b/src/lib/ai/coach/illness-snapshot.ts new file mode 100644 index 000000000..12f948752 --- /dev/null +++ b/src/lib/ai/coach/illness-snapshot.ts @@ -0,0 +1,84 @@ +/** + * v1.18.1 P4 — illness / condition context block for the Coach snapshot. + * + * Gives the Coach the FACT that the user has (or recently had) a condition so + * its replies are grounded: it should not push a "measure more often" cadence + * at someone who is unwell, and it should read a low recovery / off-band vital + * as illness-explained rather than alarming. This is CONTEXT, not a metric — + * the block carries no measured number, only labels + lifecycle + dates (never + * the decrypted free-text note, which must not reach the prompt). + * + * Server-authoritative and module-gated: a non-illness / opted-out account + * gets `null` (no block, no read). The active set drives the `restMode` flag + * the Coach reads; a short resolved-history tail lets it answer "how often do + * I get sick" without re-deriving anything. + */ +import { prisma } from "@/lib/db"; +import { isIllnessEnabled } from "@/lib/illness/gate"; + +/** How many recently-resolved episodes to include as history context. */ +const RESOLVED_HISTORY_LIMIT = 6; + +export interface CoachIllnessBlock { + /** True when ≥ 1 episode is active right now (the Rest Mode flag). */ + restMode: boolean; + /** Active (unresolved) episodes — the Coach frames its reply around these. */ + active: Array<{ + label: string; + type: string; + lifecycle: string; + onsetAt: string; + }>; + /** Recently-resolved episodes, newest first — light history context. */ + recentResolved: Array<{ + label: string; + type: string; + onsetAt: string; + resolvedAt: string; + }>; +} + +/** + * Build the illness context block, or `null` when the module is off / the + * account has no episode history. Reads label + lifecycle + dates only — the + * `noteEncrypted` column is never selected. + */ +export async function buildIllnessSnapshotBlock( + userId: string, + now: Date = new Date(), +): Promise { + if (!(await isIllnessEnabled(userId))) return null; + + const [activeRows, resolvedRows] = await Promise.all([ + prisma.illnessEpisode.findMany({ + where: { userId, deletedAt: null, resolvedAt: null, onsetAt: { lte: now } }, + orderBy: { onsetAt: "asc" }, + select: { label: true, type: true, lifecycle: true, onsetAt: true }, + }), + prisma.illnessEpisode.findMany({ + where: { userId, deletedAt: null, resolvedAt: { not: null } }, + orderBy: { resolvedAt: "desc" }, + take: RESOLVED_HISTORY_LIMIT, + select: { label: true, type: true, onsetAt: true, resolvedAt: true }, + }), + ]); + + if (activeRows.length === 0 && resolvedRows.length === 0) return null; + + return { + restMode: activeRows.length > 0, + active: activeRows.map((e) => ({ + label: e.label, + type: e.type, + lifecycle: e.lifecycle, + onsetAt: e.onsetAt.toISOString(), + })), + recentResolved: resolvedRows.map((e) => ({ + label: e.label, + type: e.type, + onsetAt: e.onsetAt.toISOString(), + // `resolvedAt` is non-null by the query filter above. + resolvedAt: (e.resolvedAt as Date).toISOString(), + })), + }; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 3f2402b35..c7639ecfb 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -47,6 +47,7 @@ import { buildDerivedSnapshotBlock } from "./derived-snapshot"; import { buildCoachMemoryBlock } from "./memory-snapshot"; import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot"; import { buildCycleSnapshotBlock } from "./cycle-snapshot"; +import { buildIllnessSnapshotBlock } from "./illness-snapshot"; import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; import { @@ -1052,6 +1053,13 @@ async function buildCoachSnapshotImpl( ? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz) : null; + // v1.18.1 P4 — illness/condition context. Always attempted (the helper is + // module-gated internally and short-circuits to null for a non-illness + // account). It is CONTEXT, not a scope-gated metric: appended like + // anthropometrics/scope below without a `registerBlock` so the budget + // degrader never sheds it — the Coach needs to know about Rest Mode. + const illnessBlockPromise = buildIllnessSnapshotBlock(userId, now); + const [ moodRows, complianceMeds, @@ -1065,6 +1073,7 @@ async function buildCoachSnapshotImpl( trajectoryBlock, memoryBlock, cycleBlock, + illnessBlock, ] = await Promise.all([ moodRowsPromise, complianceMedsPromise, @@ -1078,6 +1087,7 @@ async function buildCoachSnapshotImpl( trajectoryBlockPromise, memoryBlockPromise, cycleBlockPromise, + illnessBlockPromise, ]); const byType = (t: string) => @@ -1850,6 +1860,14 @@ async function buildCoachSnapshotImpl( registerBlock("cycle", "skin_temp"); } + // v1.18.1 P4 — illness/condition context. Small + load-bearing, so it is + // attached WITHOUT a cluster registration (like scope/anthropometrics): the + // budget degrader never sheds it, the Coach always knows whether the user is + // in Rest Mode. Labels + lifecycle + dates only — no decrypted note. + if (illnessBlock) { + snapshot.illness = illnessBlock; + } + if (Object.keys(snapshot).length === 0) { metrics.add("general"); } diff --git a/src/lib/analytics/health-score.ts b/src/lib/analytics/health-score.ts index f2af6973b..8e30ee151 100644 --- a/src/lib/analytics/health-score.ts +++ b/src/lib/analytics/health-score.ts @@ -150,6 +150,27 @@ export interface HealthScoreResult { }; /** Difference vs. last week's score; null when no historical input. */ delta: number | null; + /** + * v1.18.1 P4 — Rest Mode context. When an illness/condition episode is + * active the route attaches this so the surface can frame the score ("you + * were unwell during this window") WITHOUT changing it: `computeHealthScore` + * stays pure and the number is never penalised. Absent / null when the + * account is not in Rest Mode (the pure computation never sets it). + */ + restMode?: RestModeAnnotation | null; +} + +/** + * v1.18.1 P4 — the score-payload view of Rest Mode. A compact, value-free + * annotation: never a decrypted note, never a score adjustment. iOS mirrors it + * verbatim (server-authoritative). The full context lives on + * `@/lib/illness/rest-mode`; this is the slice a score result carries. + */ +export interface RestModeAnnotation { + active: boolean; + /** ISO onset of the earliest active episode. */ + since: string | null; + episodeCount: number; } const BASE_WEIGHTS = { diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index c909bdac2..647f0ae27 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -287,6 +287,29 @@ export interface DoctorReportData { /** Count of readings for this analyte in the window (≥ 1). */ count: number; }> | null; + /** + * v1.18.1 P4 — illness / condition episodes overlapping the report window. + * Populated ONLY when the `illness` module is enabled (born-gated opt-in); + * the user turned the journal on and is sharing it deliberately, so the + * privacy stance matches labs (ON when the module is on), not mood / cycle. + * Statistics + labels only — the free-text note is NEVER read here, so no + * encrypted plaintext can leak into the report. Each entry feeds a FHIR + * `Condition` (+ `Encounter`) and a PDF table row. Null/absent when the + * module is off or no episode overlaps the window; both builders then skip + * the section. Optional so pre-v1.18.1 fixtures still typecheck. + */ + illnessEpisodes?: Array<{ + /** User-facing condition label (e.g. "Erkältung"). */ + label: string; + /** Broad condition class (INFECTION / ALLERGY / INJURY / …). */ + type: string; + /** Lifecycle (ACUTE / CHRONIC_ONGOING / RECURRING / FLARE). */ + lifecycle: string; + /** ISO onset instant. */ + onsetAt: string; + /** ISO resolution instant, or null while ongoing. */ + resolvedAt: string | null; + }> | null; } /** The three persisted score types surfaced in the wellness summary. */ @@ -1324,6 +1347,40 @@ export async function collectDoctorReportData( labResults = collapsed.length > 0 ? collapsed : null; } + // v1.18.1 P4 — illness / condition episodes overlapping the window. Gated + // on the illness module (born-gated opt-in): when off, no read, no section. + // An episode overlaps when it began on or before the window end AND is + // either still ongoing or resolved on or after the window start. Labels + + // lifecycle + dates only — the encrypted note is never selected, so no + // free-text leaks into the clinical export. + let illnessEpisodes: DoctorReportData["illnessEpisodes"] = null; + if (moduleMap.illness !== false) { + const episodeRows = await prisma.illnessEpisode.findMany({ + where: { + userId, + deletedAt: null, + onsetAt: { lte: end }, + OR: [{ resolvedAt: null }, { resolvedAt: { gte: start } }], + }, + orderBy: { onsetAt: "asc" }, + select: { + label: true, + type: true, + lifecycle: true, + onsetAt: true, + resolvedAt: true, + }, + }); + const mapped = episodeRows.map((e) => ({ + label: e.label, + type: e.type, + lifecycle: e.lifecycle, + onsetAt: e.onsetAt.toISOString(), + resolvedAt: e.resolvedAt ? e.resolvedAt.toISOString() : null, + })); + illnessEpisodes = mapped.length > 0 ? mapped : null; + } + return { period: { days, @@ -1377,6 +1434,7 @@ export async function collectDoctorReportData( wellnessScores, cycle, labResults, + illnessEpisodes, }; } diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index b3e623b8b..7e158b476 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -1521,6 +1521,65 @@ export function buildDoctorReportPdfDocument( .finalY + 8; } + // v1.18.1 P4 — illness / condition episodes overlapping the window. Present + // only when the illness module is on AND the window held an episode (the + // aggregator gates `data.illnessEpisodes`). Labels + lifecycle + dates only; + // the encrypted note is never read. A purely retrospective, factual table — + // no colour, no severity tint — matching the clinical-document register. + if (data.illnessEpisodes && data.illnessEpisodes.length > 0) { + y = ensureSpace(y, 6 + 18); + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(t("doctorReport.illnessTitle"), margin, y); + y += 6; + + const illnessRows = data.illnessEpisodes.map((ep) => [ + ep.label, + t(`illness.type.${ep.type}`), + t(`illness.lifecycle.${ep.lifecycle}`), + fmtDate(ep.onsetAt), + ep.resolvedAt ? fmtDate(ep.resolvedAt) : t("doctorReport.illnessOngoing"), + ]); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.illnessColCondition"), + t("doctorReport.illnessColType"), + t("doctorReport.illnessColLifecycle"), + t("doctorReport.illnessColOnset"), + t("doctorReport.illnessColResolved"), + ], + ], + body: illnessRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + alternateRowStyles: { fillColor: [252, 252, 252] }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + // v1.7.0 — optional AI summary. OUT of the clinical PDF by default; // rendered ONLY when the user explicitly opted in. Clearly labelled and // flagged as not clinically validated so a physician never mistakes it diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 3f95a9576..eb6ced1e0 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -1172,3 +1172,105 @@ describe("buildFhirDocumentBundle", () => { expect(titles).not.toContain("Observations"); }); }); + +describe("buildFhirDocumentBundle — illness episodes (v1.18.1 P4)", () => { + function conditionsOf(bundle: ReturnType) { + return bundle.entry + .map((e) => e.resource) + .filter((r) => r.resourceType === "Condition"); + } + function encountersOf(bundle: ReturnType) { + return bundle.entry + .map((e) => e.resource) + .filter((r) => r.resourceType === "Encounter"); + } + + it("emits no Condition / Encounter when there are no episodes", () => { + const bundle = buildFhirDocumentBundle( + makeData({ illnessEpisodes: null }), + { insuranceNumber: null }, + FIXED_NOW, + ); + expect(conditionsOf(bundle)).toHaveLength(0); + expect(encountersOf(bundle)).toHaveLength(0); + const composition = bundle.entry[0].resource; + const titles = + composition.resourceType === "Composition" + ? (composition.section ?? []).map((s) => s.title) + : []; + expect(titles).not.toContain("Conditions"); + }); + + it("maps a resolved episode to a Condition (resolved) + finished Encounter", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + illnessEpisodes: [ + { + label: "Erkältung", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: "2026-04-01T00:00:00.000Z", + resolvedAt: "2026-04-10T00:00:00.000Z", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const conditions = conditionsOf(bundle); + expect(conditions).toHaveLength(1); + const c = conditions[0]; + if (c.resourceType !== "Condition") throw new Error("not a Condition"); + // The user's label rides code.text; the code is the generic disease root + // (never a fabricated diagnosis). + expect(c.code.text).toBe("Erkältung"); + expect(c.code.coding?.[0].code).toBe("64572001"); + expect(c.onsetDateTime).toBe("2026-04-01T00:00:00.000Z"); + expect(c.abatementDateTime).toBe("2026-04-10T00:00:00.000Z"); + expect(c.clinicalStatus?.coding?.[0].code).toBe("resolved"); + // Patient-reported, not clinician-confirmed. + expect(c.verificationStatus?.coding?.[0].code).toBe("unconfirmed"); + + const encounters = encountersOf(bundle); + expect(encounters).toHaveLength(1); + const enc = encounters[0]; + if (enc.resourceType !== "Encounter") throw new Error("not an Encounter"); + expect(enc.status).toBe("finished"); + expect(enc.period?.start).toBe("2026-04-01T00:00:00.000Z"); + expect(enc.period?.end).toBe("2026-04-10T00:00:00.000Z"); + expect(enc.reasonReference?.[0].reference).toBe(`Condition/${c.id}`); + + const composition = bundle.entry[0].resource; + const titles = + composition.resourceType === "Composition" + ? (composition.section ?? []).map((s) => s.title) + : []; + expect(titles).toContain("Conditions"); + }); + + it("maps an ongoing episode to active Condition + in-progress Encounter (no abatement)", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + illnessEpisodes: [ + { + label: "Long-COVID", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: "2026-03-01T00:00:00.000Z", + resolvedAt: null, + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const c = conditionsOf(bundle)[0]; + if (c.resourceType !== "Condition") throw new Error("not a Condition"); + expect(c.clinicalStatus?.coding?.[0].code).toBe("active"); + expect(c.abatementDateTime).toBeUndefined(); + const enc = encountersOf(bundle)[0]; + if (enc.resourceType !== "Encounter") throw new Error("not an Encounter"); + expect(enc.status).toBe("in-progress"); + expect(enc.period?.end).toBeUndefined(); + }); +}); diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 506a370c6..029765a63 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -33,6 +33,7 @@ import { coverageResource, observationsFromReportData, cycleObservationsFromReportData, + conditionsFromReportData, medicationStatementsFromReportData, medicationAdministrationsFromReportData, } from "@/lib/fhir/resources"; @@ -84,6 +85,7 @@ export function buildFhirDocumentBundle( const cycleObservationRefs: FhirReference[] = []; const medicationRefs: FhirReference[] = []; const administrationRefs: FhirReference[] = []; + const conditionRefs: FhirReference[] = []; // --- Patient ----------------------------------------------------------- const patient = patientResource(data, identity); @@ -124,6 +126,20 @@ export function buildFhirDocumentBundle( }); } + // --- Condition + Encounter per illness/condition episode (opt-in) ------ + // v1.18.1 — present only when the illness module is enabled AND the window + // held an episode (the aggregator gates `data.illnessEpisodes`). Each + // episode emits a patient-reported Condition (generic SNOMED root, label on + // `code.text`) plus a bounding Encounter that references it. + const { conditions, encounters } = conditionsFromReportData(data); + for (const condition of conditions) { + entries.push({ fullUrl: `urn:uuid:${condition.id}`, resource: condition }); + conditionRefs.push({ reference: `Condition/${condition.id}` }); + } + for (const encounter of encounters) { + entries.push({ fullUrl: `urn:uuid:${encounter.id}`, resource: encounter }); + } + // --- Composition (leading "cover" resource) ---------------------------- // v1.9.0 — when the aggregator capped the administration set, disclose // it in the narrative so the export is honest: it carries the @@ -196,6 +212,17 @@ export function buildFhirDocumentBundle( }, ] : []), + // v1.18.1 — opt-in illness/condition section. Only present when the + // illness module surfaced episodes. Lists the Condition resources (the + // Encounter rides each Condition's reasonReference). + ...(conditionRefs.length > 0 + ? [ + { + title: "Conditions", + entry: conditionRefs, + }, + ] + : []), ], }; diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts index 61cf6c327..a3010f7e5 100644 --- a/src/lib/fhir/resources.ts +++ b/src/lib/fhir/resources.ts @@ -51,6 +51,8 @@ import type { FhirDosage, FhirPatient, FhirCoverage, + FhirCondition, + FhirEncounter, FhirOrganization, FhirReference, } from "@/lib/fhir/types"; @@ -885,6 +887,119 @@ export function cycleObservationsFromReportData( return observations; } +/** + * SNOMED CT "Disease (disorder)" — the generic, non-diagnostic root concept. + * HealthLog records a patient-kept condition journal; it is NOT a diagnosing + * device and asserts no specific ICD/SNOMED diagnosis. The user's own label + * rides `code.text` and the broad class is a `category`; the coded `code` + * stays this safe root so a downstream system never reads a fabricated + * diagnosis off the export. + */ +const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const; + +/** R4 `Condition.clinicalStatus` concept for the given resolution state. */ +function conditionClinicalStatus(resolved: boolean): FhirCodeableConcept { + return { + coding: [ + { + system: + "http://terminology.hl7.org/CodeSystem/condition-clinical", + code: resolved ? "resolved" : "active", + }, + ], + }; +} + +/** + * v1.18.1 P4 — emit one `Condition` per illness/condition episode plus a + * bounding `Encounter`. Absent unless the illness module is enabled AND the + * window held an episode (the aggregator only populates `illnessEpisodes` + * then). Ids run `condition-1..N` / `encounter-1..N`; the Encounter references + * its Condition so a clinician sees the time-boxed episode. No specific + * diagnosis is coded — see `DISEASE_SNOMED`. + */ +export function conditionsFromReportData(data: DoctorReportData): { + conditions: FhirCondition[]; + encounters: FhirEncounter[]; +} { + const episodes = data.illnessEpisodes; + if (!episodes || episodes.length === 0) { + return { conditions: [], encounters: [] }; + } + const conditions: FhirCondition[] = []; + const encounters: FhirEncounter[] = []; + let seq = 0; + for (const ep of episodes) { + seq += 1; + const conditionId = `condition-${seq}`; + const resolved = ep.resolvedAt !== null; + conditions.push({ + resourceType: "Condition", + id: conditionId, + clinicalStatus: conditionClinicalStatus(resolved), + verificationStatus: { + coding: [ + { + system: + "http://terminology.hl7.org/CodeSystem/condition-ver-status", + // Patient-reported journal entry → "unconfirmed", never a + // clinician-confirmed diagnosis. + code: "unconfirmed", + }, + ], + }, + category: [ + { + coding: [ + { + system: + "http://terminology.hl7.org/CodeSystem/condition-category", + code: "problem-list-item", + }, + ], + // The journal's broad class as a human-readable category label. + text: ep.type, + }, + ], + code: { + coding: [ + { + system: SNOMED_SYSTEM, + code: DISEASE_SNOMED.code, + display: DISEASE_SNOMED.display, + }, + ], + text: ep.label, + }, + subject: patientRef, + onsetDateTime: ep.onsetAt, + ...(ep.resolvedAt ? { abatementDateTime: ep.resolvedAt } : {}), + note: [ + { + text: `Self-recorded ${ep.lifecycle.toLowerCase().replace(/_/g, " ")} condition journal entry; patient-reported, not a clinical diagnosis.`, + }, + ], + }); + encounters.push({ + resourceType: "Encounter", + id: `encounter-${seq}`, + status: resolved ? "finished" : "in-progress", + class: { + system: "http://terminology.hl7.org/CodeSystem/v3-ActCode", + code: "AMB", + display: "ambulatory", + }, + subject: patientRef, + period: { + start: ep.onsetAt, + ...(ep.resolvedAt ? { end: ep.resolvedAt } : {}), + }, + reasonReference: [{ reference: `Condition/${conditionId}` }], + }); + } + return { conditions, encounters }; +} + /** * Emit one `MedicationStatement` per active medication. v1.9.0 — additive * ATC (primary) / RxNorm (secondary) codings when the user stored them; diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 883606b52..303d327d7 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -206,6 +206,45 @@ export interface FhirComposition { section?: FhirCompositionSection[]; } +/** + * v1.18.1 P4 — R4 `Condition`. Carries the illness/condition episode the + * journal records: a `code` (SNOMED 64572001 "Disease" as the safe non- + * diagnostic root + a `.text` label), the clinical + verification status, an + * onset, and (when resolved) an `abatementDateTime`. HealthLog does NOT assert + * a specific ICD/SNOMED diagnosis code — the journal is patient-recorded and + * not a diagnosing device — so the coding stays the generic root and the + * user's label rides `code.text`. + */ +export interface FhirCondition { + resourceType: "Condition"; + id: string; + clinicalStatus?: FhirCodeableConcept; + verificationStatus?: FhirCodeableConcept; + category?: FhirCodeableConcept[]; + code: FhirCodeableConcept; + subject: FhirReference; + onsetDateTime?: string; + abatementDateTime?: string; + recordedDate?: string; + note?: FhirAnnotation[]; +} + +/** + * v1.18.1 P4 — R4 `Encounter`. Emitted alongside a resolved episode to bound + * the period of care (onset → resolution) so a clinician sees the episode as a + * time-boxed event. `status` is `finished` for a resolved episode and + * `in-progress` for an ongoing one; `class` is the ambulatory code. + */ +export interface FhirEncounter { + resourceType: "Encounter"; + id: string; + status: "in-progress" | "finished"; + class: FhirCoding; + subject: FhirReference; + period?: FhirPeriod; + reasonReference?: FhirReference[]; +} + export type FhirResource = | FhirComposition | FhirPatient @@ -213,6 +252,8 @@ export type FhirResource = | FhirObservation | FhirMedicationStatement | FhirMedicationAdministration + | FhirCondition + | FhirEncounter | FhirDiagnosticReport; export interface FhirBundleEntry { diff --git a/src/lib/gamification/__tests__/achievements.test.ts b/src/lib/gamification/__tests__/achievements.test.ts index afe2e7fe1..45a4e7afd 100644 --- a/src/lib/gamification/__tests__/achievements.test.ts +++ b/src/lib/gamification/__tests__/achievements.test.ts @@ -3,6 +3,7 @@ import { ACHIEVEMENT_DEFINITIONS, ACHIEVEMENT_CATEGORY_ORDER, applyDiscoveryFilter, + bridgeFrozenStreakGaps, calculateLongestStreak, evaluateAchievementsWithCompletionDates, getUniqueBerlinDays, @@ -198,3 +199,43 @@ describe("applyDiscoveryFilter", () => { expect(result.summary.unlockedCount).toBe(0); }); }); + +// v1.18.1 P4 — Rest Mode streak-freeze. A streak that lapses ONLY across +// illness days bridges into one continuous run rather than breaking; it is +// never invented, and a non-illness gap still breaks the streak. +describe("bridgeFrozenStreakGaps (Rest Mode streak-freeze)", () => { + it("is a no-op with no frozen days (returns the sorted input)", () => { + const days = ["2026-06-03", "2026-06-01", "2026-06-02"]; + expect(bridgeFrozenStreakGaps(days, new Set())).toEqual([ + "2026-06-01", + "2026-06-02", + "2026-06-03", + ]); + }); + + it("bridges a gap whose every interior day is frozen, preserving the streak", () => { + // Tracked 06-01, then ill 06-02..06-04, tracked again 06-05. Without the + // freeze the streak would break to 1; with it, the run is continuous. + const tracked = ["2026-06-01", "2026-06-05"]; + const frozen = new Set(["2026-06-02", "2026-06-03", "2026-06-04"]); + const bridged = bridgeFrozenStreakGaps(tracked, frozen); + expect(calculateLongestStreak(bridged)).toBe(5); + }); + + it("does NOT bridge a gap that contains a non-frozen missed day (genuine break)", () => { + const tracked = ["2026-06-01", "2026-06-05"]; + // 06-03 is NOT a frozen illness day → the gap is a real lapse. + const frozen = new Set(["2026-06-02", "2026-06-04"]); + const bridged = bridgeFrozenStreakGaps(tracked, frozen); + expect(calculateLongestStreak(bridged)).toBe(1); + }); + + it("never invents a streak from a lone frozen day with no surrounding run", () => { + const tracked = ["2026-06-10"]; + const frozen = new Set(["2026-06-02", "2026-06-03"]); + const bridged = bridgeFrozenStreakGaps(tracked, frozen); + // A single tracked day, untouched — the frozen days bridge nothing. + expect(bridged).toEqual(["2026-06-10"]); + expect(calculateLongestStreak(bridged)).toBe(1); + }); +}); diff --git a/src/lib/gamification/achievements.ts b/src/lib/gamification/achievements.ts index a4a7ae47e..227f96761 100644 --- a/src/lib/gamification/achievements.ts +++ b/src/lib/gamification/achievements.ts @@ -791,6 +791,68 @@ export function calculateLongestStreak(dayKeys: string[]): number { return maxStreak; } +/** + * v1.18.1 P4 — freeze (don't break) a day-streak across illness days. + * + * Rest Mode principle: a streak that lapses ONLY because the user was unwell + * should not be broken — being ill is not a failure to track. This pure + * helper bridges any calendar gap between two qualifying days when EVERY + * missing day in the gap is a frozen (active-illness) day. The bridged days + * are folded into the series so `calculateLongestStreak` sees one continuous + * run across the episode. + * + * It never INVENTS a streak: a frozen day with no surrounding qualifying days + * on either side adds nothing (there is no run to preserve), and a gap that + * contains even one non-frozen missed day stays a genuine break. The user is + * neither penalised for being ill nor rewarded for it — the streak simply + * pauses and resumes. + * + * Both inputs are `YYYY-MM-DD` keys. Returns a fresh ascending, de-duplicated + * day-key array; the original is not mutated. A no-op (returns the sorted + * input) when `frozenDayKeys` is empty. + */ +export function bridgeFrozenStreakGaps( + dayKeys: string[], + frozenDayKeys: ReadonlySet, +): string[] { + const unique = Array.from(new Set(dayKeys)).sort(); + if (frozenDayKeys.size === 0 || unique.length < 2) return unique; + + const frozenSerials = new Set(); + for (const key of frozenDayKeys) frozenSerials.add(dayKeyToNumber(key)); + + const bridged = new Set(unique); + for (let i = 1; i < unique.length; i++) { + const prev = dayKeyToNumber(unique[i - 1]); + const current = dayKeyToNumber(unique[i]); + if (current - prev <= 1) continue; + // A gap. Bridge it only when every interior day is a frozen illness day. + let allFrozen = true; + for (let serial = prev + 1; serial < current; serial++) { + if (!frozenSerials.has(serial)) { + allFrozen = false; + break; + } + } + if (allFrozen) { + for (let serial = prev + 1; serial < current; serial++) { + bridged.add(serialToDayKey(serial)); + } + } + } + + return Array.from(bridged).sort(); +} + +/** Inverse of `dayKeyToNumber` — a UTC day serial back to a `YYYY-MM-DD` key. */ +function serialToDayKey(serial: number): string { + const d = new Date(serial * 86_400_000); + const year = d.getUTCFullYear(); + const month = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + function calculateProgress(current: number, target: number): number { if (target <= 0) return 100; return Math.max(0, Math.min(100, Math.round((current / target) * 100))); diff --git a/src/lib/illness/__tests__/rest-mode.test.ts b/src/lib/illness/__tests__/rest-mode.test.ts new file mode 100644 index 000000000..2d46ceb0e --- /dev/null +++ b/src/lib/illness/__tests__/rest-mode.test.ts @@ -0,0 +1,117 @@ +/** + * v1.18.1 P4 — Rest Mode resolver. + * + * Rest Mode ANNOTATES, never penalises. These tests pin the resolver + * contract: module-gated (a disabled / opted-out account is never in Rest + * Mode and does no episode read), the fold to the renderable context, and the + * fail-soft behaviour (a read error reads as "not in Rest Mode" so a Rest Mode + * annotation can never break the payload it rides on). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const gate = vi.hoisted(() => ({ isIllnessEnabled: vi.fn() })); +const db = vi.hoisted(() => ({ + prisma: { illnessEpisode: { findMany: vi.fn() } }, +})); + +vi.mock("@/lib/illness/gate", () => gate); +vi.mock("@/lib/db", () => db); + +import { + getActiveIllnessEpisodes, + resolveRestMode, + toRestModeContext, + REST_MODE_INACTIVE, +} from "@/lib/illness/rest-mode"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getActiveIllnessEpisodes", () => { + it("returns [] without a read when the illness module is disabled", async () => { + gate.isIllnessEnabled.mockResolvedValue(false); + await expect(getActiveIllnessEpisodes("u1")).resolves.toEqual([]); + expect(db.prisma.illnessEpisode.findMany).not.toHaveBeenCalled(); + }); + + it("queries only active (unresolved, not-deleted, onset ≤ asOf) episodes when enabled", async () => { + gate.isIllnessEnabled.mockResolvedValue(true); + db.prisma.illnessEpisode.findMany.mockResolvedValue([]); + const asOf = new Date("2026-06-16T00:00:00.000Z"); + await getActiveIllnessEpisodes("u1", asOf); + const arg = db.prisma.illnessEpisode.findMany.mock.calls[0][0]; + expect(arg.where).toMatchObject({ + userId: "u1", + deletedAt: null, + resolvedAt: null, + onsetAt: { lte: asOf }, + }); + }); +}); + +describe("toRestModeContext", () => { + it("folds an empty set to the inactive context", () => { + expect(toRestModeContext([])).toEqual(REST_MODE_INACTIVE); + }); + + it("anchors `since` on the earliest onset and counts episodes (no note leaks)", () => { + const ctx = toRestModeContext([ + { + id: "e1", + label: "Erkältung", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: new Date("2026-06-10T00:00:00.000Z"), + }, + { + id: "e2", + label: "Heuschnupfen", + type: "ALLERGY", + lifecycle: "RECURRING", + onsetAt: new Date("2026-06-14T00:00:00.000Z"), + }, + ]); + expect(ctx.active).toBe(true); + expect(ctx.episodeCount).toBe(2); + expect(ctx.since).toBe("2026-06-10T00:00:00.000Z"); + // The context carries labels + lifecycle only — no free-text note field. + expect(ctx.episodes[0]).not.toHaveProperty("note"); + expect(Object.keys(ctx.episodes[0]).sort()).toEqual([ + "id", + "label", + "lifecycle", + "onsetAt", + "type", + ]); + }); +}); + +describe("resolveRestMode", () => { + it("resolves active context from the active episodes", async () => { + gate.isIllnessEnabled.mockResolvedValue(true); + db.prisma.illnessEpisode.findMany.mockResolvedValue([ + { + id: "e1", + label: "Erkältung", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: new Date("2026-06-10T00:00:00.000Z"), + }, + ]); + const ctx = await resolveRestMode("u1"); + expect(ctx.active).toBe(true); + expect(ctx.episodeCount).toBe(1); + }); + + it("is fail-soft: a read error reads as not-in-Rest-Mode", async () => { + gate.isIllnessEnabled.mockResolvedValue(true); + db.prisma.illnessEpisode.findMany.mockRejectedValue(new Error("boom")); + await expect(resolveRestMode("u1")).resolves.toEqual(REST_MODE_INACTIVE); + }); + + it("is inactive (no read) for a disabled module", async () => { + gate.isIllnessEnabled.mockResolvedValue(false); + await expect(resolveRestMode("u1")).resolves.toEqual(REST_MODE_INACTIVE); + }); +}); diff --git a/src/lib/illness/rest-mode.ts b/src/lib/illness/rest-mode.ts new file mode 100644 index 000000000..66e7a38cb --- /dev/null +++ b/src/lib/illness/rest-mode.ts @@ -0,0 +1,137 @@ +/** + * Rest Mode — the server-authoritative "an episode is active" context (v1.18.1, P4). + * + * CORE PRINCIPLE: an active illness/condition episode ANNOTATES, it never + * PENALISES. Rest Mode never changes a measured number — it only frames the + * narrative around it: the health score keeps its value but carries a "you + * were unwell" note, recovery keeps its number, achievement streaks freeze + * (rather than break) across the days you were ill, cadence-nudges pause, and + * a coincident vital deviation reads as illness-explained instead of an + * unexplained anomaly. + * + * This module is the ONE place that resolves "is the account in Rest Mode + * right now, and which episodes back it" — so every surface annotates from + * the same fact. It is server-authoritative: the resolved `RestModeContext` + * is the DTO the iOS client mirrors (it renders Rest Mode, never recomputes + * the suppression). The illness module gate is honoured: a disabled / opted- + * out account is never in Rest Mode (the gate short-circuits before any read). + * + * "Active" = an `IllnessEpisode` with `resolvedAt = null` (still ongoing), + * not soft-deleted, whose `onsetAt` is on or before the reference instant. + * CHRONIC_ONGOING episodes are intentionally INCLUDED here — an ongoing + * chronic condition is precisely the case where a measured dip should be + * framed, not penalised. (They are excluded only from the recovery-GAP math, + * which is P3 and lives elsewhere.) + */ +import type { PrismaClient, IllnessEpisode } from "@/generated/prisma/client"; +import { prisma as defaultPrisma } from "@/lib/db"; +import { isIllnessEnabled } from "@/lib/illness/gate"; + +/** Minimal shape the Rest Mode resolver needs from an episode row. */ +export type ActiveIllnessEpisode = Pick< + IllnessEpisode, + "id" | "label" | "type" | "lifecycle" | "onsetAt" +>; + +/** + * The resolved Rest Mode annotation a surface attaches to its result. Carries + * NO measured value — it is pure narrative context. `active` is the single + * flag a renderer branches on; `since` is the earliest active onset (the + * "unwell since" anchor); `episodes` is the small set backing it (label + + * type only — no decrypted note, no free-text leaks into a score payload). + * + * This is the server-authoritative shape iOS mirrors verbatim. + */ +export interface RestModeContext { + /** True when ≥ 1 illness episode is active as of the reference instant. */ + active: boolean; + /** ISO onset of the earliest active episode, or null when none. */ + since: string | null; + /** Count of active episodes (usually 1; ≥ 2 when conditions overlap). */ + episodeCount: number; + /** The active episodes, label + type + onset only — never a decrypted note. */ + episodes: Array<{ + id: string; + label: string; + type: string; + lifecycle: string; + onsetAt: string; + }>; +} + +/** The neutral, honest empty state — not in Rest Mode. */ +export const REST_MODE_INACTIVE: RestModeContext = { + active: false, + since: null, + episodeCount: 0, + episodes: [], +}; + +/** + * Read the active illness episodes for an account as of `asOf`. Gated through + * the illness module: a disabled / not-opted-in account returns `[]` without + * touching the episode table. The reference instant is injectable so the + * resolver is deterministic for tests and so a nightly job can resolve "as of + * the scored day". + */ +export async function getActiveIllnessEpisodes( + userId: string, + asOf: Date = new Date(), + client: PrismaClient = defaultPrisma, +): Promise { + if (!(await isIllnessEnabled(userId))) return []; + return client.illnessEpisode.findMany({ + where: { + userId, + deletedAt: null, + resolvedAt: null, + onsetAt: { lte: asOf }, + }, + select: { + id: true, + label: true, + type: true, + lifecycle: true, + onsetAt: true, + }, + orderBy: { onsetAt: "asc" }, + }); +} + +/** Fold a set of active episodes into the renderable Rest Mode context. Pure. */ +export function toRestModeContext( + episodes: ActiveIllnessEpisode[], +): RestModeContext { + if (episodes.length === 0) return REST_MODE_INACTIVE; + return { + active: true, + since: episodes[0].onsetAt.toISOString(), + episodeCount: episodes.length, + episodes: episodes.map((e) => ({ + id: e.id, + label: e.label, + type: e.type, + lifecycle: e.lifecycle, + onsetAt: e.onsetAt.toISOString(), + })), + }; +} + +/** + * Resolve the Rest Mode context for an account as of `asOf` — the one call a + * surface makes to learn whether to annotate. Module-gated and fail-soft: any + * resolution error reads as "not in Rest Mode" so a Rest Mode read can never + * break the score / recovery / achievements payload it annotates. + */ +export async function resolveRestMode( + userId: string, + asOf: Date = new Date(), + client: PrismaClient = defaultPrisma, +): Promise { + try { + const episodes = await getActiveIllnessEpisodes(userId, asOf, client); + return toRestModeContext(episodes); + } catch { + return REST_MODE_INACTIVE; + } +} diff --git a/src/lib/insights/__tests__/recovery-score.test.ts b/src/lib/insights/__tests__/recovery-score.test.ts index ac2b39a51..d54b7781a 100644 --- a/src/lib/insights/__tests__/recovery-score.test.ts +++ b/src/lib/insights/__tests__/recovery-score.test.ts @@ -19,11 +19,25 @@ vi.mock("@/lib/insights/derived/readiness", () => ({ computeReadiness: (...args: unknown[]) => computeReadinessMock(...args), })); +// v1.18.1 P4 — Rest Mode resolver. Default inactive; the annotation test +// flips it active to assert the score is NOT penalised, only annotated. +const restModeMock = vi.hoisted(() => ({ + resolveRestMode: vi.fn(async () => ({ + active: false as boolean, + since: null as string | null, + episodeCount: 0, + episodes: [] as unknown[], + })), +})); +vi.mock("@/lib/illness/rest-mode", () => restModeMock); + import { recoveryDayKey, recoveryExternalId, recoveryMeasuredAt, + computeRecoveryScore, persistRecoveryScore, + loadRecoveryProfile, RECOVERY_SCORE_EXTERNAL_ID_PREFIX, } from "../recovery-score"; @@ -65,6 +79,12 @@ function makePrisma() { beforeEach(() => { vi.clearAllMocks(); + restModeMock.resolveRestMode.mockResolvedValue({ + active: false, + since: null, + episodeCount: 0, + episodes: [], + }); }); describe("recovery-score helpers", () => { @@ -83,6 +103,41 @@ describe("recovery-score helpers", () => { }); }); +describe("computeRecoveryScore — Rest Mode annotation (v1.18.1 P4)", () => { + const profile = {} as Parameters[2]; + + it("annotates an active episode WITHOUT penalising the score", async () => { + // A genuinely low recovery blend (40) — the kind a low day produces. + computeReadinessMock.mockResolvedValue(okReadiness(40)); + restModeMock.resolveRestMode.mockResolvedValue({ + active: true, + since: "2026-05-30T00:00:00.000Z", + episodeCount: 1, + episodes: [], + }); + const { prisma } = makePrisma(); + const result = await computeRecoveryScore(prisma, "u1", profile, NOW); + // The raw blend is reported verbatim — Rest Mode does not adjust it. + expect(result.score).toBe(40); + // …but the context is attached so the surface can frame it. + expect(result.restMode.active).toBe(true); + expect(result.restMode.since).toBe("2026-05-30T00:00:00.000Z"); + }); + + it("carries the inactive context when no episode is active", async () => { + computeReadinessMock.mockResolvedValue(okReadiness(80)); + const { prisma } = makePrisma(); + const result = await computeRecoveryScore(prisma, "u1", profile, NOW); + expect(result.score).toBe(80); + expect(result.restMode.active).toBe(false); + }); + + // Keep `loadRecoveryProfile` referenced so the named import stays exercised. + it("exposes loadRecoveryProfile", () => { + expect(typeof loadRecoveryProfile).toBe("function"); + }); +}); + describe("persistRecoveryScore", () => { it("stores a COMPUTED RECOVERY_SCORE row when the blend is ok", async () => { const { prisma, upsert } = makePrisma(); diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts index 5ef846c60..ca6e9c593 100644 --- a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts +++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts @@ -9,6 +9,16 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({ vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ readBestGranularityRollups: vi.fn().mockResolvedValue(null), })); +// v1.18.1 P4 — Rest Mode. Default inactive; the reframe test flips it active. +const restModeMock = vi.hoisted(() => ({ + resolveRestMode: vi.fn(async () => ({ + active: false as boolean, + since: null as string | null, + episodeCount: 0, + episodes: [] as unknown[], + })), +})); +vi.mock("@/lib/illness/rest-mode", () => restModeMock); import { prisma } from "@/lib/db"; import { @@ -24,8 +34,40 @@ const findMany = prisma.measurement.findMany as ReturnType; beforeEach(() => { vi.clearAllMocks(); findMany.mockResolvedValue([]); + restModeMock.resolveRestMode.mockResolvedValue({ + active: false, + since: null, + episodeCount: 0, + episodes: [], + }); }); +/** RHR + HRV each flat, then a final day that pushes both outside the band. */ +function firingRows(args: { where: { type: string } }) { + const baseline = (base: number, outlier: number) => [ + ...Array.from({ length: 9 }, (_, i) => ({ + value: base, + measuredAt: new Date(`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`), + })), + { value: outlier, measuredAt: new Date("2026-06-02T07:00:00Z") }, + ]; + if (args.where.type === "RESTING_HEART_RATE") return baseline(58, 95); + if (args.where.type === "HEART_RATE_VARIABILITY") return baseline(60, 10); + return []; +} + +/** RHR + HRV each flat (banded but inside) — the no-fire-but-banded case. */ +function quietBandedRows(args: { where: { type: string } }) { + const flat = (base: number) => + Array.from({ length: 10 }, (_, i) => ({ + value: base, + measuredAt: new Date(`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`), + })); + if (args.where.type === "RESTING_HEART_RATE") return flat(58); + if (args.where.type === "HEART_RATE_VARIABILITY") return flat(60); + return []; +} + describe("classifyDeviation", () => { it("flags above / below / in-band", () => { expect(classifyDeviation("PULSE", 90, 60, 80, 70).direction).toBe("above"); @@ -105,4 +147,33 @@ describe("computeCoincidentDeviation", () => { expect(result.value.fired).toBe(false); } }); + + it("reframes a fired flag as illness-explained during Rest Mode (vitals unchanged)", async () => { + restModeMock.resolveRestMode.mockResolvedValue({ + active: true, + since: "2026-05-30T00:00:00.000Z", + episodeCount: 1, + episodes: [], + }); + findMany.mockImplementation(firingRows); + const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.fired).toBe(true); + // Reframed — but the measured deviations are untouched. + expect(result.value.illnessExplained).toBe(true); + expect(result.value.contributing.length).toBeGreaterThanOrEqual( + COINCIDENT_FIRE_THRESHOLD, + ); + } + }); + + it("does not resolve Rest Mode (no reframe) when the flag did not fire", async () => { + // Banded but inside-band: the reframe must not even query Rest Mode. + findMany.mockImplementation(quietBandedRows); + const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW }); + expect(result.status === "ok" && result.value.fired).toBe(false); + expect(result.status === "ok" && result.value.illnessExplained).toBe(false); + expect(restModeMock.resolveRestMode).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index e347f975b..cc26a653c 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -39,6 +39,7 @@ import { import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; import { VITALS_BASELINE_TYPES } from "./registry"; import type { Derived, DerivedProvenanceSource } from "./types"; +import { resolveRestMode } from "@/lib/illness/rest-mode"; const MS_PER_DAY = 24 * 60 * 60 * 1000; const DEFAULT_WINDOW_DAYS = 30; @@ -78,6 +79,16 @@ export interface CoincidentDeviationValue { contributing: VitalDeviation[]; /** The day the flag was evaluated (YYYY-MM-DD). */ day: string; + /** + * v1.18.1 P4 — Rest Mode reframe. True when the flag fired AND an + * illness/condition episode is active: the deviations have a known + * explanation (the user is unwell), so the surface frames them as + * illness-explained — "your vitals are off because you're ill" — instead of + * presenting them as an unexplained anomaly. The vital numbers themselves + * are unchanged; only the framing differs. Resolved server-side; iOS + * mirrors it. + */ + illnessExplained: boolean; } // ── pure classifier (exported for tests) ─────────────────────────────── @@ -216,6 +227,13 @@ export async function computeCoincidentDeviation( const contributing = vitals.filter((v) => v.outside); const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD; + // v1.18.1 P4 — when the flag fired, reframe it as illness-explained if an + // episode is active. Only resolved when the flag fired (no read on the + // common quiet day). Annotation only — the vital deviations are unchanged. + const illnessExplained = fired + ? (await resolveRestMode(userId, now)).active + : false; + const { coverage: cov, confidence } = deriveCoverage({ // The coverage axis is "how many vitals could have coincided". requiredInputs: vitals.length, @@ -236,6 +254,7 @@ export async function computeCoincidentDeviation( vitals, contributing, day: latestDay, + illnessExplained, }, coverage: cov, confidence, diff --git a/src/lib/insights/recovery-score.ts b/src/lib/insights/recovery-score.ts index 40db18a88..ec3cff2cd 100644 --- a/src/lib/insights/recovery-score.ts +++ b/src/lib/insights/recovery-score.ts @@ -51,6 +51,7 @@ import { upsertScoreRow, } from "@/lib/insights/score-row"; import type { Derived } from "@/lib/insights/derived/types"; +import { resolveRestMode, type RestModeContext } from "@/lib/illness/rest-mode"; /** The per-day idempotency-key prefix for a stored Recovery score row. */ export const RECOVERY_SCORE_EXTERNAL_ID_PREFIX = "recovery:"; @@ -80,6 +81,14 @@ export interface RecoveryComputeResult { readiness: Derived; /** The 0..100 score to persist, or null when the blend was insufficient. */ score: number | null; + /** + * v1.18.1 P4 — Rest Mode context as of the scored day. ANNOTATES, never + * penalises: the `score` above is the raw recovery blend, unchanged when an + * illness episode is active. This context lets the recovery surface frame a + * low recovery score as "expected while you're unwell" rather than reading + * it as an unexplained drop. Resolved server-side; iOS mirrors it. + */ + restMode: RestModeContext; } /** @@ -100,7 +109,11 @@ export async function computeRecoveryScore( // the result is deterministic for tests. const readiness = await computeReadiness(userId, profile, { now }); const score = readiness.status === "ok" ? readiness.value.score : null; - return { readiness, score }; + // Rest Mode context as of the scored day — annotation only, never folded + // into `score`. An active episode frames a low recovery number; it does not + // adjust it. + const restMode = await resolveRestMode(userId, now, prisma); + return { readiness, score, restMode }; } /** diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index 64378ebef..a417e359b 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -52,6 +52,17 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(async () => null), })); +// v1.18.1 P4 — Rest Mode pause. Default: not in Rest Mode (every existing +// gate/trigger test stays unaffected). The pause test overrides it active. +const restModeMock = vi.hoisted(() => ({ + resolveRestMode: vi.fn(async () => ({ + active: false as boolean, + since: null as string | null, + episodeCount: 0, + episodes: [] as unknown[], + })), +})); +vi.mock("@/lib/illness/rest-mode", () => restModeMock); function dose(taken: boolean, skipped = false, autoMissed?: boolean) { return { @@ -560,6 +571,12 @@ describe("runCoachNudgeTick — gates and prefs", () => { coach: true, } as Awaited>); vi.mocked(userRowHasProviderCredential).mockReturnValue(true); + restModeMock.resolveRestMode.mockResolvedValue({ + active: false, + since: null, + episodeCount: 0, + episodes: [], + }); }); it("counts a master opt-out before touching provider or cap gates", async () => { @@ -646,6 +663,32 @@ describe("runCoachNudgeTick — gates and prefs", () => { expect(summary.skippedNoTrigger).toBe(1); }); + it("pauses the cadence-nudge during an active illness episode (Rest Mode)", async () => { + // A user who would otherwise fire a compliance nudge (17 % adherence). + const dispatch = vi.fn(); + restModeMock.resolveRestMode.mockResolvedValue({ + active: true, + since: "2026-06-10T00:00:00.000Z", + episodeCount: 1, + episodes: [], + }); + const prisma = prismaMock({ + users: [userRow(null)], + intakeRows: failingIntakes, + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch }, + ); + // The nudge is paused, not penalised: no dispatch, no trigger search even + // ran (the Rest Mode gate sits before the trigger query). + expect(dispatch).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + expect(summary.skippedDuringIllness).toBe(1); + expect(summary.dispatched).toBe(0); + }); + it("personalises the dispatched body with the decrypted Coach focus", async () => { const dispatch = vi.fn(async (input: unknown) => { void input; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index baea8459a..8c5ecb03f 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -78,6 +78,7 @@ import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; import { getEvent } from "@/lib/logging/context"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; +import { resolveRestMode } from "@/lib/illness/rest-mode"; import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; @@ -171,6 +172,14 @@ export interface CoachNudgeSummary { skippedRecentNudge: number; skippedNoTrigger: number; skippedNoChannel: number; + /** + * v1.18.1 P4 — Rest Mode pause: a cadence-nudge ("weigh more often", + * "measure BP morning + evening") is the wrong message to a user who is + * actively unwell. While an illness episode is active the nudge is paused, + * not penalised: nothing is recorded against the user, the cadence simply + * resumes once the episode resolves. + */ + skippedDuringIllness: number; failed: number; } @@ -698,6 +707,7 @@ export async function runCoachNudgeTick( skippedRecentNudge: 0, skippedNoTrigger: 0, skippedNoChannel: 0, + skippedDuringIllness: 0, failed: 0, }; @@ -782,6 +792,18 @@ export async function runCoachNudgeTick( continue; } + // Gate 6 — Rest Mode pause (v1.18.1 P4). A user with an active + // illness/condition episode should not be told to measure more often; + // the cadence-nudge pauses for the duration of the episode. Module-gated + // (a non-illness account is never in Rest Mode) and only reached past + // every cheaper gate, so the read fires for at most a nudge-eligible + // user per tick. + const restMode = await resolveRestMode(user.id, now, prisma); + if (restMode.active) { + summary.skippedDuringIllness += 1; + continue; + } + const trigger = await findTriggerForUser( prisma, user, From e23f57448abfe5ae3857c34015ef80b08f7030f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 23:05:03 +0200 Subject: [PATCH 29/48] fix(crypto): wire every encrypted column into key rotation behind a registry guard The rotation script omitted the four v1.18.1 clinical-spine note columns (LabResult.noteEncrypted, Biomarker.contextEncrypted, IllnessEpisode.noteEncrypted, IllnessDayLog.noteEncrypted) plus a longer pre-existing tail (Nightscout/Polar/Oura credentials, the KVNR field, the health-profile and cycle-day-log payloads, InsightNarrative content). Rotation reported zero legacy rows while these still held old-key ciphertext, so dropping the legacy key left them permanently undecryptable (decrypt is fail-closed). Introduce src/lib/crypto/encrypted-columns.ts as the canonical registry of every reversibly-encrypted column, drive the rotation script from it, and add a guard test that cross-checks the registry against every encrypted column in schema.prisma and asserts the script references each one. A new *Encrypted column not wired into rotation now fails CI by construction. --- scripts/rotate-encryption-key.ts | 614 ++++++++---------- .../__tests__/encrypted-columns.test.ts | 162 +++++ src/lib/crypto/encrypted-columns.ts | 136 ++++ 3 files changed, 562 insertions(+), 350 deletions(-) create mode 100644 src/lib/crypto/__tests__/encrypted-columns.test.ts create mode 100644 src/lib/crypto/encrypted-columns.ts diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index a2eefaa4f..4bf6d1c82 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -5,6 +5,15 @@ * active key (`ENCRYPTION_ACTIVE_KEY_ID`). Idempotent: rows whose ciphertext * already carries the active key id prefix are skipped. * + * The set of columns this script rotates is the canonical registry in + * `src/lib/crypto/encrypted-columns.ts`. The guard test + * `src/lib/crypto/__tests__/encrypted-columns.test.ts` fails CI if any + * encrypted column in `prisma/schema.prisma` is missing from the registry, + * or if any registry column is not referenced here — so a new `*Encrypted` + * column can never silently skip rotation (which would make those rows + * permanently undecryptable once the legacy key is dropped, since decrypt is + * fail-closed). + * * Usage: * ENCRYPTION_KEYS='{"v1":"","v2":""}' \ * ENCRYPTION_ACTIVE_KEY_ID=v2 \ @@ -50,13 +59,25 @@ function shouldRotate(value: string | null): boolean { return id !== getActiveKeyId(); } -async function rotateField( +/** + * Rotate one `String` ciphertext column on a Prisma model. `delegate` is the + * `prisma.` accessor; `field` is the column. Reads only `id` + the + * column, re-encrypts every row not already on the active key. + */ +async function rotateStringColumn( table: string, field: string, - rows: T[], - fieldGetter: (row: T) => string | null, - updater: (id: string, ciphertext: string) => Promise, + delegate: { + findMany: (args: { + select: Record; + }) => Promise>>; + update: (args: { + where: { id: string }; + data: Record; + }) => Promise; + }, ): Promise { + const rows = await delegate.findMany({ select: { id: true, [field]: true } }); const result: RotationResult = { table, field, @@ -65,17 +86,63 @@ async function rotateField( errors: 0, }; for (const row of rows) { - const v = fieldGetter(row); + const v = row[field] as string | null; if (!shouldRotate(v)) continue; + const id = row.id as string; try { const re = encrypt(decrypt(v as string)); - await updater(row.id, re); + await delegate.update({ where: { id }, data: { [field]: re } }); result.rotated++; } catch (err) { result.errors++; - console.error( - `[${table}.${field}] row ${row.id}: ${(err as Error).message}`, - ); + console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`); + } + } + return result; +} + +/** + * Rotate one `Bytes` ciphertext column. The encrypt/decrypt helpers operate + * on strings, so we go through a UTF-8 Buffer round-trip identical to the + * persistence layer (`src/lib/ai/coach/persistence.ts:60-71`). + */ +async function rotateBytesColumn( + table: string, + field: string, + delegate: { + findMany: (args: { + select: Record; + }) => Promise>>; + update: (args: { + where: { id: string }; + data: Record; + }) => Promise; + }, +): Promise { + const rows = await delegate.findMany({ select: { id: true, [field]: true } }); + const result: RotationResult = { + table, + field, + scanned: rows.length, + rotated: 0, + errors: 0, + }; + for (const row of rows) { + const buf = row[field] as Uint8Array | null; + if (!buf || buf.byteLength === 0) continue; + const asString = Buffer.from(buf).toString("utf8"); + if (!shouldRotate(asString)) continue; + const id = row.id as string; + try { + const rotated = encrypt(decrypt(asString)); + const encoded = Buffer.from(rotated, "utf8"); + const next = new Uint8Array(new ArrayBuffer(encoded.byteLength)); + next.set(encoded); + await delegate.update({ where: { id }, data: { [field]: next } }); + result.rotated++; + } catch (err) { + result.errors++; + console.error(`[${table}.${field}] row ${id}: ${(err as Error).message}`); } } return result; @@ -85,36 +152,24 @@ async function main() { console.log(`Rotating encrypted rows to active key id: ${targetKeyId}`); const results: RotationResult[] = []; - // ───── User table ───── - const users = await prisma.user.findMany({ - select: { - id: true, - codexAccessTokenEncrypted: true, - codexRefreshTokenEncrypted: true, - aiAnthropicKeyEncrypted: true, - aiLocalKeyEncrypted: true, - aiOpenaiKeyEncrypted: true, - moodLogWebhookSecret: true, - telegramBotToken: true, - moodLogUrlEncrypted: true, - moodLogApiKeyEncrypted: true, - withingsClientIdEncrypted: true, - withingsClientSecretEncrypted: true, - whoopClientIdEncrypted: true, - whoopClientSecretEncrypted: true, - fitbitClientIdEncrypted: true, - fitbitClientSecretEncrypted: true, - }, - }); - - const userFields: Array = [ + // ───── User — integration credentials + AI keys + KVNR (String) ───── + // Columns: "codexAccessTokenEncrypted" "codexRefreshTokenEncrypted" + // "telegramBotToken" "moodLogWebhookSecret" "moodLogUrlEncrypted" + // "moodLogApiKeyEncrypted" "withingsClientIdEncrypted" + // "withingsClientSecretEncrypted" "whoopClientIdEncrypted" + // "whoopClientSecretEncrypted" "fitbitClientIdEncrypted" + // "fitbitClientSecretEncrypted" "nightscoutUrlEncrypted" + // "nightscoutTokenEncrypted" "polarAccessTokenEncrypted" + // "polarUserIdEncrypted" "polarClientIdEncrypted" + // "polarClientSecretEncrypted" "ouraAccessTokenEncrypted" + // "ouraRefreshTokenEncrypted" "ouraClientIdEncrypted" + // "ouraClientSecretEncrypted" "aiAnthropicKeyEncrypted" + // "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted" + const userFields = [ "codexAccessTokenEncrypted", "codexRefreshTokenEncrypted", - "aiAnthropicKeyEncrypted", - "aiLocalKeyEncrypted", - "aiOpenaiKeyEncrypted", - "moodLogWebhookSecret", "telegramBotToken", + "moodLogWebhookSecret", "moodLogUrlEncrypted", "moodLogApiKeyEncrypted", "withingsClientIdEncrypted", @@ -123,338 +178,197 @@ async function main() { "whoopClientSecretEncrypted", "fitbitClientIdEncrypted", "fitbitClientSecretEncrypted", + "nightscoutUrlEncrypted", + "nightscoutTokenEncrypted", + "polarAccessTokenEncrypted", + "polarUserIdEncrypted", + "polarClientIdEncrypted", + "polarClientSecretEncrypted", + "ouraAccessTokenEncrypted", + "ouraRefreshTokenEncrypted", + "ouraClientIdEncrypted", + "ouraClientSecretEncrypted", + "aiAnthropicKeyEncrypted", + "aiLocalKeyEncrypted", + "aiOpenaiKeyEncrypted", + "insuranceNumberEncrypted", ]; for (const field of userFields) { - const r = await rotateField( - "User", - field as string, - users, - (u) => (u as Record)[field as string] as string | null, - async (id, ciphertext) => { - await prisma.user.update({ - where: { id }, - data: { [field]: ciphertext } as Record, - }); - }, - ); - results.push(r); + results.push(await rotateStringColumn("User", field, prisma.user)); } - // ───── WithingsConnection table (accessToken / refreshToken) ───── - const withings = await prisma.withingsConnection.findMany({ - select: { id: true, accessToken: true, refreshToken: true }, - }); - for (const field of ["accessToken", "refreshToken"] as const) { - const r = await rotateField( - "WithingsConnection", - field, - withings, - (w) => w[field], - async (id, ciphertext) => { - await prisma.withingsConnection.update({ - where: { id }, - data: { [field]: ciphertext } as Record, - }); - }, - ); - results.push(r); - } - - // ───── WhoopConnection table (accessToken / refreshToken) ───── - const whoop = await prisma.whoopConnection.findMany({ - select: { id: true, accessToken: true, refreshToken: true }, - }); - for (const field of ["accessToken", "refreshToken"] as const) { - const r = await rotateField( - "WhoopConnection", - field, - whoop, - (w) => w[field], - async (id, ciphertext) => { - await prisma.whoopConnection.update({ - where: { id }, - data: { [field]: ciphertext } as Record, - }); - }, - ); - results.push(r); - } - - // ───── FitbitConnection table (accessToken / refreshToken) ───── - const fitbit = await prisma.fitbitConnection.findMany({ - select: { id: true, accessToken: true, refreshToken: true }, - }); - for (const field of ["accessToken", "refreshToken"] as const) { - const r = await rotateField( - "FitbitConnection", - field, - fitbit, - (f) => f[field], - async (id, ciphertext) => { - await prisma.fitbitConnection.update({ - where: { id }, - data: { [field]: ciphertext } as Record
- {t("settings.sections.thresholds.description")} -
{t("insights.coach.historyEmpty")}