From 9839bde4f650eb5d94e57d870b605ebb8c22624f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 18:46:19 +0200 Subject: [PATCH 001/127] chore(demo): let the dental check-up fall due on the seed day --- scripts/seed-demo.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index c37cf6089..569230424 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -31,7 +31,8 @@ * - 90 days of mood entries * - Six mental-wellbeing screeners (WHO-5, PHQ-9, GAD-7) spread over the * window, item answers encrypted the way the check-in route writes them - * - Vorsorge (preventive-care) reminders — upcoming dental + annual physical + * - Vorsorge (preventive-care) reminders — a dental check-up due today and + * an annual physical in three months * - Two lab panels of biomarkers across two dates (quantitative with * reference ranges + qualitative "negativ" rows) * - Two illness episodes — a resolved acute cold with a day-by-day symptom @@ -1511,14 +1512,15 @@ async function seed() { // ── Vorsorge reminders (preventive care) ── // User-created (origin VORSORGE) checklist reminders with no auto-resolve - // measurement target. Both anchored to fire comfortably in the future so - // the Vorsorge surface shows sensible upcoming items, not overdue ones. + // measurement target. The dental check-up falls due this morning, so the + // dashboard's "worth a look" carries it beside tomorrow's appointment; the + // annual physical sits comfortably in the future. console.log("Creating Vorsorge reminders..."); - const dentalNextDue = daysAgo(-45); // ~6 weeks out + const dentalNextDue = berlinHmAsUtc(9, 0); // due today, 09:00 local await client.query( `INSERT INTO measurement_reminders (id, user_id, label, interval_days, anchor_date, origin, notify_hour, location, next_due_at, created_at, updated_at) VALUES ($1, $2, 'Dental check-up', 365, $3, 'VORSORGE', 9, 'Dentist', $4, NOW(), NOW())`, - [cuid(), userId, daysAgo(320), dentalNextDue], + [cuid(), userId, daysAgo(365), dentalNextDue], ); const physicalNextDue = daysAgo(-90); // ~3 months out await client.query( From 89ccb71a12408887dff8dbcbb43ddfddc4346d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 18:49:51 +0200 Subject: [PATCH 002/127] chore(demo): show one row of six tiles with the weight chart below it --- scripts/seed-demo.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 569230424..ae3061820 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -493,24 +493,25 @@ async function seed() { ); // ── Dashboard showcase layout ───────────── - // The demo seeds rich sleep / steps / glucose / VO2max / vorsorge data, - // so persist an explicit dashboard layout that surfaces those tiles plus - // the daily-verdict hero. The v1.20.0 defaults already turn the data - // tiles on, but writing the layout verbatim keeps the public demo's home - // page stable regardless of future default tweaks. Derived from - // DEFAULT_DASHBOARD_LAYOUT with the seeded-rich strip tiles forced on. + // One row of six tiles (weight, the two blood-pressure values, resting + // heart rate, HRV and mood) and the weight chart right below it, so the + // first screen of the public demo shows the summary, the tiles and the + // start of a chart together. Every other tile stays one switch away in + // the layout settings; its data is seeded either way. Writing the layout + // verbatim keeps the demo's home page stable across default tweaks. const SHOWCASE_TILE_IDS = new Set([ - "sleep", - "steps", - "glucose", - "vo2Max", - "recentWorkouts", + "weight", + "bp", + "pulse", + "hrv", + "mood", ]); const demoDashboardLayout = serializeDashboardLayout({ ...DEFAULT_DASHBOARD_LAYOUT, - widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => - SHOWCASE_TILE_IDS.has(w.id) ? { ...w, tileVisible: true } : w, - ), + widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => ({ + ...w, + tileVisible: SHOWCASE_TILE_IDS.has(w.id), + })), }); await client.query( `UPDATE users SET dashboard_widgets_json = $1 WHERE id = $2`, From 5d6216a59f54c3168270a97f472fb1845dbfccbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 20:24:00 +0200 Subject: [PATCH 003/127] fix(labs): resolve biomarker delete through the record-scoped labs authority DELETE /api/biomarkers/{id} used a bare requireAuth() while the PUT beside it requires requireRecordAuth("manage", "labs"). The delete now uses the same authority and, as a manage-level hard delete, files what it destroyed (name, unit, reading count) through destroyedDetails. --- src/__tests__/sharing-surface-guard.test.ts | 8 +- .../[id]/__tests__/delete-record-auth.test.ts | 101 ++++++++++++++++++ src/app/api/biomarkers/[id]/route.ts | 35 ++++-- tests/fixtures/v137/sharing-matrix.ts | 8 ++ .../sharing-manage-handler-matrix.test.ts | 33 +++++- 5 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 src/app/api/biomarkers/[id]/__tests__/delete-record-auth.test.ts diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 6d627ac32..e75df50bc 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -853,7 +853,7 @@ const DELEGABLE_ROUTES: Record = { }, "app/api/biomarkers/route.ts": { domain: "labs", - why: "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are refused.", + why: "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are manage-level.", }, "app/api/biomarkers/[id]/route.ts": { domain: "labs", @@ -1601,8 +1601,8 @@ const DELEGABLE_MANAGE_ROUTES: Record = { }, "app/api/biomarkers/[id]/route.ts": { domain: "labs", - conditions: ["C4"], - why: "Editing an analyte's reference range, the one admitted edit whose error is silent, which is why C4 puts it in the feed. The DELETE beside it stays refused.", + conditions: ["C3", "C4"], + why: "Editing an analyte's reference range, the one admitted edit whose error is silent, which is why C4 puts it in the feed. The DELETE beside it resolves through the same labs authority since v1.43 instead of a bare requireAuth; it hard-deletes the marker with its readings, so C3 files the name, unit and reading count it took.", }, "app/api/allergies/route.ts": { domain: "profile", @@ -2866,7 +2866,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(88); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/app/api/biomarkers/[id]/__tests__/delete-record-auth.test.ts b/src/app/api/biomarkers/[id]/__tests__/delete-record-auth.test.ts new file mode 100644 index 000000000..3b9cb8f7a --- /dev/null +++ b/src/app/api/biomarkers/[id]/__tests__/delete-record-auth.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * `DELETE /api/biomarkers/{id}` takes a marker together with every reading + * filed under it. It must resolve its record through the same record-scoped + * labs authority as the PUT beside it — `requireRecordAuth("manage", "labs")` + * — rather than the bare `requireAuth()` it carried before, so the delete is + * held to the labs domain, the session fence, and the grant level like every + * other labs write. + */ + +const requireAuth = vi.fn(); +const requireRecordAuth = vi.fn(); + +vi.mock("@/lib/api-handler", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requireAuth: (...args: unknown[]) => requireAuth(...args), + requireRecordAuth: (...args: unknown[]) => requireRecordAuth(...args), + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + biomarker: { findFirst: vi.fn(), delete: vi.fn() }, + labResult: { deleteMany: vi.fn() }, + $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), + }, +})); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserHealthScore: vi.fn(), +})); + +import { DELETE } from "../route"; +import { HttpError } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; + +const RECORD_USER = { id: "record-1", username: "r", role: "USER" }; + +function delReq(): NextRequest { + return new NextRequest("http://localhost/api/biomarkers/bm-1", { + method: "DELETE", + }); +} +const params = { params: Promise.resolve({ id: "bm-1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + requireAuth.mockRejectedValue( + new HttpError(403, "Not available for this record"), + ); + requireRecordAuth.mockResolvedValue({ + user: RECORD_USER, + actor: { id: "actor-1" }, + grantId: "grant-1", + authMethod: "cookie", + }); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue({ + id: "bm-1", + userId: "record-1", + } as never); + vi.mocked(prisma.labResult.deleteMany).mockResolvedValue({ + count: 2, + } as never); + vi.mocked(prisma.biomarker.delete).mockResolvedValue({} as never); +}); + +describe("DELETE /api/biomarkers/[id] record authority", () => { + it("resolves the record through requireRecordAuth('manage', 'labs')", async () => { + const res = await DELETE(delReq(), params); + expect(res.status).toBe(200); + expect(requireRecordAuth).toHaveBeenCalledWith("manage", "labs"); + expect(requireAuth).not.toHaveBeenCalled(); + }); + + it("narrows both delete legs to the resolved record", async () => { + await DELETE(delReq(), params); + expect(prisma.labResult.deleteMany).toHaveBeenCalledWith({ + where: { userId: "record-1", biomarkerId: "bm-1" }, + }); + }); + + it("refuses when the record authority refuses, deleting nothing", async () => { + requireRecordAuth.mockRejectedValue( + new HttpError(403, "Not available for this record"), + ); + const res = await DELETE(delReq(), params); + expect(res.status).toBe(403); + expect(prisma.labResult.deleteMany).not.toHaveBeenCalled(); + expect(prisma.biomarker.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 506333ad3..bdfdbae1b 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -10,7 +10,10 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; -import { overwriteDetails } from "@/lib/sharing/audit-details"; +import { + destroyedDetails, + overwriteDetails, +} from "@/lib/sharing/audit-details"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { @@ -90,8 +93,8 @@ export const PUT = apiHandler( // v1.37.0 — MANAGE, and the closest call in this domain: a reference range // recolours every reading under it, so this is the one admitted edit whose // error is silent rather than visible. C4 on the audit row is what puts it - // in the owner's feed. The DELETE below stays refused — it takes the - // readings with it and files an id. + // in the owner's feed. The DELETE below resolves through the same labs + // authority; it takes the readings with it and files the id. const { user } = await requireRecordAuth("manage", "labs"); const { id } = await params; @@ -254,7 +257,10 @@ export const PUT = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // The same record-scoped labs authority as the PUT above. A bare + // `requireAuth()` here held the one destructive biomarker write to a + // weaker gate than the edit beside it: no labs domain, no grant level. + const { user } = await requireRecordAuth("manage", "labs"); const { id } = await params; const existing = await prisma.biomarker.findFirst({ where: { id } }); @@ -267,7 +273,7 @@ export const DELETE = apiHandler( // readings go with it rather than lingering as orphaned, unlinked rows. // Both legs are userId-narrowed so a cross-user id can never reach another // account's data. - await prisma.$transaction([ + const [removedReadings] = await prisma.$transaction([ prisma.labResult.deleteMany({ where: { userId: user.id, biomarkerId: id }, }), @@ -278,7 +284,22 @@ export const DELETE = apiHandler( await auditLog("biomarker.delete", { userId: user.id, ipAddress: getClientIp(request), - details: { biomarkerId: id }, + // C3: a hard delete files what it destroyed — the marker's name, unit, + // and how many readings went with it — so the owner's feed can say what + // a manager removed, not just an id that now points at nothing. + details: { + biomarkerId: id, + ...destroyedDetails({ + model: "Biomarker", + id, + label: existing.name, + effectiveAt: existing.createdAt, + extra: { + unit: existing.unit, + readingCount: removedReadings?.count ?? 0, + }, + }), + }, }); annotate({ diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 5d70af0c0..a204608b9 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -288,6 +288,14 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "update", ), + handler( + "app/api/biomarkers/[id]/route.ts", + "/api/biomarkers/[id]", + "DELETE", + "labs", + "manage", + "delete", + ), handler( "app/api/allergies/route.ts", "/api/allergies", diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 6b7d83fce..e373e8131 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(88); }); }); @@ -1097,6 +1097,33 @@ manageContract("PUT /api/biomarkers/[id]", { ?.unit === "ng/mL", }); +manageContract("DELETE /api/biomarkers/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/biomarkers/[id]" && entry.action === "DELETE", + )!, + prepare: async (userId) => + getPrismaClient().biomarker.create({ + data: { userId, name: "Transferrin", unit: "g/L" }, + }), + act: async (row) => { + const { DELETE } = await import("@/app/api/biomarkers/[id]/route"); + return call( + DELETE as Handler, + "DELETE", + `/api/biomarkers/${row.id}`, + undefined, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "biomarker.delete", + applied: async (row) => + (await getPrismaClient().biomarker.findUnique({ + where: { id: row.id }, + })) === null, +}); + manageContract("POST /api/allergies", { contract: ADMITTED_MUTATING_HANDLERS.find( (entry) => entry.route === "/api/allergies" && entry.action === "POST", @@ -2851,13 +2878,13 @@ describe("the conditions the admissions were granted on", () => { describe("the complete MANAGE handler matrix", () => { it("registers one strict actor-and-effect driver for every admission", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(STRICT_DRIVER_KEYS.size).toBe(87); + expect(STRICT_DRIVER_KEYS.size).toBe(88); expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected); }); it("executes every registered driver through its owned effect and actor audit", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(REAL_EFFECT_KEYS.size).toBe(87); + expect(REAL_EFFECT_KEYS.size).toBe(88); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); }); From f72ec024848a255848f1f2066a0f1a79ab4ef74e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 20:24:01 +0200 Subject: [PATCH 004/127] fix(labs): charge the shared record-write bucket on OCR commit POST /api/labs/ocr/commit can write a whole report and mint markers, yet it was the one lab write without a ceiling. It now charges the same per-account record-write bucket as POST /api/labs and POST /api/biomarkers and answers 429 record_write.rate_limited once exhausted. --- .../labs/ocr/commit/__tests__/route.test.ts | 32 +++++++++++++++++++ src/app/api/labs/ocr/commit/route.ts | 13 ++++++++ 2 files changed, 45 insertions(+) diff --git a/src/app/api/labs/ocr/commit/__tests__/route.test.ts b/src/app/api/labs/ocr/commit/__tests__/route.test.ts index e77e0b794..3ab2468ad 100644 --- a/src/app/api/labs/ocr/commit/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/commit/__tests__/route.test.ts @@ -21,6 +21,15 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +const rateLimitMock = vi.hoisted(() => ({ allowed: true })); +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: rateLimitMock.allowed, + remaining: rateLimitMock.allowed ? 299 : 0, + resetAt: new Date(Date.now() + 60_000), + })), +})); const tzMock = vi.hoisted(() => ({ zone: "UTC" })); vi.mock("@/lib/tz/resolver", async (importOriginal) => ({ ...(await importOriginal()), @@ -62,6 +71,7 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -90,6 +100,7 @@ function postReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); tzMock.zone = "UTC"; + rateLimitMock.allowed = true; vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(enqueueReminderSatisfy).mockResolvedValue(undefined as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); @@ -198,3 +209,24 @@ describe("POST /api/labs/ocr/commit dedup across the user's day", () => { expect(prisma.labResult.create).toHaveBeenCalledTimes(1); }); }); + +describe("POST /api/labs/ocr/commit write ceiling", () => { + it("charges the shared per-account record-write bucket", async () => { + const res = await POST(postReq({ rows: [numericRow(95)] })); + expect(res.status).toBe(200); + expect(checkRecordWriteRateLimit).toHaveBeenCalledWith("user-1"); + }); + + it("answers 429 record_write.rate_limited and writes nothing once exhausted", async () => { + rateLimitMock.allowed = false; + const res = await POST(postReq({ rows: [numericRow(95)] })); + expect(res.status).toBe(429); + const body = (await res.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("record_write.rate_limited"); + expect(prisma.labResult.create).not.toHaveBeenCalled(); + expect(resolveOrMintBiomarker).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 1066edb69..7e0a00d17 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -20,6 +20,7 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { + apiError, apiSuccess, getClientIp, returnAllZodIssues, @@ -30,6 +31,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; @@ -136,6 +138,17 @@ function inBatchKey(row: OcrCommitRow, tz: string): string { async function commitOcrRows(request: NextRequest) { const { user } = await requireAuth(); + // Shared per-account write ceiling, the same bucket every other lab write + // charges (`POST /api/labs`, `POST /api/biomarkers`). A commit can write a + // whole report's rows and mint markers on the way, so it was the one lab + // write a looping client could repeat without any ceiling. + const writeRl = await checkRecordWriteRateLimit(user.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 256 * 1024, }); From 9ee89f2455913921c08031e1a1cf8ba1f3157150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 20:24:01 +0200 Subject: [PATCH 005/127] fix(http): adopt an incoming x-request-id only when it is a plain token The header is echoed on the response and written into every log line. Both readers (the proxy and apiHandler) now accept it only when it matches ^[A-Za-z0-9-]{8,64}$ and otherwise use a fresh id. --- src/__tests__/request-id-validation.test.ts | 91 +++++++++++++++++++++ src/lib/api-handler.ts | 10 +-- src/lib/http/request-id.ts | 26 ++++++ src/proxy.ts | 7 +- 4 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/request-id-validation.test.ts create mode 100644 src/lib/http/request-id.ts diff --git a/src/__tests__/request-id-validation.test.ts b/src/__tests__/request-id-validation.test.ts new file mode 100644 index 000000000..def2df044 --- /dev/null +++ b/src/__tests__/request-id-validation.test.ts @@ -0,0 +1,91 @@ +/** + * An incoming `x-request-id` is client-controlled. It is echoed on the + * response and written into every log line of the request, so it is adopted + * only when it is a plain correlation token — `^[A-Za-z0-9-]{8,64}$` — and + * replaced with a fresh id otherwise. Both places that read it are covered: + * the proxy (every proxied path) and `apiHandler` (which also serves the + * routes the proxy matcher leaves out). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, + shouldRunWorker: () => true, + getProcessType: () => "all", +})); + +import { proxy } from "../proxy"; +import { apiHandler } from "@/lib/api-handler"; +import { getEvent } from "@/lib/logging/context"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +const VALID = ["3f9a2c1b", "abcDEF12-3456-7890", "a".repeat(64)]; +const INVALID = [ + "short", + "a".repeat(65), + "id with spaces", + "id_underscore_x", + "", + 'x" level="error', + "ümläut-id-1234", +]; + +beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); +}); +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function proxied(requestId: string): NextRequest { + return new NextRequest("http://localhost/api/version", { + headers: { "x-request-id": requestId }, + }); +} + +describe("proxy: incoming x-request-id", () => { + it.each(VALID)("adopts the well-formed id %s", (id) => { + const res = proxy(proxied(id)); + expect(res.headers.get("x-request-id")).toBe(id); + }); + + it.each(INVALID)("replaces the malformed id %j with a fresh one", (id) => { + const res = proxy(proxied(id)); + const echoed = res.headers.get("x-request-id"); + expect(echoed).not.toBe(id); + expect(echoed).toMatch(UUID); + // The rewritten request header the route sees carries the same fresh id. + expect(res.headers.get("x-middleware-request-x-request-id")).toBe(echoed); + }); +}); + +describe("apiHandler: incoming x-request-id", () => { + function call(id: string) { + let logged: string | undefined; + const wrapped = apiHandler(async (_request: NextRequest) => { + logged = getEvent()?.getRequestId(); + return NextResponse.json({ data: "ok" }); + }); + return wrapped( + new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "x-request-id": id }, + }), + ).then((res) => ({ res, logged })); + } + + it.each(VALID)("logs and echoes the well-formed id %s", async (id) => { + const { res, logged } = await call(id); + expect(logged).toBe(id); + expect(res.headers.get("x-request-id")).toBe(id); + }); + + it.each(INVALID)("never logs or echoes the malformed id %j", async (id) => { + const { res, logged } = await call(id); + expect(logged).not.toBe(id); + expect(logged).toMatch(UUID); + expect(res.headers.get("x-request-id")).toBe(logged); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 7c6a1603b..c837aec0f 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -54,6 +54,7 @@ import { attachRecordContextEcho, } from "./sharing/record-session-fence"; import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal"; +import { acceptIncomingRequestId } from "./http/request-id"; import { capturedRateLimit, rateLimitResponseHeaders, @@ -229,11 +230,10 @@ export function apiHandler Promise>( const evt = new WideEventBuilder("http"); - // Propagate x-request-id if present - const incomingRequestId = safeRequestProp( - request, - (r) => r.headers.get("x-request-id"), - null, + // Propagate x-request-id when it is a well-formed token. A malformed one + // never reaches the log or the response; the event keeps its fresh id. + const incomingRequestId = acceptIncomingRequestId( + safeRequestProp(request, (r) => r.headers.get("x-request-id"), null), ); if (incomingRequestId) evt.setRequestId(incomingRequestId); diff --git a/src/lib/http/request-id.ts b/src/lib/http/request-id.ts new file mode 100644 index 000000000..51754621d --- /dev/null +++ b/src/lib/http/request-id.ts @@ -0,0 +1,26 @@ +/** + * Incoming `x-request-id` acceptance. + * + * The header is client-controlled, and the value is echoed on the response + * and written into every log line of the request. Only a plain correlation + * token is adopted — letters, digits and hyphens, 8 to 64 characters, which + * covers a UUID and every proxy-generated id in use — so a caller cannot + * inject quotes, markup or log-shaped text through it. Anything else is + * dropped and the request gets a fresh id. + * + * Edge-safe: no Node imports, because `src/proxy.ts` reads it too. + */ +export const REQUEST_ID_PATTERN = /^[A-Za-z0-9-]{8,64}$/; + +/** The incoming id when it is well-formed, else `null`. */ +export function acceptIncomingRequestId( + value: string | null | undefined, +): string | null { + if (typeof value !== "string") return null; + return REQUEST_ID_PATTERN.test(value) ? value : null; +} + +/** The incoming id when it is well-formed, else a fresh UUID. */ +export function resolveRequestId(value: string | null | undefined): string { + return acceptIncomingRequestId(value) ?? crypto.randomUUID(); +} diff --git a/src/proxy.ts b/src/proxy.ts index 3389400b3..85bc55282 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -7,6 +7,7 @@ import { } from "@/lib/http/retired-routes"; import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes"; import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal"; +import { resolveRequestId } from "@/lib/http/request-id"; /** * Paths that do NOT require a session cookie (public pages + external webhooks). @@ -568,8 +569,10 @@ export function proxy(request: NextRequest) { } } - // Generate or propagate x-request-id for request correlation - const requestId = request.headers.get("x-request-id") || crypto.randomUUID(); + // Generate or propagate x-request-id for request correlation. An incoming + // id is adopted only when it is a plain token (`resolveRequestId`); it is + // echoed and logged, so anything else is replaced with a fresh one. + const requestId = resolveRequestId(request.headers.get("x-request-id")); // 128 bits of random, base64-encoded. randomUUID().toString() only carries // ~122 bits and has a predictable structure that base64-encodes to a From 97871f7b90d244b4e8219f428b1c4a2f6731ffe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 21:31:50 +0200 Subject: [PATCH 006/127] fix(http): mint the fresh request id through randomId The proxy no longer calls crypto.randomUUID itself, so it leaves the client-random-id exemption list; the request-id helper uses the shared randomId, which also works without a secure context. --- src/__tests__/client-random-id-guard.test.ts | 2 -- src/lib/http/request-id.ts | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/client-random-id-guard.test.ts b/src/__tests__/client-random-id-guard.test.ts index fc1c9491a..af8da88f1 100644 --- a/src/__tests__/client-random-id-guard.test.ts +++ b/src/__tests__/client-random-id-guard.test.ts @@ -47,8 +47,6 @@ const SKIP_DIRS = new Set([ * runs on the server where the property always exists. */ const SERVER_SIDE_EXEMPT = new Set([ - // Next middleware — runs on the edge runtime, never in the page. - "src/proxy.ts", // The one module allowed to reach for the platform call. "src/lib/random-id.ts", ]); diff --git a/src/lib/http/request-id.ts b/src/lib/http/request-id.ts index 51754621d..74df2a8c5 100644 --- a/src/lib/http/request-id.ts +++ b/src/lib/http/request-id.ts @@ -10,6 +10,8 @@ * * Edge-safe: no Node imports, because `src/proxy.ts` reads it too. */ +import { randomId } from "@/lib/random-id"; + export const REQUEST_ID_PATTERN = /^[A-Za-z0-9-]{8,64}$/; /** The incoming id when it is well-formed, else `null`. */ @@ -22,5 +24,5 @@ export function acceptIncomingRequestId( /** The incoming id when it is well-formed, else a fresh UUID. */ export function resolveRequestId(value: string | null | undefined): string { - return acceptIncomingRequestId(value) ?? crypto.randomUUID(); + return acceptIncomingRequestId(value) ?? randomId(); } From 0b64b803436296d07102a9e9f5fbbca1266e8306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 21:31:50 +0200 Subject: [PATCH 007/127] test(sharing): count the admitted biomarker delete in the fixture inventory --- src/__tests__/v137-wave-zero-fixtures.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 82ba66592..6ef3fb5ba 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(87); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(88); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( From 6e72de1c35ebfc0cb2bda7e328d4d54fd91f10be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 21:48:57 +0200 Subject: [PATCH 008/127] feat(db): add the v1.43 schema and drop the readable location columns Migrations 0402 to 0410: AI usage levels, biomarker aliases and merges, medication ingredients (with the supplement label run kind), life circumstance fact kinds, AI sections and AUDIT-C, Dawarich context, the pulse day-mean method, intraday hour buckets, the readable location drop and five relation link tables. Every new column carries its reader or a pending-consumer marker. 0409 refuses to run while any home, location period or stored day still holds a readable location, and names the counts. Locations are read from the sealed copy only; the encryption backfill no longer handles them, and the demo seed writes sealed locations only. The new tables are backed up and wiped, the new sealed columns are in the rotation registry with labels of their own, the AI usage level and the life-circumstance confirmations travel with the backup, and the wellbeing score reads the mental-health screeners only. --- .../0402_ai_usage_levels/migration.sql | 24 + .../migration.sql | 58 +++ .../0404_medication_ingredients/migration.sql | 43 ++ .../0405_life_circumstances/migration.sql | 27 ++ .../0406_dawarich_context/migration.sql | 38 ++ .../0407_pulse_day_mean_method/migration.sql | 14 + .../0408_intraday_hour_buckets/migration.sql | 16 + .../migration.sql | 50 ++ .../0410_relation_links/migration.sql | 151 +++++++ prisma/schema.prisma | 426 +++++++++++++++--- scripts/rotate-encryption-key.ts | 20 +- scripts/seed-demo.ts | 17 +- .../environment-backup-completeness.test.ts | 13 +- .../backfill/__tests__/route.test.ts | 1 - src/app/api/environment/backfill/route.ts | 6 +- src/app/api/environment/home/route.ts | 3 - src/app/api/environment/route.ts | 22 +- src/app/api/environment/travel/route.ts | 3 - .../tools/__tests__/environment-read.test.ts | 3 +- src/lib/analytics/score/reader.ts | 44 +- src/lib/crypto/encrypted-columns.ts | 37 +- src/lib/data-wipe/wipe-plan.ts | 26 +- src/lib/documents/ai-runs/store.ts | 5 +- src/lib/documents/ai-runs/types.ts | 13 + .../__tests__/location-cipher.test.ts | 26 +- .../__tests__/service-air-quality.test.ts | 21 +- src/lib/environment/air-quality-history.ts | 10 +- src/lib/environment/location-cipher.ts | 67 +-- src/lib/environment/service.ts | 42 +- .../__tests__/account-settings-backup.test.ts | 15 +- src/lib/export/account-settings-backup.ts | 50 +- src/lib/export/backup-plan.ts | 53 ++- src/lib/export/environment-backup.ts | 70 +-- src/lib/export/profile-backup.ts | 26 +- .../free-text-encryption-backfill.test.ts | 154 ------- .../jobs/environment-air-quality-history.ts | 1 - src/lib/jobs/environment-fetch.ts | 9 +- src/lib/jobs/free-text-encryption-backfill.ts | 179 +------- src/lib/validations/backup.ts | 9 + src/lib/validations/mental-health.ts | 15 + tests/integration/backup-round-trip.test.ts | 49 +- .../drop-plaintext-location-migration.test.ts | 245 ++++++++++ .../environment-coarse-location.test.ts | 83 +--- .../environment-location-encryption.test.ts | 110 +---- .../prisma-schema-name-drift.test.ts | 9 - 45 files changed, 1386 insertions(+), 917 deletions(-) create mode 100644 prisma/migrations/0402_ai_usage_levels/migration.sql create mode 100644 prisma/migrations/0403_biomarker_aliases_merges/migration.sql create mode 100644 prisma/migrations/0404_medication_ingredients/migration.sql create mode 100644 prisma/migrations/0405_life_circumstances/migration.sql create mode 100644 prisma/migrations/0406_dawarich_context/migration.sql create mode 100644 prisma/migrations/0407_pulse_day_mean_method/migration.sql create mode 100644 prisma/migrations/0408_intraday_hour_buckets/migration.sql create mode 100644 prisma/migrations/0409_drop_plaintext_location/migration.sql create mode 100644 prisma/migrations/0410_relation_links/migration.sql create mode 100644 tests/integration/drop-plaintext-location-migration.test.ts diff --git a/prisma/migrations/0402_ai_usage_levels/migration.sql b/prisma/migrations/0402_ai_usage_levels/migration.sql new file mode 100644 index 000000000..45db2f08e --- /dev/null +++ b/prisma/migrations/0402_ai_usage_levels/migration.sql @@ -0,0 +1,24 @@ +-- AI usage in three levels (#1217). +-- +-- An account picks frugal, standard or full; the operator sets the highest +-- level an account may pick and the level an account without its own choice +-- runs at. `ai_sync_regen_gap_hours` is the minimum gap between status-card +-- regenerations after a sync at `standard` (NULL = env or 6 h). Both +-- instance defaults are STANDARD; an account's stored NULL follows the +-- instance default. + +-- CreateEnum +CREATE TYPE "ai_usage_level" AS ENUM ('FRUGAL', 'STANDARD', 'FULL'); + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "ai_usage_level" "ai_usage_level"; + +-- AlterTable +ALTER TABLE "app_settings" + ADD COLUMN "ai_usage_max_level" "ai_usage_level" NOT NULL DEFAULT 'STANDARD', + ADD COLUMN "ai_usage_default_level" "ai_usage_level" NOT NULL DEFAULT 'STANDARD', + ADD COLUMN "ai_sync_regen_gap_hours" INTEGER; + +ALTER TABLE "app_settings" + ADD CONSTRAINT "app_settings_ai_sync_regen_gap_hours_check" + CHECK ("ai_sync_regen_gap_hours" IS NULL OR "ai_sync_regen_gap_hours" BETWEEN 1 AND 48); diff --git a/prisma/migrations/0403_biomarker_aliases_merges/migration.sql b/prisma/migrations/0403_biomarker_aliases_merges/migration.sql new file mode 100644 index 000000000..7a753f7ab --- /dev/null +++ b/prisma/migrations/0403_biomarker_aliases_merges/migration.sql @@ -0,0 +1,58 @@ +-- Biomarker aliases and the merge ledger (D1223). +-- +-- An alias is another spelling of a marker on one account; the fold of the +-- name is unique per account, so one spelling resolves to one marker. A merge +-- moves the source markers' readings onto the target and deletes the sources; +-- `biomarker_merges` keeps one row per source so a stale id can answer with +-- the marker it went into. + +-- CreateTable +CREATE TABLE "biomarker_aliases" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "biomarker_id" TEXT NOT NULL, + "name" VARCHAR(120) NOT NULL, + "name_folded" VARCHAR(120) NOT NULL, + "source" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "biomarker_aliases_pkey" PRIMARY KEY ("id"), + CONSTRAINT "biomarker_aliases_source_check" CHECK ("source" IN ('MERGE', 'IMPORT_CHOICE', 'MANUAL')) +); + +-- CreateTable +CREATE TABLE "biomarker_merges" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "target_biomarker_id" TEXT NOT NULL, + "source_biomarker_id" TEXT NOT NULL, + "source_name" VARCHAR(120) NOT NULL, + "reading_count" INTEGER NOT NULL, + "merged_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "biomarker_merges_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "biomarker_aliases_biomarker_id_idx" ON "biomarker_aliases"("biomarker_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "biomarker_aliases_user_id_name_folded_key" ON "biomarker_aliases"("user_id", "name_folded"); + +-- CreateIndex +CREATE INDEX "biomarker_merges_user_id_source_biomarker_id_idx" ON "biomarker_merges"("user_id", "source_biomarker_id"); + +-- CreateIndex +CREATE INDEX "biomarker_merges_target_biomarker_id_idx" ON "biomarker_merges"("target_biomarker_id"); + +-- AddForeignKey +ALTER TABLE "biomarker_aliases" ADD CONSTRAINT "biomarker_aliases_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "biomarker_aliases" ADD CONSTRAINT "biomarker_aliases_biomarker_id_fkey" FOREIGN KEY ("biomarker_id") REFERENCES "biomarkers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "biomarker_merges" ADD CONSTRAINT "biomarker_merges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "biomarker_merges" ADD CONSTRAINT "biomarker_merges_target_biomarker_id_fkey" FOREIGN KEY ("target_biomarker_id") REFERENCES "biomarkers"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0404_medication_ingredients/migration.sql b/prisma/migrations/0404_medication_ingredients/migration.sql new file mode 100644 index 000000000..34202b250 --- /dev/null +++ b/prisma/migrations/0404_medication_ingredients/migration.sql @@ -0,0 +1,43 @@ +-- Supplement composition. +-- +-- What one unit (tablet, capsule) of a medication contains, per nutrient, in +-- the nutrient catalogue's canonical unit. One row per (medication, nutrient). +-- The entered amount and unit are kept when the input unit differed (IU for +-- vitamin D). Taken doses add their share to `nutrient_intake_days` under the +-- text source `SUPPLEMENT`, which needs no schema change. Reading a label +-- photo is a background document AI run of its own kind; the value is added +-- only, nothing here uses it. + +-- AlterEnum +ALTER TYPE "document_ai_run_kind" ADD VALUE 'SUPPLEMENT_LABEL_EXTRACT'; + +-- CreateTable +CREATE TABLE "medication_ingredients" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "nutrient" VARCHAR(40) NOT NULL, + "amount_per_unit" DOUBLE PRECISION NOT NULL, + "unit" VARCHAR(8) NOT NULL, + "entered_amount" DOUBLE PRECISION, + "entered_unit" VARCHAR(8), + "source" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "medication_ingredients_pkey" PRIMARY KEY ("id"), + CONSTRAINT "medication_ingredients_amount_per_unit_check" CHECK ("amount_per_unit" > 0), + CONSTRAINT "medication_ingredients_source_check" CHECK ("source" IN ('MANUAL', 'LABEL_AI')) +); + +-- CreateIndex +CREATE INDEX "medication_ingredients_user_id_idx" ON "medication_ingredients"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "medication_ingredients_medication_id_nutrient_key" ON "medication_ingredients"("medication_id", "nutrient"); + +-- AddForeignKey +ALTER TABLE "medication_ingredients" ADD CONSTRAINT "medication_ingredients_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_ingredients" ADD CONSTRAINT "medication_ingredients_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0405_life_circumstances/migration.sql b/prisma/migrations/0405_life_circumstances/migration.sql new file mode 100644 index 000000000..a7441c152 --- /dev/null +++ b/prisma/migrations/0405_life_circumstances/migration.sql @@ -0,0 +1,27 @@ +-- Life circumstances (#614). +-- +-- Six new structured fact kinds (sealed JSON values, `schema: 1`), four new +-- AI sections that are never in the column default, AUDIT-C as an assessment +-- instrument, and the per-section "still true" confirmations. The new enum +-- values are added only; nothing in this migration uses them, so the +-- in-transaction ADD VALUE restriction does not apply. + +-- AlterEnum +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'TOBACCO_DETAIL'; +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'ACTIVITY_HABIT'; +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'WORK_CONTEXT'; +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'FREE_WEEKDAYS'; +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'HABITS'; +ALTER TYPE "health_profile_fact_kind" ADD VALUE 'HEALTH_GOALS'; + +-- AlterEnum +ALTER TYPE "health_profile_ai_section" ADD VALUE 'ACTIVITY'; +ALTER TYPE "health_profile_ai_section" ADD VALUE 'WORK'; +ALTER TYPE "health_profile_ai_section" ADD VALUE 'HABITS'; +ALTER TYPE "health_profile_ai_section" ADD VALUE 'GOALS'; + +-- AlterEnum +ALTER TYPE "assessment_instrument" ADD VALUE 'AUDIT_C'; + +-- AlterTable +ALTER TABLE "user_health_profiles" ADD COLUMN "life_confirmed_json" JSONB; diff --git a/prisma/migrations/0406_dawarich_context/migration.sql b/prisma/migrations/0406_dawarich_context/migration.sql new file mode 100644 index 000000000..ad58dde23 --- /dev/null +++ b/prisma/migrations/0406_dawarich_context/migration.sql @@ -0,0 +1,38 @@ +-- Dawarich movement context. +-- +-- Per-account sealed connection (URL, API key, preferences) and one row per +-- local day with daily values only: distances in metres, places visited, +-- minutes away from home, and the sealed country code of the day. No +-- coordinate, place name or visit is stored anywhere. + +-- AlterTable +ALTER TABLE "users" + ADD COLUMN "dawarich_url_encrypted" TEXT, + ADD COLUMN "dawarich_api_key_encrypted" TEXT, + ADD COLUMN "dawarich_prefs_encrypted" BYTEA, + ADD COLUMN "dawarich_allow_private_host" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "dawarich_days" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "date" VARCHAR(10) NOT NULL, + "total_distance_m" INTEGER, + "driving_distance_m" INTEGER, + "train_distance_m" INTEGER, + "places_visited" INTEGER, + "away_from_home_minutes" INTEGER, + "country_code_encrypted" BYTEA, + "is_travel_day" BOOLEAN, + "fetched_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "dawarich_days_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "dawarich_days_user_id_date_key" ON "dawarich_days"("user_id", "date"); + +-- AddForeignKey +ALTER TABLE "dawarich_days" ADD CONSTRAINT "dawarich_days_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0407_pulse_day_mean_method/migration.sql b/prisma/migrations/0407_pulse_day_mean_method/migration.sql new file mode 100644 index 000000000..fed53c3e6 --- /dev/null +++ b/prisma/migrations/0407_pulse_day_mean_method/migration.sql @@ -0,0 +1,14 @@ +-- Pulse day-mean method (#1107 follow-up). +-- +-- The method every pulse day-mean reader uses, switched only by the admin +-- apply step after a dry run. `hour_mean_v1` is the behaviour up to v1.42. + +-- AlterTable +ALTER TABLE "app_settings" + ADD COLUMN "pulse_day_mean_method" TEXT NOT NULL DEFAULT 'hour_mean_v1', + ADD COLUMN "pulse_day_mean_applied_at" TIMESTAMP(3), + ADD COLUMN "pulse_day_mean_dry_run_json" JSONB; + +ALTER TABLE "app_settings" + ADD CONSTRAINT "app_settings_pulse_day_mean_method_check" + CHECK ("pulse_day_mean_method" IN ('hour_mean_v1', 'weighted_hour_mean_v2')); diff --git a/prisma/migrations/0408_intraday_hour_buckets/migration.sql b/prisma/migrations/0408_intraday_hour_buckets/migration.sql new file mode 100644 index 000000000..dd38defbc --- /dev/null +++ b/prisma/migrations/0408_intraday_hour_buckets/migration.sql @@ -0,0 +1,16 @@ +-- Hour buckets for summed intraday types (ios #77). +-- +-- A day of steps, active energy, distance or flights can be built from the +-- hourly `stats::T` rows the app posts instead of the fold +-- of single samples. The hour increments live beside the running totals; +-- `basis` says which source built the profile. + +-- AlterTable +ALTER TABLE "intraday_cumulative_profiles" + ADD COLUMN "hourly_increments" DOUBLE PRECISION[], + ADD COLUMN "basis" TEXT NOT NULL DEFAULT 'PER_SAMPLE', + ADD COLUMN "bucket_count" INTEGER; + +ALTER TABLE "intraday_cumulative_profiles" + ADD CONSTRAINT "intraday_cumulative_profiles_basis_check" + CHECK ("basis" IN ('PER_SAMPLE', 'HOUR_BUCKETS')); diff --git a/prisma/migrations/0409_drop_plaintext_location/migration.sql b/prisma/migrations/0409_drop_plaintext_location/migration.sql new file mode 100644 index 000000000..af7f5f4fd --- /dev/null +++ b/prisma/migrations/0409_drop_plaintext_location/migration.sql @@ -0,0 +1,50 @@ +-- Drop the plaintext location columns (#615 follow-up). +-- +-- Since v1.42 every location is sealed (`users.home_location_encrypted`, +-- `environment_contexts.location_encrypted`, +-- `environment_travel_locations.location_encrypted`), and the free-text +-- encryption backfill moved the plaintext into the sealed copy and cleared +-- it. This migration removes the plaintext columns and the partial indexes +-- from 0382 that served the backfill. +-- +-- It refuses to run while any row still holds a plaintext location: such a +-- row has not been sealed yet, and dropping the column would lose it. Start +-- the previous release, let the backfill finish (the three counts below must +-- all be 0), then upgrade again. +DO $$ +DECLARE + home_rows BIGINT; + travel_rows BIGINT; + day_rows BIGINT; +BEGIN + SELECT count(*) INTO home_rows FROM "users" WHERE "home_lat" IS NOT NULL; + SELECT count(*) INTO travel_rows FROM "environment_travel_locations" WHERE "lat" IS NOT NULL; + SELECT count(*) INTO day_rows FROM "environment_contexts" WHERE "lat" IS NOT NULL; + IF home_rows > 0 OR travel_rows > 0 OR day_rows > 0 THEN + RAISE EXCEPTION 'HealthLog migration 0409 stopped: % home location(s), % location period(s) and % daily location(s) are still stored in plaintext. Run the previous release until its free-text encryption backfill reports zero remaining rows, then upgrade again. Nothing was changed.', home_rows, travel_rows, day_rows; + END IF; +END +$$; + +-- DropIndex +DROP INDEX IF EXISTS "users_home_location_plain_idx"; +DROP INDEX IF EXISTS "environment_travel_locations_plain_location_idx"; +DROP INDEX IF EXISTS "environment_contexts_plain_location_idx"; + +-- AlterTable +ALTER TABLE "users" + DROP COLUMN "home_lat", + DROP COLUMN "home_lon", + DROP COLUMN "home_label"; + +-- AlterTable +ALTER TABLE "environment_contexts" + DROP COLUMN "lat", + DROP COLUMN "lon", + DROP COLUMN "location_label"; + +-- AlterTable +ALTER TABLE "environment_travel_locations" + DROP COLUMN "lat", + DROP COLUMN "lon", + DROP COLUMN "label"; diff --git a/prisma/migrations/0410_relation_links/migration.sql b/prisma/migrations/0410_relation_links/migration.sql new file mode 100644 index 000000000..ba4e674cc --- /dev/null +++ b/prisma/migrations/0410_relation_links/migration.sql @@ -0,0 +1,151 @@ +-- Relation links that close the remaining pairs of the relation matrix. +-- +-- Lab result and document, visit and medication, visit and vaccination, +-- medication and document, medication and condition (indication). Same shape +-- as `encounter_lab_links`: both ends cascade, the pair is unique. + +-- CreateTable +CREATE TABLE "lab_result_document_links" ( + "id" TEXT NOT NULL, + "lab_result_id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "lab_result_document_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encounter_medication_links" ( + "id" TEXT NOT NULL, + "encounter_id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "encounter_medication_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encounter_vaccination_links" ( + "id" TEXT NOT NULL, + "encounter_id" TEXT NOT NULL, + "vaccination_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "encounter_vaccination_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "medication_document_links" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "medication_document_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "medication_condition_links" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "episode_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "medication_condition_links_pkey" PRIMARY KEY ("id") +); + + +-- CreateIndex +CREATE INDEX "lab_result_document_links_document_id_idx" ON "lab_result_document_links"("document_id"); + +-- CreateIndex +CREATE INDEX "lab_result_document_links_user_id_idx" ON "lab_result_document_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "lab_result_document_links_lab_result_id_document_id_key" ON "lab_result_document_links"("lab_result_id", "document_id"); + +-- CreateIndex +CREATE INDEX "encounter_medication_links_medication_id_idx" ON "encounter_medication_links"("medication_id"); + +-- CreateIndex +CREATE INDEX "encounter_medication_links_user_id_idx" ON "encounter_medication_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "encounter_medication_links_encounter_id_medication_id_key" ON "encounter_medication_links"("encounter_id", "medication_id"); + +-- CreateIndex +CREATE INDEX "encounter_vaccination_links_vaccination_id_idx" ON "encounter_vaccination_links"("vaccination_id"); + +-- CreateIndex +CREATE INDEX "encounter_vaccination_links_user_id_idx" ON "encounter_vaccination_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "encounter_vaccination_links_encounter_id_vaccination_id_key" ON "encounter_vaccination_links"("encounter_id", "vaccination_id"); + +-- CreateIndex +CREATE INDEX "medication_document_links_document_id_idx" ON "medication_document_links"("document_id"); + +-- CreateIndex +CREATE INDEX "medication_document_links_user_id_idx" ON "medication_document_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "medication_document_links_medication_id_document_id_key" ON "medication_document_links"("medication_id", "document_id"); + +-- CreateIndex +CREATE INDEX "medication_condition_links_episode_id_idx" ON "medication_condition_links"("episode_id"); + +-- CreateIndex +CREATE INDEX "medication_condition_links_user_id_idx" ON "medication_condition_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "medication_condition_links_medication_id_episode_id_key" ON "medication_condition_links"("medication_id", "episode_id"); + +-- AddForeignKey +ALTER TABLE "lab_result_document_links" ADD CONSTRAINT "lab_result_document_links_lab_result_id_fkey" FOREIGN KEY ("lab_result_id") REFERENCES "lab_results"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lab_result_document_links" ADD CONSTRAINT "lab_result_document_links_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lab_result_document_links" ADD CONSTRAINT "lab_result_document_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_medication_links" ADD CONSTRAINT "encounter_medication_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_medication_links" ADD CONSTRAINT "encounter_medication_links_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_medication_links" ADD CONSTRAINT "encounter_medication_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_vaccination_links" ADD CONSTRAINT "encounter_vaccination_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_vaccination_links" ADD CONSTRAINT "encounter_vaccination_links_vaccination_id_fkey" FOREIGN KEY ("vaccination_id") REFERENCES "vaccination_records"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_vaccination_links" ADD CONSTRAINT "encounter_vaccination_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_document_links" ADD CONSTRAINT "medication_document_links_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_document_links" ADD CONSTRAINT "medication_document_links_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_document_links" ADD CONSTRAINT "medication_document_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_condition_links" ADD CONSTRAINT "medication_condition_links_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_condition_links" ADD CONSTRAINT "medication_condition_links_episode_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "illness_episodes"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "medication_condition_links" ADD CONSTRAINT "medication_condition_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f78946ab2..6328b220d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,9 +94,6 @@ model User { // without a home set) simply has no environment rows. `homeTimezone` anchors // the day-key an observation is stored under (it can differ from the display // `timezone` above for an account that tracks weather for another locale). - homeLat Float? @map("home_lat") - homeLon Float? @map("home_lon") - homeLabel String? @map("home_label") homeTimezone String? @map("home_timezone") // v1.25 — effective-from instant for the home location. Stamped the moment a // home is set/updated. Day resolution attributes a past day to home ONLY when @@ -105,9 +102,8 @@ model User { // dated location period instead. Backfill defaults its start to this instant. homeSince DateTime? @map("home_since") /// v1.42 — sealed home location (lat, lon, label) - /// under AAD `healthlog/environment-location/v1`. Replaces the plaintext - /// `homeLat` / `homeLon` / `homeLabel` once the free-text encryption backfill - /// reports zero remaining rows; the plaintext columns drop in v1.43. + /// under AAD `healthlog/environment-location/v1`. The only copy since + /// v1.43: migration 0409 dropped the plaintext columns it replaced. homeLocationEncrypted Bytes? @map("home_location_encrypted") /// v1.42 — per-user switch for the air-quality and /// pollen part of the environment module. On by default; off means the @@ -237,6 +233,20 @@ model User { nightscoutTokenEncrypted String? @map("nightscout_token_encrypted") nightscoutAllowPrivateHost Boolean @default(false) @map("nightscout_allow_private_host") + // v1.43 — Dawarich movement context (per-user, encrypted at app level, part + // of the `environment` module). Base URL and API key sealed like the + // Nightscout pair; `dawarichPrefsEncrypted` seals + // `{ homePlaceId, homeCountry, dismissedSuggestionIds }`. Same SSRF floor: + // a private host needs `DAWARICH_PRIVATE_ORIGINS` or this switch. + /// @pending-consumer(#v143-B7) Sealed Dawarich base URL. + dawarichUrlEncrypted String? @map("dawarich_url_encrypted") + /// @pending-consumer(#v143-B7) Sealed Dawarich API key. + dawarichApiKeyEncrypted String? @map("dawarich_api_key_encrypted") + /// @pending-consumer(#v143-B7) Sealed home place id, home country and dismissed travel suggestions. + dawarichPrefsEncrypted Bytes? @map("dawarich_prefs_encrypted") + /// @pending-consumer(#v143-B7) Opt this Dawarich instance past the public-host requirement. + dawarichAllowPrivateHost Boolean @default(false) @map("dawarich_allow_private_host") + // v1.17.0 — Polar OAuth credentials (F4, per-user, encrypted at app level). // Mirrors the WHOOP credential columns: the per-app authorized-user cap on // Polar AccessLink makes a single shared app unworkable for a multi-operator @@ -612,6 +622,14 @@ model User { // `useAuth()` hook. disableCoach Boolean @default(false) @map("disable_coach") + /// @pending-consumer(#v143-B1) The account's own AI usage level (#1217). + /// NULL follows the instance default (`AppSettings.aiUsageDefaultLevel`); + /// a stored level above `AppSettings.aiUsageMaxLevel` is kept but capped + /// at read time. Written by `PUT /api/auth/me/ai-usage`, resolved in + /// `src/lib/ai/usage-level.ts` and published as `ai.usage` on + /// `GET /api/auth/me`. + aiUsageLevel AiUsageLevel? @map("ai_usage_level") + // v1.18.0 — module enable/disable foundation. // // A DISABLED allowlist for the optional ("secondary") domains. The @@ -1017,6 +1035,18 @@ model User { // move, a birth, a new job. Shared under the `profile` domain; never sent to // a model. lifeEvents LifeEvent[] + // v1.43 — biomarker aliases and the merge ledger (D1223), supplement + // composition, Dawarich daily values, and the relation links that close + // the remaining pairs of the relation matrix. + biomarkerAliases BiomarkerAlias[] + biomarkerMerges BiomarkerMerge[] + medicationIngredients MedicationIngredient[] + dawarichDays DawarichDay[] + labResultDocumentLinks LabResultDocumentLink[] + encounterMedicationLinks EncounterMedicationLink[] + encounterVaccinationLinks EncounterVaccinationLink[] + medicationDocumentLinks MedicationDocumentLink[] + medicationConditionLinks MedicationConditionLink[] measurementFoldRepair MeasurementFoldRepair? // v1.7.0 profile — optional patient-identity fields surfaced on the @@ -1120,20 +1150,9 @@ model EnvironmentContext { /// YYYY-MM-DD anchored to the resolved location's timezone. date String - /// Coarse resolved location for the day (lat/lon rounded to 1 decimal, about - /// 11 km, + label + the source that won precedence). Nullable since v1.42: - /// the sealed copy in `locationEncrypted` replaces the plaintext once the - /// encryption backfill has run; the plaintext columns drop in v1.43. - lat Float? - lon Float? - /// @internal: human-readable label of the resolved location, stored as - /// operator/debug provenance beside the coordinates; surfaces select the - /// numeric and weather columns only, never this label. The backup selects it - /// (`src/lib/export/environment-backup.ts`) with the rest of the row: a - /// restored reading has to keep saying where it was read. - locationLabel String? @map("location_label") - /// v1.42 — sealed (lat, lon, label) for the day, - /// AAD `healthlog/environment-location/v1`. + /// v1.42 — sealed coarse resolved location for the day (lat/lon rounded to + /// 1 decimal, about 11 km, + label), AAD `healthlog/environment-location/v1`. + /// The only copy since v1.43: migration 0409 dropped the plaintext columns. locationEncrypted Bytes? @map("location_encrypted") source EnvironmentLocationSource @@ -1219,8 +1238,7 @@ model EnvironmentContext { @@unique([userId, date]) @@index([userId, date]) // `environment_contexts_aq_gap_idx` (user_id, date) WHERE aq_fetched_at IS - // NULL and `environment_contexts_plain_location_idx` (id) WHERE lat IS NOT - // NULL are partial indexes from migrations 0381 / 0382 (raw SQL only). + // NULL is a partial index from migration 0381 (raw SQL only). @@map("environment_contexts") } @@ -1241,22 +1259,15 @@ model EnvironmentTravelLocation { startDate String @map("start_date") endDate String @map("end_date") - /// Plaintext coarse location. Nullable since v1.42: the sealed copy in - /// `locationEncrypted` replaces it once the encryption backfill has run; - /// the plaintext columns drop in v1.43. - lat Float? - lon Float? - label String? - /// v1.42 — sealed (lat, lon, label) for the - /// period, AAD `healthlog/environment-location/v1`. - locationEncrypted Bytes? @map("location_encrypted") + /// v1.42 — sealed coarse location (lat, lon, label) for the period, AAD + /// `healthlog/environment-location/v1`. The only copy since v1.43: + /// migration 0409 dropped the plaintext columns. + locationEncrypted Bytes? @map("location_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@index([userId, startDate, endDate]) - // `environment_travel_locations_plain_location_idx` (id) WHERE lat IS NOT - // NULL is a partial index from migration 0382 (raw SQL only). @@map("environment_travel_locations") } @@ -1701,6 +1712,13 @@ enum AssessmentInstrument { GAD7 WHO5 SCI + /// v1.43 (#614) — AUDIT-C, the alcohol section of the life circumstances. + /// Stored here for the scoring, encryption and backup it shares with the + /// screeners, but never served by `/api/mental-health/assessments` (a + /// closed decoder in older app builds): read through + /// `GET /api/anamnesis/life-circumstances`, written through + /// `POST /api/anamnesis/audit-c`. + AUDIT_C @@map("assessment_instrument") } @@ -2316,6 +2334,17 @@ model IntradayCumulativeProfile { /// two rows is not a shape; the baseline reader uses this to refuse such /// a day rather than let it drag the median. sampleCount Int @map("sample_count") + /// @pending-consumer(#v143-B9) v1.43 (#77) — per-hour increments posted as + /// `stats::T` rows, 24 slots in canonical units. Empty + /// for a profile folded from per-sample rows. + hourlyIncrements Float[] @map("hourly_increments") + /// @pending-consumer(#v143-B9) What `hourlyCumulative` was built from: + /// `PER_SAMPLE` (the fold of raw rows) or `HOUR_BUCKETS` (the posted hour + /// rows). CHECK in migration 0408. + basis String @default("PER_SAMPLE") + /// @pending-consumer(#v143-B9) How many hour buckets backed a + /// `HOUR_BUCKETS` profile; NULL for `PER_SAMPLE`. + bucketCount Int? @map("bucket_count") /// The IANA zone the day was bucketed in. A user who later moves zones /// leaves behind profiles cut on the old clock; carrying the zone lets /// the reader drop the mismatched days instead of silently comparing @@ -2898,6 +2927,12 @@ model Medication { schedules MedicationSchedule[] intakeEvents MedicationIntakeEvent[] + /// v1.43 — composition per unit (supplements), and the relation links to + /// visits, documents and conditions (indication). + ingredients MedicationIngredient[] + encounterLinks EncounterMedicationLink[] + documentLinks MedicationDocumentLink[] + conditionLinks MedicationConditionLink[] intakeImportJobs MedicationIntakeImportJob[] phaseConfig ReminderPhaseConfig? telegramMessages TelegramReminderMessage[] @@ -5227,6 +5262,19 @@ model AppSettings { assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled") assistantDocumentAiEnabled Boolean @default(true) @map("assistant_document_ai_enabled") + // v1.43 — the operator's AI usage bounds (#1217). `aiUsageMaxLevel` is the + // highest level an account may choose, `aiUsageDefaultLevel` the level an + // account without its own choice runs at (never above the ceiling), + // `aiSyncRegenGapHours` the minimum gap between status-card regenerations + // after a sync at `standard` (NULL = env `AI_SYNC_REGEN_GAP_HOURS`, else 6). + // Edited through `PUT /api/admin/settings/assistant-flags` (`aiUsage`). + /// @pending-consumer(#v143-B1) Instance ceiling for the per-account AI usage level. + aiUsageMaxLevel AiUsageLevel @default(STANDARD) @map("ai_usage_max_level") + /// @pending-consumer(#v143-B1) Level for accounts without their own choice. + aiUsageDefaultLevel AiUsageLevel @default(STANDARD) @map("ai_usage_default_level") + /// @pending-consumer(#v143-B1) Sync regeneration gap in hours (1..48); NULL = env or 6. + aiSyncRegenGapHours Int? @map("ai_sync_regen_gap_hours") + // v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches // provider-side reasoning off for every surface (the Coach and the three // background jobs that use it); `aiReasoningMaxEffort` caps the level a @@ -5283,6 +5331,17 @@ model AppSettings { /// finding of the earlier engine and so earns the one-time note. correlationSeasonalEngineSince DateTime? @map("correlation_seasonal_engine_since") + // v1.43 — pulse day-mean method (#1107 follow-up). `hour_mean_v1` weighs + // every hour alike; `weighted_hour_mean_v2` weighs an hour by + // `min(1, n / 3)`. Switched only by the admin apply step after a dry run; + // the dry-run result is the only thing a dry run writes. + /// @pending-consumer(#v143-B8) Active pulse day-mean method (CHECK in migration 0407). + pulseDayMeanMethod String @default("hour_mean_v1") @map("pulse_day_mean_method") + /// @pending-consumer(#v143-B8) When the admin applied the current method. + pulseDayMeanAppliedAt DateTime? @map("pulse_day_mean_applied_at") + /// @pending-consumer(#v143-B8) Last dry-run status and result (account labels only, no ids). + pulseDayMeanDryRunJson Json? @map("pulse_day_mean_dry_run_json") + @@map("app_settings") } @@ -6250,9 +6309,10 @@ 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) - biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull) encounterLinks EncounterLabLink[] + documentLinks LabResultDocumentLink[] @@index([userId, analyte, takenAt]) @@index([userId, deletedAt]) @@ -6309,6 +6369,10 @@ model Biomarker { /// v1.28 — medications the user has pinned to this biomarker as their /// efficacy target. SetNull on delete keeps the override rows as orphans. efficacyTargets MedicationEfficacyTarget[] + /// v1.43 — other spellings that resolve to this marker, and the merges that + /// folded other markers into it. + aliases BiomarkerAlias[] + mergesInto BiomarkerMerge[] @@unique([userId, name]) @@index([userId]) @@ -6962,6 +7026,12 @@ model UserHealthProfile { emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted") emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted") emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted") + /// @pending-consumer(#v143-B6) v1.43 (#614) — "still true" confirmations of the life + /// circumstance sections: `{ "
": "" }`. Written by + /// `POST /api/anamnesis/life-circumstances/confirm`, read as + /// `lastConfirmedAt` / `reviewDue` by the life-circumstances read route. + /// Plaintext: section keys and timestamps only, never an answer. + lifeConfirmedJson Json? @map("life_confirmed_json") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -7006,6 +7076,18 @@ enum AdvanceDirectiveStatus { @@map("advance_directive_status") } +/// v1.43 — how much background AI work an account asks for (#1217): status +/// card regeneration cadence, snapshot ceiling, background reasoning, briefing +/// length and automatic document reading. Wire form is lower case +/// (`frugal` / `standard` / `full`); the order is the ceiling order. +enum AiUsageLevel { + FRUGAL + STANDARD + FULL + + @@map("ai_usage_level") +} + /// Sections of the health profile that may enter AI prompt context. This is an /// inclusion list: a section absent from the array is neither decrypted nor /// assembled for Coach or comprehensive briefings. @@ -7018,6 +7100,14 @@ enum HealthProfileAiSection { SMOKING_STATUS ALCOHOL_PATTERN SHIFT_SCHEDULE + /// v1.43 (#614) — life-circumstance sections. Never in the column default + /// and never listed by `GET /api/coach/about-me` (a closed decoder in older + /// app builds); switched through + /// `PATCH /api/anamnesis/life-circumstances/ai-sections`. + ACTIVITY + WORK + HABITS + GOALS @@map("health_profile_ai_section") } @@ -7029,6 +7119,16 @@ enum HealthProfileFactKind { SMOKING_STATUS ALCOHOL_PATTERN SHIFT_SCHEDULE + /// v1.43 (#614) — life-circumstance facts. The value is a sealed JSON object + /// with `schema: 1`, one Zod schema per kind. `GET /api/anamnesis/facts` + /// never returns these (a closed decoder in older app builds); they are read + /// through `GET /api/anamnesis/life-circumstances`. + TOBACCO_DETAIL + ACTIVITY_HABIT + WORK_CONTEXT + FREE_WEEKDAYS + HABITS + HEALTH_GOALS @@map("health_profile_fact_kind") } @@ -8175,15 +8275,16 @@ model IllnessEpisode { /// 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) /// Parent condition for a flare/recurrence (self-relation). - parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) - flares IllnessEpisode[] @relation("IllnessFlares") - dayLogs IllnessDayLog[] - documentLinks DocumentConditionLink[] - encounterLinks EncounterConditionLink[] + parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) + flares IllnessEpisode[] @relation("IllnessFlares") + dayLogs IllnessDayLog[] + documentLinks DocumentConditionLink[] + encounterLinks EncounterConditionLink[] + medicationLinks MedicationConditionLink[] /// Symptom events the person filed against this episode while it was open. - symptomEvents SymptomEvent[] + symptomEvents SymptomEvent[] @@index([userId, deletedAt]) @@index([userId, onsetAt]) @@ -8762,6 +8863,9 @@ model InboundDocument { user User @relation(fields: [userId], references: [id], onDelete: Cascade) encounterLinks EncounterDocumentLink[] + /// v1.43 — lab results and medications filed against this document. + labResultLinks LabResultDocumentLink[] + medicationLinks MedicationDocumentLink[] /// Vaccination memberships (m:n via `VaccinationDocumentLink`): a scanned /// Impfpass page carries many doses, and a dose may be evidenced by more /// than one page. @@ -9215,15 +9319,17 @@ model Encounter { /// 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) - practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull) - reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull) - documentLinks EncounterDocumentLink[] - labLinks EncounterLabLink[] - conditionLinks EncounterConditionLink[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull) + reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull) + documentLinks EncounterDocumentLink[] + labLinks EncounterLabLink[] + conditionLinks EncounterConditionLink[] + medicationLinks EncounterMedicationLink[] + vaccinationLinks EncounterVaccinationLink[] /// Doses administered at this visit. SetNull on the far side: deleting the /// visit leaves the immunization history intact. - vaccinations VaccinationRecord[] + vaccinations VaccinationRecord[] @@index([userId, deletedAt, occurredAt]) @@index([userId, status, occurredAt]) @@ -9477,12 +9583,13 @@ model VaccinationRecord { /// 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) - practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull) - encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: SetNull) - reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull) - customVaccine CustomVaccine? @relation(fields: [customVaccineId], references: [id], onDelete: SetNull) - documentLinks VaccinationDocumentLink[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull) + encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: SetNull) + reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull) + customVaccine CustomVaccine? @relation(fields: [customVaccineId], references: [id], onDelete: SetNull) + documentLinks VaccinationDocumentLink[] + encounterLinks EncounterVaccinationLink[] @@index([userId, deletedAt, occurredAt]) @@index([userId, antigenSlug, occurredAt]) @@ -9635,6 +9742,9 @@ enum DocumentAiRunKind { /// Structured facts read out of a stored document, staged PENDING for the /// review step. The confirm route stays the only write into the record. DOCUMENT_EXTRACT + /// v1.43 — a supplement label read into a proposed composition. Nothing is + /// stored until the person saves it through the ingredients route. + SUPPLEMENT_LABEL_EXTRACT @@map("document_ai_run_kind") } @@ -9784,3 +9894,209 @@ model MeasurementFoldRepair { @@map("measurement_fold_repairs") } + +// ─── v1.43 contract tables ─────────────────────────────────── + +/// v1.43 (D1223) — another spelling of a biomarker on one account: a name a +/// merge folded in, a name chosen at import, or one typed on the marker. The +/// resolver (`src/lib/labs/biomarker-resolve.ts`) matches a printed name +/// against `nameFolded` after the marker name and before the catalogue. +model BiomarkerAlias { + id String @id @default(cuid()) + userId String @map("user_id") + biomarkerId String @map("biomarker_id") + /// @pending-consumer(#v143-B3) The alias as written, shown on the marker. + name String @db.VarChar(120) + /// @pending-consumer(#v143-B3) `foldForMatch(name)`, the match key; unique per account. + nameFolded String @map("name_folded") @db.VarChar(120) + /// @pending-consumer(#v143-B3) `MERGE`, `IMPORT_CHOICE` or `MANUAL` (CHECK in migration 0403). + source String + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + biomarker Biomarker @relation(fields: [biomarkerId], references: [id], onDelete: Cascade) + + @@unique([userId, nameFolded]) + @@index([biomarkerId]) + @@map("biomarker_aliases") +} + +/// v1.43 (D1223) — one source marker folded into a target. The source row is +/// gone after the merge; this ledger is what answers `GET /api/biomarkers/{id}` +/// for a merged id with `meta.mergedInto`, and what a later split would need. +model BiomarkerMerge { + id String @id @default(cuid()) + userId String @map("user_id") + /// @pending-consumer(#v143-B3) The marker the source was folded into. + targetBiomarkerId String @map("target_biomarker_id") + /// @pending-consumer(#v143-B3) Id of the deleted source marker (no FK: the row is gone). + sourceBiomarkerId String @map("source_biomarker_id") + /// @pending-consumer(#v143-B3) The source marker's name at merge time. + sourceName String @map("source_name") @db.VarChar(120) + /// @pending-consumer(#v143-B3) How many readings moved from the source. + readingCount Int @map("reading_count") + /// @pending-consumer(#v143-B3) When the merge ran. + mergedAt DateTime @default(now()) @map("merged_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + target Biomarker @relation(fields: [targetBiomarkerId], references: [id], onDelete: Cascade) + + @@index([userId, sourceBiomarkerId]) + @@index([targetBiomarkerId]) + @@map("biomarker_merges") +} + +/// v1.43 — what one unit (tablet, capsule) of a medication contains, in the +/// nutrient catalogue's canonical unit. Every taken dose contributes +/// `amountPerUnit × unitsPerDose` to that day's `NutrientIntakeDay` row with +/// `source = "SUPPLEMENT"`. A changed formula is a new medication. +model MedicationIngredient { + id String @id @default(cuid()) + userId String @map("user_id") + medicationId String @map("medication_id") + /// @pending-consumer(#v143-B4) Nutrient catalogue code (`src/lib/nutrients/catalog.ts`). + nutrient String @db.VarChar(40) + /// @pending-consumer(#v143-B4) Amount per unit in the canonical unit (> 0, CHECK in migration 0404). + amountPerUnit Float @map("amount_per_unit") + /// @pending-consumer(#v143-B4) The catalogue's canonical unit for `nutrient`. + unit String @db.VarChar(8) + /// @pending-consumer(#v143-B4) The amount as entered, when it was entered in another input unit. + enteredAmount Float? @map("entered_amount") + /// @pending-consumer(#v143-B4) The input unit it was entered in (e.g. `IU`). + enteredUnit String? @map("entered_unit") @db.VarChar(8) + /// @pending-consumer(#v143-B4) `MANUAL` or `LABEL_AI` (CHECK in migration 0404). + source String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + + @@unique([medicationId, nutrient]) + @@index([userId]) + @@map("medication_ingredients") +} + +/// v1.43 — one local day of Dawarich movement context. Daily values only: +/// no coordinate, no place name, no visit is ever stored. The country code is +/// sealed (AAD as the environment location). Part of the `environment` module. +model DawarichDay { + id String @id @default(cuid()) + userId String @map("user_id") + /// Local calendar day (`YYYY-MM-DD`) in the account's timezone. + date String @db.VarChar(10) + /// @pending-consumer(#v143-B7) Timeline `summary.total_distance`, metres. + totalDistanceM Int? @map("total_distance_m") + /// @pending-consumer(#v143-B7) Timeline `summary.mode_distances.driving`, metres. + drivingDistanceM Int? @map("driving_distance_m") + /// @pending-consumer(#v143-B7) Timeline `summary.mode_distances.train`, metres. + trainDistanceM Int? @map("train_distance_m") + /// @pending-consumer(#v143-B7) Timeline `summary.places_visited`. + placesVisited Int? @map("places_visited") + /// @pending-consumer(#v143-B7) Minutes away from the home place; NULL = no visit coverage that day. + awayFromHomeMinutes Int? @map("away_from_home_minutes") + /// @pending-consumer(#v143-B7) Sealed ISO-3166 alpha-2 country of the day (`/residency`). + countryCodeEncrypted Bytes? @map("country_code_encrypted") + /// @pending-consumer(#v143-B7) Country differs from the home country. + isTravelDay Boolean? @map("is_travel_day") + /// @pending-consumer(#v143-B7) When the day was last fetched from Dawarich. + fetchedAt DateTime @map("fetched_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, date]) + @@map("dawarich_days") +} + +/// v1.43 — lab result ↔ document (the report it was read from). A scan commit +/// with a `documentId` files the link itself. +model LabResultDocumentLink { + id String @id @default(cuid()) + labResultId String @map("lab_result_id") + documentId String @map("document_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + labResult LabResult @relation(fields: [labResultId], references: [id], onDelete: Cascade) + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([labResultId, documentId]) + @@index([documentId]) + @@index([userId]) + @@map("lab_result_document_links") +} + +/// v1.43 — visit ↔ medication (prescribed, changed or reviewed at the visit). +model EncounterMedicationLink { + id String @id @default(cuid()) + encounterId String @map("encounter_id") + medicationId String @map("medication_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade) + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([encounterId, medicationId]) + @@index([medicationId]) + @@index([userId]) + @@map("encounter_medication_links") +} + +/// v1.43 — visit ↔ vaccination dose (given at the visit). +model EncounterVaccinationLink { + id String @id @default(cuid()) + encounterId String @map("encounter_id") + vaccinationId String @map("vaccination_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade) + vaccination VaccinationRecord @relation(fields: [vaccinationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([encounterId, vaccinationId]) + @@index([vaccinationId]) + @@index([userId]) + @@map("encounter_vaccination_links") +} + +/// v1.43 — medication ↔ document (a prescription, a package insert, a plan). +model MedicationDocumentLink { + id String @id @default(cuid()) + medicationId String @map("medication_id") + documentId String @map("document_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([medicationId, documentId]) + @@index([documentId]) + @@index([userId]) + @@map("medication_document_links") +} + +/// v1.43 — medication ↔ condition episode (the indication). +model MedicationConditionLink { + id String @id @default(cuid()) + medicationId String @map("medication_id") + episodeId String @map("episode_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([medicationId, episodeId]) + @@index([episodeId]) + @@index([userId]) + @@map("medication_condition_links") +} diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index acbab8c25..99f5f9f3b 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -306,6 +306,8 @@ async function main() { "googleHealthClientSecretEncrypted", "nightscoutUrlEncrypted", "nightscoutTokenEncrypted", + "dawarichUrlEncrypted", + "dawarichApiKeyEncrypted", "polarAccessTokenEncrypted", "polarUserIdEncrypted", "polarClientIdEncrypted", @@ -825,8 +827,8 @@ async function main() { // ───── v1.42 environment locations (Bytes, binary2, batched) ───── // The sealed home, each dated location period and each day's resolved - // location, all under the environment-location label. NULL on a row the - // encryption backfill has not reached yet, which the walk skips. + // location, all under the environment-location label. NULL on a row + // without a location, which the walk skips. results.push( await rotateRegistryColumn("User", "homeLocationEncrypted", { user: prisma.user, @@ -847,6 +849,20 @@ async function main() { } as unknown as CorpusClient), ); + // ───── v1.43 Dawarich context (Bytes, binary2, batched) ───── + // The sealed connection preferences on the user row and each stored day's + // country code, each under its own label. NULL rows are skipped. + results.push( + await rotateRegistryColumn("User", "dawarichPrefsEncrypted", { + user: prisma.user, + } as unknown as CorpusClient), + ); + results.push( + await rotateRegistryColumn("DawarichDay", "countryCodeEncrypted", { + dawarichDay: prisma.dawarichDay, + } as unknown as CorpusClient), + ); + // ───── v1.39.3 custom-metric reading note (Bytes column) ───── // NULL on a reading without a note, and on a row the free-text backfill has // not reached yet; `rotateBytesColumn` skips both. diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index ae3061820..6e13de3b0 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -499,13 +499,7 @@ async function seed() { // start of a chart together. Every other tile stays one switch away in // the layout settings; its data is seeded either way. Writing the layout // verbatim keeps the demo's home page stable across default tweaks. - const SHOWCASE_TILE_IDS = new Set([ - "weight", - "bp", - "pulse", - "hrv", - "mood", - ]); + const SHOWCASE_TILE_IDS = new Set(["weight", "bp", "pulse", "hrv", "mood"]); const demoDashboardLayout = serializeDashboardLayout({ ...DEFAULT_DASHBOARD_LAYOUT, widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => ({ @@ -2798,12 +2792,11 @@ async function seed() { } // The timeline module is opt-in; the demo shows it. The home location - // is sealed the way the settings route writes it (readable columns null). + // is sealed the way the settings route writes it. const homeSince = daysAgoAt(HISTORY_DAYS, 12); await client.query( `UPDATE users SET module_preferences_json = $2, - home_lat = NULL, home_lon = NULL, home_label = NULL, home_location_encrypted = $3, home_timezone = 'Europe/Berlin', home_since = $4, @@ -2913,9 +2906,6 @@ async function seed() { cuid(), userId, key, - null, - null, - null, away ? sealedHoliday : sealedHome, away ? "TRAVEL" : "HOME", tempMin, @@ -2964,9 +2954,6 @@ async function seed() { "id", "user_id", "date", - "lat", - "lon", - "location_label", "location_encrypted", "source", "temp_min", diff --git a/src/__tests__/environment-backup-completeness.test.ts b/src/__tests__/environment-backup-completeness.test.ts index fbc6f5933..725b93225 100644 --- a/src/__tests__/environment-backup-completeness.test.ts +++ b/src/__tests__/environment-backup-completeness.test.ts @@ -43,13 +43,7 @@ const EXCLUDED: Record = { }; /** Columns written through the location helper rather than `row.`. */ -const LOCATION_COLUMNS = new Set([ - "lat", - "lon", - "locationLabel", - "label", - "locationEncrypted", -]); +const LOCATION_COLUMNS = new Set(["locationEncrypted"]); function block(text: string, start: string, end = "\n}"): string { const from = text.indexOf(start); @@ -95,9 +89,10 @@ describe.each([ const selected = selectKeys(selectName); it("parses both ends into a plausible shape", () => { - expect(columns.length).toBeGreaterThan(8); + // A period is seven columns since v1.43 (the readable location dropped). + expect(columns.length).toBeGreaterThan(6); expect(columns).toContain("locationEncrypted"); - expect(selected.length).toBeGreaterThan(5); + expect(selected.length).toBeGreaterThan(4); }); it("the backup select covers every column", () => { diff --git a/src/app/api/environment/backfill/__tests__/route.test.ts b/src/app/api/environment/backfill/__tests__/route.test.ts index 2d1cea45f..c50ee2e94 100644 --- a/src/app/api/environment/backfill/__tests__/route.test.ts +++ b/src/app/api/environment/backfill/__tests__/route.test.ts @@ -61,7 +61,6 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn(async () => ({ - homeLat: null, homeLocationEncrypted: new Uint8Array([1]), homeSince: new Date("2025-01-01T00:00:00Z"), })), diff --git a/src/app/api/environment/backfill/route.ts b/src/app/api/environment/backfill/route.ts index 690a338ea..8e809b193 100644 --- a/src/app/api/environment/backfill/route.ts +++ b/src/app/api/environment/backfill/route.ts @@ -63,11 +63,9 @@ export const POST = apiHandler(async (request: NextRequest) => { const profile = await prisma.user.findUnique({ where: { id: user.id }, - select: { homeLat: true, homeLocationEncrypted: true, homeSince: true }, + select: { homeLocationEncrypted: true, homeSince: true }, }); - // The sealed home (v1.42), or the readable one the encryption backfill has - // not cleared yet. - if (profile?.homeLocationEncrypted == null && profile?.homeLat == null) { + if (profile?.homeLocationEncrypted == null) { return apiError("Set a home location before backfilling.", 409, { errorCode: "environment.no_home", }); diff --git a/src/app/api/environment/home/route.ts b/src/app/api/environment/home/route.ts index 3c6d554ce..82cad0d8f 100644 --- a/src/app/api/environment/home/route.ts +++ b/src/app/api/environment/home/route.ts @@ -57,9 +57,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { where: { id: user.id }, data: { homeLocationEncrypted: sealLocation(home), - homeLat: null, - homeLon: null, - homeLabel: null, homeTimezone: timezone, homeSince, }, diff --git a/src/app/api/environment/route.ts b/src/app/api/environment/route.ts index cb75a7260..9aa424290 100644 --- a/src/app/api/environment/route.ts +++ b/src/app/api/environment/route.ts @@ -48,9 +48,6 @@ export const GET = apiHandler(async () => { prisma.user.findUnique({ where: { id: user.id }, select: { - homeLat: true, - homeLon: true, - homeLabel: true, homeLocationEncrypted: true, homeTimezone: true, homeSince: true, @@ -66,9 +63,6 @@ export const GET = apiHandler(async () => { id: true, startDate: true, endDate: true, - lat: true, - lon: true, - label: true, locationEncrypted: true, }, }), @@ -116,14 +110,7 @@ export const GET = apiHandler(async () => { ]); const homeLocation = profile - ? readLocation({ - sealed: profile.homeLocationEncrypted, - lat: profile.homeLat, - lon: profile.homeLon, - // The label may be absent on a pre-v1.42 home; the answer has - // always allowed a null label there. - label: profile.homeLabel ?? "", - }) + ? readLocation(profile.homeLocationEncrypted) : null; const home = profile && homeLocation @@ -139,12 +126,7 @@ export const GET = apiHandler(async () => { : null; const travel = travelRows.flatMap((row) => { - const location = readLocation({ - sealed: row.locationEncrypted, - lat: row.lat, - lon: row.lon, - label: row.label, - }); + const location = readLocation(row.locationEncrypted); return location ? [ { diff --git a/src/app/api/environment/travel/route.ts b/src/app/api/environment/travel/route.ts index 0f38eda59..9510a740b 100644 --- a/src/app/api/environment/travel/route.ts +++ b/src/app/api/environment/travel/route.ts @@ -57,9 +57,6 @@ export const POST = apiHandler(async (request: NextRequest) => { startDate: entry.startDate, endDate: entry.endDate, locationEncrypted: sealLocation(location), - lat: null, - lon: null, - label: null, }, select: { id: true, startDate: true, endDate: true }, }); diff --git a/src/lib/ai/coach/tools/__tests__/environment-read.test.ts b/src/lib/ai/coach/tools/__tests__/environment-read.test.ts index 3ce7e5f6c..f8c8c5e6e 100644 --- a/src/lib/ai/coach/tools/__tests__/environment-read.test.ts +++ b/src/lib/ai/coach/tools/__tests__/environment-read.test.ts @@ -53,7 +53,8 @@ function storedDay(date: string, extra: Record = {}) { return { date, source: "HOME", - // A row as the database might still hold it; none of this may leave. + // A row carrying location-shaped fields (a mocked row can, the table no + // longer does); none of this may leave. lat: 51.5, lon: 7.2, locationLabel: "Bochum, Germany", diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index eae8b85a8..3eb4aeb08 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -43,6 +43,10 @@ import { computeWeightGoal } from "./weight-goal"; import { DAY_MS, uniqueSources } from "./shared"; import type { DerivedProvenanceSource } from "@/lib/insights/derived/types"; import type { GlucoseUnit } from "@/lib/glucose"; +import { + SCREENER_INSTRUMENTS, + isScreenerInstrument, +} from "@/lib/validations/mental-health"; const HISTORY_OFFSET_DAYS = 14; const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS; @@ -687,20 +691,32 @@ export async function computeUserHealthScore( ), input.modules.mentalHealth ? capture( - db.mentalHealthAssessment.findMany({ - where: { - userId: input.userId, - takenAt: { gte: since }, - deletedAt: null, - }, - orderBy: { takenAt: "asc" }, - select: { - instrument: true, - totalScore: true, - item9Flagged: true, - takenAt: true, - }, - }), + db.mentalHealthAssessment + .findMany({ + where: { + userId: input.userId, + takenAt: { gte: since }, + deletedAt: null, + // The wellbeing screeners only. AUDIT-C shares the table for + // its storage, but it belongs to the life circumstances, not + // to this pillar. + instrument: { in: [...SCREENER_INSTRUMENTS] }, + }, + orderBy: { takenAt: "asc" }, + select: { + instrument: true, + totalScore: true, + item9Flagged: true, + takenAt: true, + }, + }) + .then((rows) => + rows.flatMap((row) => + isScreenerInstrument(row.instrument) + ? [{ ...row, instrument: row.instrument }] + : [], + ), + ), [], "WELLBEING", ) diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 93c5ae7a8..6869e47b6 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -68,6 +68,16 @@ export const DOCUMENT_AI_RUN_RESULT_AAD = "healthlog/document-ai-run-result/v1"; */ export const ENVIRONMENT_LOCATION_AAD = "healthlog/environment-location/v1"; +/** + * The associated-data labels for the Dawarich context (v1.43): the sealed + * connection preferences on the user row (home place id, home country, + * dismissed travel suggestions) and each stored day's country code. Labelled + * like the environment locations, each with its own label so a country code + * never opens as a location or as the preferences. + */ +export const DAWARICH_PREFS_AAD = "healthlog/dawarich-prefs/v1"; +export const DAWARICH_DAY_COUNTRY_AAD = "healthlog/dawarich-day-country/v1"; + export interface EncryptedColumn { /** Prisma model name (PascalCase, as declared in schema.prisma). */ readonly model: string; @@ -149,6 +159,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "User", field: "googleHealthClientSecretEncrypted", kind: "string" }, { model: "User", field: "nightscoutUrlEncrypted", kind: "string" }, { model: "User", field: "nightscoutTokenEncrypted", kind: "string" }, + // v1.43 — Dawarich connection (URL and API key, like the Nightscout pair). + { model: "User", field: "dawarichUrlEncrypted", kind: "string" }, + { model: "User", field: "dawarichApiKeyEncrypted", kind: "string" }, { model: "User", field: "polarAccessTokenEncrypted", kind: "string" }, { model: "User", field: "polarUserIdEncrypted", kind: "string" }, { model: "User", field: "polarClientIdEncrypted", kind: "string" }, @@ -408,8 +421,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // ───── v1.42 environment locations (Bytes, binary2) ───── // The coarse home, each dated location period, and each day's resolved - // location, sealed as (lat, lon, label) under one label. The plaintext - // columns beside them empty as the encryption backfill runs. + // location, sealed as (lat, lon, label) under one label. The only copy since + // v1.43 (migration 0409 dropped the plaintext columns). { model: "User", field: "homeLocationEncrypted", @@ -432,6 +445,25 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ aad: ENVIRONMENT_LOCATION_AAD, }, + // ───── v1.43 Dawarich context (Bytes, binary2) ───── + // The connection preferences (a place id on the Dawarich instance, the home + // country, dismissed travel suggestions) and each day's country code. Daily + // values only; no coordinate or place name is ever stored. + { + model: "User", + field: "dawarichPrefsEncrypted", + kind: "bytes", + codec: "binary2", + aad: DAWARICH_PREFS_AAD, + }, + { + model: "DawarichDay", + field: "countryCodeEncrypted", + kind: "bytes", + codec: "binary2", + aad: DAWARICH_DAY_COUNTRY_AAD, + }, + // ───── v1.38.0 dose free text (Bytes column) ───── // What the person wrote about the dose — a reaction, a sore arm, why the // schedule went the way it did. The Pass's own structured fields stay @@ -583,6 +615,7 @@ export const UPDATED_AT_MODELS: ReadonlySet = new Set([ "CoachPlan", "CoachReminder", "CycleDayLog", + "DawarichDay", "DocumentContentIndex", "DocumentSourceConnection", "DocumentThumbnail", diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index a247fd823..debdb9d14 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -81,6 +81,9 @@ export const WIPE_MODELS = [ "MedicationCategoryLabel", // The courses (v1.40, #1024), before the medications they cascade from. "MedicationCourse", + // v1.43 — what one unit of a supplement contains. Cascades from the + // medication; named before it so the count reads true. + "MedicationIngredient", // ── Mood ──────────────────────────────────────────────────────────────── // The day context, before the entry it hangs off: it cascades either way, @@ -104,9 +107,18 @@ export const WIPE_MODELS = [ // are deleted before any of those, or their per-model counts read zero on an // audit row that claims to say what was removed. The encounter follows its // own links; the address book it points at is a parent and goes last. + // v1.43 adds the remaining relation links (a lab result's document, a + // visit's medications and doses, a medication's documents and conditions); + // they cascade from both ends the same way, so they go first for the same + // reason. "EncounterDocumentLink", "EncounterLabLink", "EncounterConditionLink", + "EncounterMedicationLink", + "EncounterVaccinationLink", + "LabResultDocumentLink", + "MedicationDocumentLink", + "MedicationConditionLink", "Encounter", // ── Vaccinations ──────────────────────────────────────────────────────── @@ -124,6 +136,10 @@ export const WIPE_MODELS = [ // ── Clinical record ───────────────────────────────────────────────────── "MentalHealthAssessment", "LabResult", + // v1.43 — a marker's other spellings and the merges folded into it. Both + // cascade from the marker; named before it so their counts read true. + "BiomarkerAlias", + "BiomarkerMerge", "Biomarker", "CustomMetricEntry", "MenstrualCycle", @@ -150,6 +166,8 @@ export const WIPE_MODELS = [ // ── Context and engagement ────────────────────────────────────────────── "EnvironmentContext", "EnvironmentTravelLocation", + // v1.43 — Dawarich daily movement values. Part of the environment record. + "DawarichDay", "HealthProfileFactRevision", "CorrelationPattern", "UserHealthProfile", @@ -372,9 +390,6 @@ export const USER_RESET = { avatarUpdatedAt: null, // Where the person lives — an environment-context input, and a location - homeLat: null, - homeLon: null, - homeLabel: null, homeTimezone: null, homeSince: null, homeLocationEncrypted: null, @@ -406,6 +421,7 @@ export const USER_RESET = { aiProviderChain: Prisma.DbNull, aiResponseTimeoutSeconds: null, disableCoach: false, + aiUsageLevel: null, coachPrefsJson: Prisma.DbNull, coachLastSeenAt: null, documentsAutoAiRead: false, @@ -432,6 +448,10 @@ export const USER_RESET = { nightscoutUrlEncrypted: null, nightscoutTokenEncrypted: null, nightscoutAllowPrivateHost: false, + dawarichUrlEncrypted: null, + dawarichApiKeyEncrypted: null, + dawarichPrefsEncrypted: null, + dawarichAllowPrivateHost: false, polarAccessTokenEncrypted: null, polarUserIdEncrypted: null, polarClientIdEncrypted: null, diff --git a/src/lib/documents/ai-runs/store.ts b/src/lib/documents/ai-runs/store.ts index 184b5ac5a..09a1b488c 100644 --- a/src/lib/documents/ai-runs/store.ts +++ b/src/lib/documents/ai-runs/store.ts @@ -30,6 +30,7 @@ import { type AiRunParams, type AiRunResult, type DocumentAiRunKindValue, + servedRunKind, } from "./types"; /** The longest a client is told to wait between polls. */ @@ -113,7 +114,7 @@ export async function claimAiRun( return { id: row.id, userId: row.userId, - kind: row.kind, + kind: servedRunKind(row.kind), documentId: row.documentId, params: row.paramsJson as unknown as AiRunParams, input, @@ -252,7 +253,7 @@ export async function readAiRunForUser( : 0; return { id: row.id, - kind: row.kind, + kind: servedRunKind(row.kind), documentId: row.documentId, status: unreadable ? "FAILED" : row.status, result, diff --git a/src/lib/documents/ai-runs/types.ts b/src/lib/documents/ai-runs/types.ts index 2c63c7d92..74a8538b2 100644 --- a/src/lib/documents/ai-runs/types.ts +++ b/src/lib/documents/ai-runs/types.ts @@ -25,6 +25,19 @@ export const DOCUMENT_AI_RUN_KINDS = [ ] as const; export type DocumentAiRunKindValue = (typeof DOCUMENT_AI_RUN_KINDS)[number]; +/** + * A stored run's kind as one this build can serve. The database enum may + * carry a kind ahead of its worker (a value added with its migration before + * the run that uses it lands); such a row is refused here rather than handed + * to a dispatcher that has no entry for it. + */ +export function servedRunKind(kind: string): DocumentAiRunKindValue { + if ((DOCUMENT_AI_RUN_KINDS as readonly string[]).includes(kind)) { + return kind as DocumentAiRunKindValue; + } + throw new Error(`document AI run kind not served by this build: ${kind}`); +} + /** * The capability each kind of run answers to: the one its queuing route asks, * asked again by the worker for the record before anything leaves. diff --git a/src/lib/environment/__tests__/location-cipher.test.ts b/src/lib/environment/__tests__/location-cipher.test.ts index 0e8727c36..95276f688 100644 --- a/src/lib/environment/__tests__/location-cipher.test.ts +++ b/src/lib/environment/__tests__/location-cipher.test.ts @@ -56,27 +56,23 @@ describe("sealLocation / openLocation", () => { }); describe("readLocation", () => { - it("prefers the sealed value over readable columns", () => { + it("opens the sealed value", () => { const sealed = sealLocation({ lat: 48.1, lon: 11.6, label: "Munich" }); - expect( - readLocation({ sealed, lat: 52.5, lon: 13.4, label: "Berlin" }), - ).toEqual({ lat: 48.1, lon: 11.6, label: "Munich" }); + expect(readLocation(sealed)).toEqual({ + lat: 48.1, + lon: 11.6, + label: "Munich", + }); }); - it("falls back to the readable columns for a row the backfill has not reached", () => { - expect( - readLocation({ sealed: null, lat: 52.5, lon: 13.4, label: "Berlin" }), - ).toEqual({ lat: 52.5, lon: 13.4, label: "Berlin" }); - expect( - readLocation({ sealed: null, lat: 52.5, lon: null, label: "Berlin" }), - ).toBeNull(); + it("reads a row without a sealed value as no location", () => { + expect(readLocation(null)).toBeNull(); + expect(readLocation(new Uint8Array())).toBeNull(); }); - it("reads a value that does not open as no location, never as the readable columns", () => { + it("reads a value that does not open as no location", () => { const sealed = sealLocation({ lat: 48.1, lon: 11.6, label: "Munich" }); vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); - expect( - readLocation({ sealed, lat: 52.5, lon: 13.4, label: "Berlin" }), - ).toBeNull(); + expect(readLocation(sealed)).toBeNull(); }); }); diff --git a/src/lib/environment/__tests__/service-air-quality.test.ts b/src/lib/environment/__tests__/service-air-quality.test.ts index 3d3915d8d..e53eecd0e 100644 --- a/src/lib/environment/__tests__/service-air-quality.test.ts +++ b/src/lib/environment/__tests__/service-air-quality.test.ts @@ -113,9 +113,6 @@ beforeEach(() => { vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); vi.stubEnv("ENCRYPTION_KEY", "d".repeat(64)); db.user = { - homeLat: null, - homeLon: null, - homeLabel: null, homeLocationEncrypted: sealLocation({ lat: 51.48, lon: 7.21, @@ -146,9 +143,6 @@ describe("fetchAndStoreEnvironment", () => { { startDate: "2026-05-02", endDate: "2026-05-03", - lat: null, - lon: null, - label: null, locationEncrypted: damaged, }, ]; @@ -189,10 +183,10 @@ describe("fetchAndStoreEnvironment", () => { expect([call.lat, call.lon]).toEqual([51.5, 7.2]); } const { create } = db.upserts[0]; + // The location rides sealed only; no readable column exists to write. + expect(create).not.toHaveProperty("lat"); + expect(create).not.toHaveProperty("locationLabel"); expect(create).toMatchObject({ - lat: null, - lon: null, - locationLabel: null, source: "HOME", apparentMax: 25, pm25Mean: 9.5, @@ -260,9 +254,6 @@ describe("fetchAndStoreEnvironment", () => { { startDate: "2026-05-01", endDate: "2026-05-02", - lat: null, - lon: null, - label: null, locationEncrypted: sealLocation({ lat: 38.72, lon: -9.14, @@ -290,16 +281,10 @@ describe("fillAirQualityGaps", () => { db.days = [ { date: "2026-05-03", - lat: null, - lon: null, - locationLabel: null, locationEncrypted: sealed, }, { date: "2026-05-02", - lat: null, - lon: null, - locationLabel: null, locationEncrypted: sealed, }, ]; diff --git a/src/lib/environment/air-quality-history.ts b/src/lib/environment/air-quality-history.ts index c192eff28..603292aaa 100644 --- a/src/lib/environment/air-quality-history.ts +++ b/src/lib/environment/air-quality-history.ts @@ -358,9 +358,6 @@ export async function collectAirQualityHistory( select: { date: true, aqFetchedAt: true, - lat: true, - lon: true, - locationLabel: true, locationEncrypted: true, source: true, }, @@ -375,12 +372,7 @@ export async function collectAirQualityHistory( const row = rowByDate.get(date); let location: ResolvedLocation | null; if (row) { - const stored = readLocation({ - sealed: row.locationEncrypted, - lat: row.lat, - lon: row.lon, - label: row.locationLabel, - }); + const stored = readLocation(row.locationEncrypted); location = stored ? { ...stored, source: row.source } : null; } else if (inAnyPeriod(date, travel.unreadable)) { location = null; diff --git a/src/lib/environment/location-cipher.ts b/src/lib/environment/location-cipher.ts index cda1a481f..78c7e48a7 100644 --- a/src/lib/environment/location-cipher.ts +++ b/src/lib/environment/location-cipher.ts @@ -1,22 +1,21 @@ /** * The environment module's coarse locations, sealed at rest (v1.42, #615). * - * Three places held a location in readable columns: the home on the user row - * (`homeLat` / `homeLon` / `homeLabel`), each dated location period - * (`EnvironmentTravelLocation.lat` / `lon` / `label`), and each stored day - * (`EnvironmentContext.lat` / `lon` / `locationLabel`). Coarse as they are - * (one decimal, about 11 km), a home city and a dated list of trips are still - * a movement profile. From v1.42 each row stores ONE sealed value instead, the - * JSON `{ lat, lon, label }` under AES-256-GCM with the associated-data label + * Three places hold a location: the home on the user row + * (`homeLocationEncrypted`), each dated location period + * (`EnvironmentTravelLocation.locationEncrypted`), and each stored day + * (`EnvironmentContext.locationEncrypted`). Coarse as they are (one decimal, + * about 11 km), a home city and a dated list of trips are still a movement + * profile, so each row stores ONE sealed value, the JSON `{ lat, lon, label }` + * under AES-256-GCM with the associated-data label * {@link ENVIRONMENT_LOCATION_AAD}: one decrypt per row, and the three parts * can never drift apart. * - * Writers seal through {@link sealLocation}; the readable columns are written - * as null. Readers go through {@link readLocation}: the sealed value when a - * row has one, the readable columns only for a row the boot-time encryption - * backfill (`free-text-encryption-backfill.ts`) has not reached yet. A sealed - * value that does not open reads as no location, with a warning on the - * request's event, and is never replaced by the readable columns. + * Writers seal through {@link sealLocation}; readers go through + * {@link readLocation}. A sealed value that does not open reads as no + * location, with a warning on the request's event. The readable columns the + * sealed value replaced were dropped in v1.43 (migration 0409), which refuses + * to run while any of them still held a value. * * The coordinates are rounded to the privacy floor BEFORE sealing, so the * sealed value never carries more precision than the readable column did, @@ -78,37 +77,21 @@ export function openLocation(sealed: Uint8Array): CoarseLocation { } /** - * The location a row holds: the sealed value first, the legacy readable - * columns only when there is no sealed value. Null when the row holds - * neither (or only part of the readable triple), and null with a warning when - * the sealed value does not open. - * - * `fallbackLabel` stands in for a missing readable label, which the home has - * always allowed (`homeLabel` was nullable from the start). + * The location a row holds, from its sealed value. Null when the row holds + * none, and null with a warning when the sealed value does not open. */ export function readLocation( - row: { - sealed: Uint8Array | null | undefined; - lat: number | null | undefined; - lon: number | null | undefined; - label: string | null | undefined; - }, - fallbackLabel?: string, + sealed: Uint8Array | null | undefined, ): CoarseLocation | null { - if (row.sealed && row.sealed.byteLength > 0) { - try { - return openLocation(row.sealed); - } catch (err) { - getEvent()?.addWarning( - `environment location decrypt failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - return null; - } + if (!sealed || sealed.byteLength === 0) return null; + try { + return openLocation(sealed); + } catch (err) { + getEvent()?.addWarning( + `environment location decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; } - if (row.lat == null || row.lon == null) return null; - const label = row.label ?? fallbackLabel; - if (label == null) return null; - return { lat: row.lat, lon: row.lon, label }; } diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts index d9af4cea7..071e245e7 100644 --- a/src/lib/environment/service.ts +++ b/src/lib/environment/service.ts @@ -165,9 +165,6 @@ export interface FetchAndStoreResult { /** The user columns every environment read resolves the home from. */ export const HOME_SELECT = { - homeLat: true, - homeLon: true, - homeLabel: true, homeLocationEncrypted: true, homeTimezone: true, homeSince: true, @@ -176,25 +173,16 @@ export const HOME_SELECT = { } as const; /** - * The account's home as the resolver uses it: the sealed copy opened, or the - * readable columns for a row the encryption backfill has not reached. Null + * The account's home as the resolver uses it: the sealed copy opened. Null * when no home is set (or the sealed value does not open). */ export function resolveHome(user: { - homeLat: number | null; - homeLon: number | null; - homeLabel: string | null; homeLocationEncrypted: Uint8Array | null; homeTimezone: string | null; homeSince: Date | null; timezone: string; }): HomeLocation | null { - const location = readLocation({ - sealed: user.homeLocationEncrypted, - lat: user.homeLat, - lon: user.homeLon, - label: user.homeLabel ?? "", - }); + const location = readLocation(user.homeLocationEncrypted); if (!location) return null; return { ...location, @@ -229,21 +217,13 @@ export async function readTravelOverrides(userId: string): Promise<{ select: { startDate: true, endDate: true, - lat: true, - lon: true, - label: true, locationEncrypted: true, }, }); const overrides: TravelOverride[] = []; const unreadable: UnreadablePeriod[] = []; for (const row of records) { - const location = readLocation({ - sealed: row.locationEncrypted, - lat: row.lat, - lon: row.lon, - label: row.label, - }); + const location = readLocation(row.locationEncrypted); if (location) { overrides.push({ startDate: row.startDate, @@ -478,12 +458,8 @@ export function buildUpsert( ? airQualityColumns(air.day, air.now) : AIR_QUALITY_UNFETCHED; const data = { - // The coarse location rides sealed only; the readable columns are - // written empty (they drop in a later release). + // The coarse location rides sealed only. locationEncrypted: sealed, - lat: null, - lon: null, - locationLabel: null, source: loc.source, tempMin: obs.tempMin, tempMax: obs.tempMax, @@ -550,9 +526,6 @@ export async function fillAirQualityGaps( take: maxDays, select: { date: true, - lat: true, - lon: true, - locationLabel: true, locationEncrypted: true, }, }); @@ -564,12 +537,7 @@ export async function fillAirQualityGaps( { lat: number; lon: number; days: string[] } >(); for (const row of rows) { - const location = readLocation({ - sealed: row.locationEncrypted, - lat: row.lat, - lon: row.lon, - label: row.locationLabel, - }); + const location = readLocation(row.locationEncrypted); if (!location) continue; const key = `${location.lat},${location.lon}`; const group = byLocation.get(key) ?? { diff --git a/src/lib/export/__tests__/account-settings-backup.test.ts b/src/lib/export/__tests__/account-settings-backup.test.ts index abb0594a7..09b105b6f 100644 --- a/src/lib/export/__tests__/account-settings-backup.test.ts +++ b/src/lib/export/__tests__/account-settings-backup.test.ts @@ -394,17 +394,13 @@ describe("the environment home (v1.42, sealed at rest)", () => { ); }); - it("seals a readable home from a file and writes the readable columns empty", () => { + it("seals a readable home from a file", () => { const { data, refused } = admitAccountSettings( { homeLat: 48.137, homeLon: 11.575, homeLabel: "Munich" }, ctx, ); expect(refused).toEqual([]); - expect(data).toMatchObject({ - homeLat: null, - homeLon: null, - homeLabel: null, - }); + expect(data).not.toHaveProperty("homeLat"); expect(openLocation(data.homeLocationEncrypted as Uint8Array)).toEqual({ lat: 48.1, lon: 11.6, @@ -421,11 +417,6 @@ describe("the environment home (v1.42, sealed at rest)", () => { expect(Buffer.from(restored).equals(Buffer.from(sealed))).toBe(true); expect( admitAccountSettings({ homeLat: null, homeLon: null }, ctx).data, - ).toEqual({ - homeLocationEncrypted: null, - homeLat: null, - homeLon: null, - homeLabel: null, - }); + ).toEqual({ homeLocationEncrypted: null }); }); }); diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index f69131b8e..2f7a426e5 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -41,6 +41,7 @@ import { Buffer } from "node:buffer"; import { Prisma, type PrismaClient } from "@/generated/prisma/client"; import { + AiUsageLevel, DateFormatPreference, InjectionSite, TimeFormatPreference, @@ -384,21 +385,19 @@ const insuranceNumber: SettingCodec = { /** * v1.42 (#615) — the environment home, sealed at rest as one value - * (`homeLocationEncrypted`, see `src/lib/environment/location-cipher.ts`) - * beside the readable `homeLat` / `homeLon` / `homeLabel` that the - * encryption backfill empties. The four columns travel as one, the way the - * avatar's three do: + * (`homeLocationEncrypted`, see `src/lib/environment/location-cipher.ts`). + * The readable `homeLat` / `homeLon` / `homeLabel` columns were dropped in + * v1.43; the three keys live on in the file: * - * - a portable file carries the home readable, opened from the sealed copy - * when there is one, and no sealed value; - * - a disaster-recovery file carries the sealed value verbatim (base64) - * beside whatever the readable columns still hold. + * - a portable file carries the home readable (`homeLat` / `homeLon` / + * `homeLabel`), opened from the sealed copy, and no sealed value; + * - a disaster-recovery file carries the sealed value verbatim (base64). * * The restore writes the sealed value as it came, or seals a readable home - * under this host's key, and writes the readable columns empty either way. A - * file whose home is null clears the account's home. A home without a label - * (allowed since v1.25) is sealed with an empty label, which every reader - * shows as no label. + * (a portable file, or a disaster-recovery file written before v1.43) under + * this host's key. A file whose home is null clears the account's home. A + * home without a label (allowed since v1.25) is sealed with an empty label, + * which every reader shows as no label. */ const homeLocation: SettingCodec = { write: (row, disasterRecovery) => { @@ -408,21 +407,13 @@ const homeLocation: SettingCodec = { : null; if (disasterRecovery) { return { - homeLat: row.homeLat, - homeLon: row.homeLon, - homeLabel: row.homeLabel, homeLocationEncrypted: sealed && sealed.byteLength > 0 ? Buffer.from(sealed).toString("base64") : null, }; } - const home = readLocation({ - sealed, - lat: row.homeLat as number | null, - lon: row.homeLon as number | null, - label: (row.homeLabel as string | null) ?? "", - }); + const home = readLocation(sealed); return home ? { homeLat: home.lat, @@ -432,12 +423,7 @@ const homeLocation: SettingCodec = { : { homeLat: null, homeLon: null, homeLabel: null }; }, read: (entry) => { - const cleared = { - homeLocationEncrypted: null, - homeLat: null, - homeLon: null, - homeLabel: null, - }; + const cleared = { homeLocationEncrypted: null }; const sealedValue = entry.homeLocationEncrypted; if (has(entry, "homeLocationEncrypted") && sealedValue !== null) { if (typeof sealedValue !== "string") { @@ -471,12 +457,6 @@ const homeLocation: SettingCodec = { }, }; -/** Carried by the home codec above. */ -const ridesWithHome: SettingCodec = { - write: () => ({}), - read: () => undefined, -}; - const ACCOUNT_SETTING_CODECS: Readonly< Record > = { @@ -493,9 +473,6 @@ const ACCOUNT_SETTING_CODECS: Readonly< avatarBytes: avatar, avatarContentType: ridesWithAvatar, avatarUpdatedAt: ridesWithAvatar, - homeLat: ridesWithHome, - homeLon: ridesWithHome, - homeLabel: ridesWithHome, homeTimezone: timeZone("homeTimezone", true), homeSince: instant("homeSince"), homeLocationEncrypted: homeLocation, @@ -545,6 +522,7 @@ const ACCOUNT_SETTING_CODECS: Readonly< ), insightsExcludeMetrics: plain("insightsExcludeMetrics"), disableCoach: plain("disableCoach"), + aiUsageLevel: oneOf("aiUsageLevel", Object.values(AiUsageLevel), true), coachPrefsJson: json("coachPrefsJson"), documentsAutoAiRead: plain("documentsAutoAiRead"), labsLocalOcrEnabled: plain("labsLocalOcrEnabled"), diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 94d737678..7409fc369 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -205,6 +205,21 @@ export const BACKED_UP_MODELS = [ // restore without the row opens a blank questionnaire for somebody who has // already answered it. "OnboardingRecord", + + // ── v1.43 ───────────────────────────────────────────────────────────────── + // A marker's other spellings and its merge ledger, a supplement's + // composition, the Dawarich daily values, and the five relation links that + // close the relation matrix. Each is owned data nothing recomputes; each is + // on COVERAGE_PENDING until its section carries it. + "BiomarkerAlias", + "BiomarkerMerge", + "MedicationIngredient", + "DawarichDay", + "LabResultDocumentLink", + "EncounterMedicationLink", + "EncounterVaccinationLink", + "MedicationDocumentLink", + "MedicationConditionLink", ] as const; /** @@ -597,6 +612,26 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly> = { * rather than by what is cheapest to write. */ export const COVERAGE_PENDING: Readonly> = { + // v1.43 — the new tables are declared here with the schema and move to + // TWO_ENDED_MODELS when their section writer and restore branch land. + BiomarkerAlias: + "Every other spelling a marker answers to: the names folded in by a merge, the choices made at a scan or a document import, the ones typed on the marker. Without them a restored account asks again at the next scan which marker each spelling belongs to.", + BiomarkerMerge: + "The ledger of which markers were merged into which. Without it a link or a cached screen that still names a merged marker reads as a plain 404 instead of being sent to the marker it went into.", + MedicationIngredient: + "What one tablet or capsule of a supplement contains. Without it a restored account's supplements stop counting toward the day's micronutrients, and the composition has to be typed in again from the label.", + DawarichDay: + "The daily movement values fetched from Dawarich: distances, places visited, time away from home, travel days. Days the Dawarich instance has since pruned cannot be fetched again, so they are lost for good.", + LabResultDocumentLink: + "Which stored report a lab result was read from. Without it the result and the report stop pointing at each other, and the result loses its source.", + EncounterMedicationLink: + "Which medications a visit prescribed, changed or reviewed. Without it the visit and the medication stop pointing at each other.", + EncounterVaccinationLink: + "Which doses were given at a visit. Without it the visit and the dose stop pointing at each other.", + MedicationDocumentLink: + "Which documents belong to a medication: the prescription, the plan, the package insert. Without it they stop pointing at each other.", + MedicationConditionLink: + "Which condition a medication treats (its indication). Without it the medication and the condition stop pointing at each other.", WorkoutRoute: "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: @@ -879,6 +914,13 @@ export const USER_COLUMN_BACKUP_CLASS = { nightscoutUrlEncrypted: "CREDENTIAL", nightscoutTokenEncrypted: "CREDENTIAL", nightscoutAllowPrivateHost: "CREDENTIAL", + // v1.43 — the Dawarich connection, credentials like the Nightscout pair. The + // sealed preferences name a place id on that instance, which means nothing + // without the key, so they stay behind with it. + dawarichUrlEncrypted: "CREDENTIAL", + dawarichApiKeyEncrypted: "CREDENTIAL", + dawarichPrefsEncrypted: "CREDENTIAL", + dawarichAllowPrivateHost: "CREDENTIAL", polarAccessTokenEncrypted: "CREDENTIAL", polarUserIdEncrypted: "CREDENTIAL", polarClientIdEncrypted: "CREDENTIAL", @@ -935,13 +977,10 @@ export const USER_COLUMN_BACKUP_CLASS = { avatarContentType: "SETTING", avatarUpdatedAt: "SETTING", // Home, which the environment history is resolved against. - homeLat: "SETTING", - homeLon: "SETTING", - homeLabel: "SETTING", homeTimezone: "SETTING", homeSince: "SETTING", - // v1.42 (#615) — the sealed copy of the same home location, and the - // account's switch for the air-quality part of the module. + // v1.42 (#615) — the sealed home location (the only copy since v1.43), + // and the account's switch for the air-quality part of the module. homeLocationEncrypted: "SETTING", environmentAirQualityEnabled: "SETTING", // How the interface reads. @@ -983,6 +1022,10 @@ export const USER_COLUMN_BACKUP_CLASS = { insightsPrivacyMode: "SETTING", insightsExcludeMetrics: "SETTING", disableCoach: "SETTING", + // v1.43 (#1217) — the account's own AI usage level; NULL follows the + // instance default, and a restored level above this host's ceiling is + // capped at read time like any stored choice. + aiUsageLevel: "SETTING", coachPrefsJson: "SETTING", documentsAutoAiRead: "SETTING", labsLocalOcrEnabled: "SETTING", diff --git a/src/lib/export/environment-backup.ts b/src/lib/export/environment-backup.ts index dc344e535..80c9f0a9f 100644 --- a/src/lib/export/environment-backup.ts +++ b/src/lib/export/environment-backup.ts @@ -14,8 +14,8 @@ * the environmental readings mean anything", and the second line is the one * that decides the design. * - * A reading carries the coarse location it was fetched for (`lat`, `lon`, - * `locationLabel`) and the precedence rule that chose it (`source`). A + * A reading carries the coarse location it was fetched for (sealed; `lat`, + * `lon`, `locationLabel` in a portable file) and the precedence rule that chose it (`source`). A * `TRAVEL` reading exists because an explicit dated location period covered * that day. `resolveLocationForDay` in `src/lib/environment/service.ts` reads * those periods live, every time; nothing else can produce a `TRAVEL` @@ -60,14 +60,13 @@ * - a portable export opens the sealed location and writes the readable * `lat` / `lon` / label, and no `locationEncrypted` at all, so the file * reads anywhere; - * - a disaster-recovery file carries `locationEncrypted` verbatim (base64) - * beside whatever readable columns the row still holds, for a host with - * the same keys. + * - a disaster-recovery file carries `locationEncrypted` verbatim (base64), + * for a host with the same keys; its readable fields are null. * * The restore takes either: a sealed value is written as it came, a readable - * one is sealed under this host's key, and the readable columns are written - * empty in both cases. A row that holds neither restores without a location; - * it is never invented. + * one (a portable file, or a disaster-recovery file written before v1.43) is + * sealed under this host's key. A row that holds neither restores without a + * location; it is never invented. * * The air-quality columns (v1.42) are plain values and ride both purposes * alike. Every one is optional on the way in: a file written before them @@ -129,8 +128,8 @@ export interface EnvironmentContextBackupEntry extends AirQualityBackupColumns { /** `YYYY-MM-DD`, anchored to the resolved location's timezone. */ date: string; /** - * Plaintext coarse location. Nullable since v1.42 (migration 0382): the - * sealed copy replaces it once the encryption backfill has run. + * Readable coarse location: set in a portable file, null in a + * disaster-recovery file (which carries `locationEncrypted`). */ lat: number | null; lon: number | null; @@ -169,7 +168,7 @@ export interface EnvironmentTravelLocationBackupEntry { /** Inclusive `YYYY-MM-DD` bounds. Strings end to end; see the file header. */ startDate: string; endDate: string; - /** Plaintext coarse location; nullable since v1.42 (migration 0382). */ + /** Readable coarse location; set in a portable file, null otherwise. */ lat: number | null; lon: number | null; label: string | null; @@ -198,9 +197,6 @@ export interface EnvironmentBackupCounts { */ const ENVIRONMENT_CONTEXT_BACKUP_SELECT = { date: true, - lat: true, - lon: true, - locationLabel: true, locationEncrypted: true, source: true, tempMin: true, @@ -245,9 +241,6 @@ const ENVIRONMENT_CONTEXT_BACKUP_SELECT = { const ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT = { startDate: true, endDate: true, - lat: true, - lon: true, - label: true, locationEncrypted: true, createdAt: true, updatedAt: true, @@ -261,16 +254,12 @@ function toBase64(bytes: Uint8Array | null): string | null { } /** - * The location columns of one entry: opened and readable for a portable - * file, as stored plus the sealed value for a disaster-recovery file. + * The location fields of one entry: opened and readable for a portable file; + * the sealed value for a disaster-recovery file, whose readable fields stay + * null (the database holds no readable copy since v1.43). */ function locationFields( - row: { - lat: number | null; - lon: number | null; - label: string | null; - locationEncrypted: Uint8Array | null; - }, + sealed: Uint8Array | null, disasterRecovery: boolean, ): { lat: number | null; @@ -280,19 +269,13 @@ function locationFields( } { if (disasterRecovery) { return { - lat: row.lat, - lon: row.lon, - label: row.label, - locationEncrypted: toBase64(row.locationEncrypted), + lat: null, + lon: null, + label: null, + locationEncrypted: toBase64(sealed), }; } - const location = readLocation({ - sealed: row.locationEncrypted, - lat: row.lat, - lon: row.lon, - label: row.label, - }); - return location ?? { lat: null, lon: null, label: null }; + return readLocation(sealed) ?? { lat: null, lon: null, label: null }; } /** @@ -326,12 +309,7 @@ export async function buildEnvironmentBackupSection( return { environmentContexts: contextRows.map((row) => { const { label, ...location } = locationFields( - { - lat: row.lat, - lon: row.lon, - label: row.locationLabel, - locationEncrypted: row.locationEncrypted, - }, + row.locationEncrypted, disasterRecovery, ); return { @@ -381,7 +359,7 @@ export async function buildEnvironmentBackupSection( environmentTravelLocations: travelRows.map((row) => ({ startDate: row.startDate, endDate: row.endDate, - ...locationFields(row, disasterRecovery), + ...locationFields(row.locationEncrypted, disasterRecovery), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), })), @@ -543,9 +521,6 @@ export async function restoreEnvironmentData( `environmentTravelLocations.${entry.startDate}..${entry.endDate}`, unopened, ), - lat: null, - lon: null, - label: null, ...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}), ...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}), })), @@ -567,9 +542,6 @@ export async function restoreEnvironmentData( `environmentContexts.${entry.date}`, unopened, ), - lat: null, - lon: null, - locationLabel: null, source: entry.source, tempMin: entry.tempMin ?? null, tempMax: entry.tempMax ?? null, diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index 282d01a70..289e54d23 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -23,7 +23,7 @@ */ import { Buffer } from "node:buffer"; -import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { Prisma, type PrismaClient } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { getEvent } from "@/lib/logging/context"; @@ -47,6 +47,19 @@ export interface ProfileBackupOptions { * The account's durable self-context. One row per user, so there is no * reference to resolve on the way back in — the owner is the key. */ +/** + * The stored section confirmations as the file carries them: an object of + * strings, anything else read as none. + */ +function lifeConfirmed(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const out: Record = {}; + for (const [key, stamp] of Object.entries(value)) { + if (typeof stamp === "string") out[key] = stamp; + } + return out; +} + export interface HealthProfileBackupEntry { /** Present in canonical DR payloads so the row keeps a stable identity. */ id?: string; @@ -91,6 +104,12 @@ export interface HealthProfileBackupEntry { * state it was in, pending prompts included. */ pendingQuestionsEncrypted?: string | null; + /** + * v1.43 (#614) — when each life-circumstance section was last confirmed as + * still true: section key to ISO instant. Plaintext keys and stamps only, + * carried by value in both purposes. Absent in files written before v1.43. + */ + lifeConfirmedJson?: Record | null; createdAt?: string; updatedAt?: string; } @@ -329,6 +348,7 @@ export async function buildProfileBackupSection( HealthProfileAiSection[] | undefined) ?? [ ...DEFAULT_HEALTH_PROFILE_AI_SECTIONS, ], + lifeConfirmedJson: lifeConfirmed(profileRow.lifeConfirmedJson), emergencyBloodType: profileRow.emergencyBloodType ?? null, organDonorStatus: profileRow.organDonorStatus ?? null, advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null, @@ -377,6 +397,7 @@ export async function buildProfileBackupSection( HealthProfileAiSection[] | undefined) ?? [ ...DEFAULT_HEALTH_PROFILE_AI_SECTIONS, ], + lifeConfirmedJson: lifeConfirmed(profileRow.lifeConfirmedJson), emergencyBloodType: profileRow.emergencyBloodType ?? null, organDonorStatus: profileRow.organDonorStatus ?? null, advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null, @@ -788,6 +809,9 @@ export async function restoreProfileData( // resolution as the self-context columns above. emergencyBloodType: p.emergencyBloodType, organDonorStatus: p.organDonorStatus, + // A file written before v1.43 says nothing about confirmations; the + // account then has none, which only re-offers the yearly check. + lifeConfirmedJson: p.lifeConfirmedJson ?? Prisma.DbNull, advanceDirectiveStatus: p.advanceDirectiveStatus, emergencyContactsEncrypted: resolveProfileColumn( p.emergencyContactsEncrypted, diff --git a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts index 5e5583822..5bc6abd92 100644 --- a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts +++ b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts @@ -5,7 +5,6 @@ import { decryptRouteGeometry, encryptRouteGeometry, } from "@/lib/workouts/route-geometry-cipher"; -import { openLocation, sealLocation } from "@/lib/environment/location-cipher"; // ── in-memory store backing a minimal prisma mock ────────────────────────── interface ConversationRow { @@ -38,36 +37,11 @@ interface RouteRow { geometryEncrypted: Uint8Array | null; } -/** v1.42 — an account's environment home; `userId` is its own id. */ -interface HomeRow { - id: string; - userId: string; - homeLat: number | null; - homeLon: number | null; - homeLabel: string | null; - homeLocationEncrypted: Uint8Array | null; - updatedAt?: Date; -} -/** v1.42 — a dated location period or a stored day (label column named). */ -interface PlaceRow { - id: string; - userId: string; - lat: number | null; - lon: number | null; - label?: string | null; - locationLabel?: string | null; - locationEncrypted: Uint8Array | null; - updatedAt?: Date; -} - const store = vi.hoisted(() => ({ conversations: [] as ConversationRow[], entries: [] as EntryRow[], practitioners: [] as PractitionerRow[], routes: [] as RouteRow[], - homes: [] as HomeRow[], - travels: [] as PlaceRow[], - days: [] as PlaceRow[], })); vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null })); @@ -144,9 +118,6 @@ vi.mock("@/lib/db", () => { customMetricEntry: delegate(() => store.entries, ["note"]), practitioner: delegate(() => store.practitioners, ["phone", "location"]), workoutRoute: delegate(() => store.routes, ["geometry"]), - user: delegate(() => store.homes, ["homeLat"]), - environmentTravelLocation: delegate(() => store.travels, ["lat"]), - environmentContext: delegate(() => store.days, ["lat"]), }; return { prisma: { @@ -254,72 +225,6 @@ beforeEach(() => { }, { id: "r3", userId: "u2", geometry: TRACK, geometryEncrypted: null }, ]; - const stamp = new Date("2026-02-01T08:00:00.000Z"); - store.homes = [ - { - id: "u1", - userId: "u1", - homeLat: 51.5, - homeLon: 7.2, - homeLabel: "Bochum, Germany", - homeLocationEncrypted: null, - updatedAt: stamp, - }, - { - id: "u2", - userId: "u2", - homeLat: 48.1, - homeLon: 11.6, - homeLabel: "Munich, Germany", - homeLocationEncrypted: null, - updatedAt: stamp, - }, - ]; - store.travels = [ - { - id: "t1", - userId: "u1", - lat: 38.7, - lon: -9.1, - label: "Lisbon, Portugal", - locationEncrypted: null, - updatedAt: stamp, - }, - // Already sealed by this release's writer. - { - id: "t2", - userId: "u1", - lat: null, - lon: null, - label: null, - locationEncrypted: sealLocation({ - lat: 41.4, - lon: 2.2, - label: "Barcelona, Spain", - }), - updatedAt: stamp, - }, - ]; - store.days = [ - { - id: "d1", - userId: "u1", - lat: 38.7, - lon: -9.1, - locationLabel: "Lisbon, Portugal", - locationEncrypted: null, - updatedAt: stamp, - }, - { - id: "d2", - userId: "u2", - lat: 48.1, - lon: 11.6, - locationLabel: "Munich, Germany", - locationEncrypted: null, - updatedAt: stamp, - }, - ]; }); afterEach(() => { @@ -337,9 +242,6 @@ describe("runFreeTextEncryptionBackfillForUser", () => { routeGeometriesMigrated: 1, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - homeLocationsMigrated: 1, - travelLocationsMigrated: 1, - dayLocationsMigrated: 1, }); const c1 = store.conversations.find((r) => r.id === "c1")!; @@ -443,64 +345,12 @@ describe("runFreeTextEncryptionBackfillForUser", () => { routeGeometriesMigrated: 0, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - homeLocationsMigrated: 0, - travelLocationsMigrated: 0, - dayLocationsMigrated: 0, }); expect(store.conversations.find((r) => r.id === "c1")!.titleEncrypted).toBe( sealed, ); }); - it("seals the environment home, periods and days, clears the readable columns, keeps the stamps", async () => { - const t2Before = store.travels.find( - (r) => r.id === "t2", - )!.locationEncrypted; - await runFreeTextEncryptionBackfillForUser("u1"); - - const home = store.homes.find((r) => r.id === "u1")!; - expect(home).toMatchObject({ - homeLat: null, - homeLon: null, - homeLabel: null, - }); - expect(openLocation(home.homeLocationEncrypted!)).toEqual({ - lat: 51.5, - lon: 7.2, - label: "Bochum, Germany", - }); - expect(home.updatedAt).toEqual(new Date("2026-02-01T08:00:00.000Z")); - - const t1 = store.travels.find((r) => r.id === "t1")!; - expect(t1).toMatchObject({ lat: null, lon: null, label: null }); - expect(openLocation(t1.locationEncrypted!)).toEqual({ - lat: 38.7, - lon: -9.1, - label: "Lisbon, Portugal", - }); - expect(store.travels.find((r) => r.id === "t2")!.locationEncrypted).toBe( - t2Before, - ); - - const d1 = store.days.find((r) => r.id === "d1")!; - expect(d1).toMatchObject({ lat: null, lon: null, locationLabel: null }); - expect(openLocation(d1.locationEncrypted!).label).toBe("Lisbon, Portugal"); - - // Another account's rows are untouched. - expect(store.homes.find((r) => r.id === "u2")!.homeLat).toBe(48.1); - expect(store.days.find((r) => r.id === "d2")!.lat).toBe(48.1); - }); - - it("seals a home stored without a label with an empty one", async () => { - store.homes[0].homeLabel = null; - await runFreeTextEncryptionBackfillForUser("u1"); - expect(openLocation(store.homes[0].homeLocationEncrypted!)).toEqual({ - lat: 51.5, - lon: 7.2, - label: "", - }); - }); - it("is fail-closed: without a key every readable row stays intact", async () => { vi.stubEnv("ENCRYPTION_KEY", ""); vi.stubEnv("ENCRYPTION_KEYS", ""); @@ -509,10 +359,6 @@ describe("runFreeTextEncryptionBackfillForUser", () => { title: "Why is my pressure up after the new tablets?", titleEncrypted: null, }); - expect(store.days.find((r) => r.id === "d1")).toMatchObject({ - lat: 38.7, - locationEncrypted: null, - }); }); }); diff --git a/src/lib/jobs/environment-air-quality-history.ts b/src/lib/jobs/environment-air-quality-history.ts index ad6acea4a..3c03090d5 100644 --- a/src/lib/jobs/environment-air-quality-history.ts +++ b/src/lib/jobs/environment-air-quality-history.ts @@ -132,7 +132,6 @@ export async function discoverAirQualityHistory( environmentAirQualityEnabled: true, OR: [ { homeLocationEncrypted: { not: null } }, - { homeLat: { not: null }, homeLon: { not: null } }, { environmentTravelLocations: { some: {} } }, ], }, diff --git a/src/lib/jobs/environment-fetch.ts b/src/lib/jobs/environment-fetch.ts index 23c838882..e15262132 100644 --- a/src/lib/jobs/environment-fetch.ts +++ b/src/lib/jobs/environment-fetch.ts @@ -146,14 +146,7 @@ export async function enqueueEnvironmentFetchDiscovery( // consults the per-user preference map + the operator availability. const [candidates, operatorAvailability] = await Promise.all([ prisma.user.findMany({ - // The sealed home (v1.42), or the readable one a row the encryption - // backfill has not reached yet still carries. - where: { - OR: [ - { homeLocationEncrypted: { not: null } }, - { homeLat: { not: null }, homeLon: { not: null } }, - ], - }, + where: { homeLocationEncrypted: { not: null } }, select: { id: true, modulePreferencesJson: true }, }), getOperatorModuleAvailability(), diff --git a/src/lib/jobs/free-text-encryption-backfill.ts b/src/lib/jobs/free-text-encryption-backfill.ts index d02277dbf..ce04c2189 100644 --- a/src/lib/jobs/free-text-encryption-backfill.ts +++ b/src/lib/jobs/free-text-encryption-backfill.ts @@ -22,15 +22,10 @@ * column holds a JSON null rather than SQL NULL has nothing to seal; it is set * to SQL NULL so discovery stops finding it. * - * v1.42 (#615) adds the environment module's coarse locations: the home on - * the user row (`homeLat` / `homeLon` / `homeLabel` -> `homeLocationEncrypted`), - * each dated location period and each stored day (`lat` / `lon` / label -> - * `locationEncrypted`), each sealed as one `{ lat, lon, label }` value under - * the environment-location label (`location-cipher.ts`). A row whose readable - * triple is incomplete (a home without a label is sealed with an empty one; - * a day or period cannot be incomplete, those - * columns were NOT NULL until migration 0382) has its readable columns - * cleared all the same, so it leaves discovery. + * v1.42 (#615) added the environment module's coarse locations (home, dated + * location periods, stored days). v1.43 dropped their readable columns + * (migration 0409, which refuses to run while one still holds a value), so + * that part of the pass is gone. * * Modelled on `med-notes-encryption-backfill.ts`: a discovery query enqueues * one job per user still holding an un-migrated row, the per-user handler walks @@ -54,9 +49,9 @@ * after the upgrade (a rename, an edited note). Its readable value is then * the newer one, and it is the one sealed. * - * The readable columns are NOT dropped in this release. That is a follow-up - * release, once this backfill reports zero remaining rows on every instance; - * every one of them carries the marker in `schema.prisma`. + * The remaining readable columns are dropped in a follow-up release, once + * this backfill reports zero remaining rows on every instance; every one of + * them carries the marker in `schema.prisma`. * * Boot discovery is staggered past the startup storm (`startAfter`), like the * other boot backfills; the daily discovery tick passes no offset. @@ -72,7 +67,6 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; -import { sealLocation } from "@/lib/environment/location-cipher"; export const FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE = "free-text-encryption-backfill"; @@ -100,12 +94,6 @@ export interface FreeTextEncryptionBackfillSummary { appointmentAddressesCleared: number; /** v1.39.4 — edit audit rows whose old phone number / address was removed. */ contactAuditRowsScrubbed: number; - /** v1.42 — the account's environment home sealed (0 or 1). */ - homeLocationsMigrated: number; - /** v1.42 — dated environment location periods sealed. */ - travelLocationsMigrated: number; - /** v1.42 — stored environment days whose location was sealed. */ - dayLocationsMigrated: number; } /** The audit action whose older rows carried contact values. */ @@ -303,107 +291,6 @@ async function migrateRouteGeometry(id: string): Promise { }); } -/** - * Seal one account's readable environment home into `homeLocationEncrypted` - * and clear the readable columns, atomically and idempotently. The user row - * keeps its own `updatedAt`: moving a value into ciphertext is not an edit. - */ -async function migrateHomeLocation(id: string): Promise { - return prisma.$transaction(async (tx) => { - const fresh = await tx.user.findUnique({ - where: { id }, - select: { - homeLat: true, - homeLon: true, - homeLabel: true, - updatedAt: true, - }, - }); - if (!fresh || fresh.homeLat === null) return false; - const { count } = await tx.user.updateMany({ - where: { id, updatedAt: fresh.updatedAt }, - data: { - updatedAt: fresh.updatedAt, - ...(fresh.homeLon !== null - ? { - homeLocationEncrypted: sealLocation({ - lat: fresh.homeLat, - lon: fresh.homeLon, - // A home stored without a label seals an empty one, which - // every reader shows as no label. - label: fresh.homeLabel ?? "", - }), - } - : {}), - homeLat: null, - homeLon: null, - homeLabel: null, - }, - }); - return count === 1; - }); -} - -/** Seal one dated location period's readable location, as above. */ -async function migrateTravelLocation(id: string): Promise { - return prisma.$transaction(async (tx) => { - const fresh = await tx.environmentTravelLocation.findUnique({ - where: { id }, - select: { lat: true, lon: true, label: true, updatedAt: true }, - }); - if (!fresh || fresh.lat === null) return false; - const { count } = await tx.environmentTravelLocation.updateMany({ - where: { id, updatedAt: fresh.updatedAt }, - data: { - updatedAt: fresh.updatedAt, - ...(fresh.lon !== null && fresh.label !== null - ? { - locationEncrypted: sealLocation({ - lat: fresh.lat, - lon: fresh.lon, - label: fresh.label, - }), - } - : {}), - lat: null, - lon: null, - label: null, - }, - }); - return count === 1; - }); -} - -/** Seal one stored day's readable location, as above. */ -async function migrateDayLocation(id: string): Promise { - return prisma.$transaction(async (tx) => { - const fresh = await tx.environmentContext.findUnique({ - where: { id }, - select: { lat: true, lon: true, locationLabel: true, updatedAt: true }, - }); - if (!fresh || fresh.lat === null) return false; - const { count } = await tx.environmentContext.updateMany({ - where: { id, updatedAt: fresh.updatedAt }, - data: { - updatedAt: fresh.updatedAt, - ...(fresh.lon !== null && fresh.locationLabel !== null - ? { - locationEncrypted: sealLocation({ - lat: fresh.lat, - lon: fresh.lon, - label: fresh.locationLabel, - }), - } - : {}), - lat: null, - lon: null, - locationLabel: null, - }, - }); - return count === 1; - }); -} - /** * Walk one kind of row page by page, migrating each. `page` returns the ids * still holding a readable value; each migration clears it, so a migrated row @@ -511,39 +398,6 @@ export async function runFreeTextEncryptionBackfillForUser( scrubContactAuditRow, once, ); - // v1.42 — the environment locations. The 0382 partial indexes match these - // predicates. - const homeLocationsMigrated = await drain( - () => - prisma.user.findMany({ - where: { id: userId, homeLat: { not: null } }, - select: { id: true }, - take: PAGE_SIZE, - }), - migrateHomeLocation, - once, - ); - const travelLocationsMigrated = await drain( - () => - prisma.environmentTravelLocation.findMany({ - where: { userId, lat: { not: null } }, - select: { id: true }, - take: PAGE_SIZE, - }), - migrateTravelLocation, - once, - ); - const dayLocationsMigrated = await drain( - () => - prisma.environmentContext.findMany({ - where: { userId, lat: { not: null } }, - select: { id: true }, - take: PAGE_SIZE, - }), - migrateDayLocation, - once, - ); - annotate({ action: { name: "free_text.encryption.backfill", @@ -554,9 +408,6 @@ export async function runFreeTextEncryptionBackfillForUser( route_geometries_migrated: routeGeometriesMigrated, appointment_addresses_cleared: appointmentAddressesCleared, contact_audit_rows_scrubbed: contactAuditRowsScrubbed, - home_locations_migrated: homeLocationsMigrated, - travel_locations_migrated: travelLocationsMigrated, - day_locations_migrated: dayLocationsMigrated, }, }, }); @@ -568,16 +419,13 @@ export async function runFreeTextEncryptionBackfillForUser( routeGeometriesMigrated, appointmentAddressesCleared, contactAuditRowsScrubbed, - homeLocationsMigrated, - travelLocationsMigrated, - dayLocationsMigrated, }; } /** * Discovery. Finds every user still holding a readable value in any of the - * columns above and enqueues one backfill job per account. The 0357, 0361 and - * 0382 partial indexes match the predicates, so the scan is index-only and converges to empty. + * columns above and enqueues one backfill job per account. The 0357 and 0361 + * partial indexes match the predicates, so the scan is index-only and converges to empty. * The appointment-address and audit predicates have no partial index: the * first scans the reminder table, the second reads one action through the * `(action, created_at)` index; both converge to empty the same way. pg-boss @@ -625,15 +473,6 @@ export async function enqueueBootTimeFreeTextEncryptionBackfill( WHERE action = ${CONTACT_AUDIT_ACTION} AND user_id IS NOT NULL AND (details LIKE '%"location":%' OR details LIKE '%"phone":%')`, - prisma.$queryRaw<{ user_id: string }[]>` - SELECT id AS user_id FROM users - WHERE home_lat IS NOT NULL`, - prisma.$queryRaw<{ user_id: string }[]>` - SELECT DISTINCT user_id FROM environment_travel_locations - WHERE lat IS NOT NULL`, - prisma.$queryRaw<{ user_id: string }[]>` - SELECT DISTINCT user_id FROM environment_contexts - WHERE lat IS NOT NULL`, ]); const userIds = new Set(); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index f9d99ba4a..70ca04c42 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -811,6 +811,13 @@ const healthProfileBackupSchema = z aiIncludedSections: z .array(healthProfileAiSectionSchema) .default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]), + // v1.43 (#614) — section confirmations, section key to ISO instant. + // Carried as stored; the read route judges each stamp, so an unreadable + // one costs a "still true?" prompt rather than the whole restore. + lifeConfirmedJson: z + .record(z.string().max(64), z.string().max(64)) + .nullable() + .optional(), // Emergency profile: three plaintext enums (carried by value in both // purposes), three free-text columns following the ciphertext-or-plaintext // split of the self-context fields above. @@ -2031,6 +2038,8 @@ const accountSettingsBackupSchema = z insightsPrivacyMode: z.string().optional(), insightsExcludeMetrics: z.array(z.string()).optional(), disableCoach: z.boolean().optional(), + // v1.43 (#1217) — the account's AI usage level; the codec checks the value. + aiUsageLevel: z.string().nullable().optional(), coachPrefsJson: z.unknown().optional(), documentsAutoAiRead: z.boolean().optional(), labsLocalOcrEnabled: z.boolean().optional(), diff --git a/src/lib/validations/mental-health.ts b/src/lib/validations/mental-health.ts index ca6b70784..003f5f619 100644 --- a/src/lib/validations/mental-health.ts +++ b/src/lib/validations/mental-health.ts @@ -16,6 +16,21 @@ import { isValidTimezone } from "@/lib/tz/format"; export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]); +/** + * The mental-health screeners, as opposed to every `AssessmentInstrument`. + * Since v1.43 the table also stores AUDIT-C (the alcohol section of the life + * circumstances), which no mental-health surface may list or score: older app + * builds decode `instrument` against exactly these four values. + */ +export const SCREENER_INSTRUMENTS = assessmentInstrumentEnum.options; +export type ScreenerInstrument = (typeof SCREENER_INSTRUMENTS)[number]; + +export function isScreenerInstrument( + instrument: string, +): instrument is ScreenerInstrument { + return (SCREENER_INSTRUMENTS as readonly string[]).includes(instrument); +} + /** * Client provenance for an administration. Closed enum so the column can never * carry a free-form label. `WEB` is the in-app questionnaire (the DB default); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 7cde2c4cb..88971c88a 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -1426,7 +1426,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { // coordinates are at 1 decimal, the precision the app stores since v1.39.4; // a restore rounds anything finer, so finer values would not round-trip. // Since v1.42 the location is stored sealed (`locationEncrypted`), the way - // the writers store it; the readable columns stay empty. + // the writers store it; it is the only copy since v1.43. await prisma.environmentTravelLocation.create({ data: { userId: OWNER_ID, @@ -3602,22 +3602,6 @@ const COLUMN_EXCLUSIONS: Readonly> = { "insert stamp of a symptom link; no reader, the day log's own timestamps travel", "IllnessSymptomLink.createdAt": "insert stamp of a symptom link; no reader, the illness day log's own timestamps travel", - // v1.42 (#615) — the readable copies of the sealed environment locations. - // The restore seals whatever location a file carries into - // `locationEncrypted` and writes these empty; the location itself is - // compared through the sealed column. They drop in v1.43. - "EnvironmentContext.lat": - "readable copy of the sealed day location; the restore seals it and writes this empty", - "EnvironmentContext.lon": - "readable copy of the sealed day location; the restore seals it and writes this empty", - "EnvironmentContext.locationLabel": - "readable copy of the sealed day location; the restore seals it and writes this empty", - "EnvironmentTravelLocation.lat": - "readable copy of the sealed period location; the restore seals it and writes this empty", - "EnvironmentTravelLocation.lon": - "readable copy of the sealed period location; the restore seals it and writes this empty", - "EnvironmentTravelLocation.label": - "readable copy of the sealed period location; the restore seals it and writes this empty", }; /** @@ -3638,7 +3622,14 @@ const COLUMN_PRESENCE_ONLY: Readonly> = { * starts coming back, so the entry has to go in the change that carries it. * The list is empty again before the release is tagged. */ -const RELEASE_PENDING_COLUMNS: Readonly> = {}; +const RELEASE_PENDING_COLUMNS: Readonly> = { + "IntradayCumulativeProfile.hourlyIncrements": + "hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest", + "IntradayCumulativeProfile.basis": + "hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest", + "IntradayCumulativeProfile.bucketCount": + "hour buckets for summed types (ios #77): the intraday section carries it with the hour-bucket ingest", +}; /** * Columns every reader parses through one function before using, compared @@ -3665,6 +3656,8 @@ const FILL_OVERRIDES: Readonly> = { "MoodEntry.tags": JSON.stringify(["round-trip-tag"]), "CorrelationPattern.qValue": 0.04, "CorrelationPattern.dismissedEvidenceHash": "ab".repeat(32), + // CHECK in migration 0408: the profile is built from samples or from hour buckets. + "IntradayCumulativeProfile.basis": "HOUR_BUCKETS", }; /** @@ -4155,17 +4148,6 @@ const USER_FILL_OVERRIDES: Readonly> = { sealLocation({ lat: 2.5, lon: 2.5, label: "round-trip home" }), }; -/** - * v1.42 (#615) — readable setting columns the restore seals into another - * column and writes empty. The value they held is compared through the sealed - * column (`homeLocationEncrypted`). - */ -const SEALED_ON_RESTORE: Readonly> = { - homeLat: "sealed into homeLocationEncrypted", - homeLon: "sealed into homeLocationEncrypted", - homeLabel: "sealed into homeLocationEncrypted", -}; - /** * Columns of the account row the restore writes for reasons of its own, so * "unchanged on the target" does not apply to them. @@ -4302,15 +4284,8 @@ describe("the account's own settings survive a real restore", () => { })) as unknown as Record; const lost = [...settings].filter( - (name) => - !Object.hasOwn(SEALED_ON_RESTORE, name) && - opened(after[name]) !== opened(source[name]), + (name) => opened(after[name]) !== opened(source[name]), ); - // The readable home columns come back empty by design; the home itself - // came back sealed, compared above through `homeLocationEncrypted`. - for (const name of Object.keys(SEALED_ON_RESTORE)) { - expect(after[name], name).toBeNull(); - } expect( lost, "these settings left with a value and came back without it", diff --git a/tests/integration/drop-plaintext-location-migration.test.ts b/tests/integration/drop-plaintext-location-migration.test.ts new file mode 100644 index 000000000..e20d48ea4 --- /dev/null +++ b/tests/integration/drop-plaintext-location-migration.test.ts @@ -0,0 +1,245 @@ +/** + * Migration 0409 drops the readable location columns, and refuses to run + * while any row still holds one. + * + * The suite's database is already migrated, so each case first puts the + * v1.42 shape back (the three readable column sets and the 0382 partial + * indexes) inside a transaction, then runs the migration's own statements + * against it. Every case rolls back, so the database leaves as it came. + * + * - A readable home, period or day aborts the migration with a message that + * counts each kind, and nothing is lost: the columns and the value are + * still there afterwards. + * - Without readable values it drops the nine columns and the three + * indexes, and the sealed copies are untouched. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import type { Prisma } from "@/generated/prisma/client"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const USER = "drop-plaintext-location"; +const ROLLBACK = new Error("roll back the case"); + +const MIGRATION = readFileSync( + resolve( + process.cwd(), + "prisma", + "migrations", + "0409_drop_plaintext_location", + "migration.sql", + ), + "utf8", +); + +/** + * The migration as separate statements. A `DO $$ … $$` block carries + * semicolons of its own, so the split honours dollar quoting; comment lines + * are dropped first. + */ +function statements(sql: string): string[] { + const text = sql + .split("\n") + .filter((line) => !line.trimStart().startsWith("--")) + .join("\n"); + const out: string[] = []; + let current = ""; + let inDollar = false; + for (let i = 0; i < text.length; i += 1) { + if (text.startsWith("$$", i)) { + inDollar = !inDollar; + current += "$$"; + i += 1; + continue; + } + const ch = text[i]; + if (ch === ";" && !inDollar) { + if (current.trim()) out.push(current.trim()); + current = ""; + continue; + } + current += ch; + } + if (current.trim()) out.push(current.trim()); + return out; +} + +/** The readable columns and partial indexes as v1.42 left them. */ +async function restoreV142Shape(tx: Prisma.TransactionClient): Promise { + await tx.$executeRawUnsafe(`ALTER TABLE "users" + ADD COLUMN "home_lat" DOUBLE PRECISION, + ADD COLUMN "home_lon" DOUBLE PRECISION, + ADD COLUMN "home_label" TEXT`); + await tx.$executeRawUnsafe(`ALTER TABLE "environment_travel_locations" + ADD COLUMN "lat" DOUBLE PRECISION, + ADD COLUMN "lon" DOUBLE PRECISION, + ADD COLUMN "label" TEXT`); + await tx.$executeRawUnsafe(`ALTER TABLE "environment_contexts" + ADD COLUMN "lat" DOUBLE PRECISION, + ADD COLUMN "lon" DOUBLE PRECISION, + ADD COLUMN "location_label" TEXT`); + await tx.$executeRawUnsafe( + `CREATE INDEX "users_home_location_plain_idx" ON "users"("id") WHERE "home_lat" IS NOT NULL`, + ); + await tx.$executeRawUnsafe( + `CREATE INDEX "environment_travel_locations_plain_location_idx" ON "environment_travel_locations"("id") WHERE "lat" IS NOT NULL`, + ); + await tx.$executeRawUnsafe( + `CREATE INDEX "environment_contexts_plain_location_idx" ON "environment_contexts"("id") WHERE "lat" IS NOT NULL`, + ); +} + +async function runMigration(tx: Prisma.TransactionClient): Promise { + for (const statement of statements(MIGRATION)) { + await tx.$executeRawUnsafe(statement); + } +} + +async function columnCount(tx: Prisma.TransactionClient): Promise { + const rows = await tx.$queryRaw<{ n: bigint }[]>` + SELECT count(*) AS n FROM information_schema.columns + WHERE (table_name = 'users' AND column_name IN ('home_lat', 'home_lon', 'home_label')) + OR (table_name = 'environment_travel_locations' AND column_name IN ('lat', 'lon', 'label')) + OR (table_name = 'environment_contexts' AND column_name IN ('lat', 'lon', 'location_label'))`; + return Number(rows[0].n); +} + +async function plainIndexCount(tx: Prisma.TransactionClient): Promise { + const rows = await tx.$queryRaw<{ n: bigint }[]>` + SELECT count(*) AS n FROM pg_indexes + WHERE indexname IN ( + 'users_home_location_plain_idx', + 'environment_travel_locations_plain_location_idx', + 'environment_contexts_plain_location_idx' + )`; + return Number(rows[0].n); +} + +/** Run `body` in a transaction that always rolls back. */ +async function inRolledBackCase( + body: (tx: Prisma.TransactionClient) => Promise, +): Promise { + await expect( + prisma.$transaction(async (tx) => { + await body(tx); + throw ROLLBACK; + }), + ).rejects.toBe(ROLLBACK); +} + +/** The migration's outcome inside a savepoint, so the case can look after it. */ +async function attempt(tx: Prisma.TransactionClient): Promise { + await tx.$executeRawUnsafe("SAVEPOINT migration_0409"); + try { + await runMigration(tx); + await tx.$executeRawUnsafe("RELEASE SAVEPOINT migration_0409"); + return null; + } catch (err) { + await tx.$executeRawUnsafe("ROLLBACK TO SAVEPOINT migration_0409"); + return err instanceof Error ? err : new Error(String(err)); + } +} + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: USER, username: USER, email: `${USER}@example.test` }, + }); +}); + +afterAll(async () => { + await truncateAllTables(prisma); +}); + +describe("migration 0409 (drop the readable location columns)", () => { + it("is split into its guard and its drops", () => { + const parts = statements(MIGRATION); + expect(parts[0]).toMatch(/^DO \$\$/); + expect(parts[0]).toContain("RAISE EXCEPTION"); + // The guard runs before anything is dropped. + expect(parts.slice(1).every((s) => /^(DROP|ALTER)/.test(s))).toBe(true); + expect(parts.length).toBeGreaterThan(3); + }); + + it("stops on a readable home, names the counts, and loses nothing", async () => { + await inRolledBackCase(async (tx) => { + await restoreV142Shape(tx); + await tx.$executeRawUnsafe( + `UPDATE "users" SET "home_lat" = 51.5, "home_lon" = 7.2, "home_label" = 'Bochum' WHERE "id" = $1`, + USER, + ); + + const error = await attempt(tx); + expect(error?.message).toMatch(/HealthLog migration 0409 stopped/); + expect(error?.message).toMatch( + /1 home location\(s\), 0 location period\(s\) and 0 daily location\(s\)/, + ); + + // Nothing was dropped and the value is where it was. + expect(await columnCount(tx)).toBe(9); + expect(await plainIndexCount(tx)).toBe(3); + const rows = await tx.$queryRawUnsafe<{ home_lat: number }[]>( + `SELECT "home_lat" FROM "users" WHERE "id" = $1`, + USER, + ); + expect(rows[0].home_lat).toBe(51.5); + }); + }); + + it("counts readable periods and days as well", async () => { + await inRolledBackCase(async (tx) => { + await restoreV142Shape(tx); + await tx.$executeRawUnsafe( + `INSERT INTO "environment_travel_locations" ("id", "user_id", "start_date", "end_date", "lat", "lon", "label", "updated_at") + VALUES ('t1', $1, '2026-05-01', '2026-05-03', 38.7, -9.1, 'Lisbon', now()), + ('t2', $1, '2026-06-01', '2026-06-03', 41.4, 2.2, 'Barcelona', now())`, + USER, + ); + await tx.$executeRawUnsafe( + `INSERT INTO "environment_contexts" ("id", "user_id", "date", "source", "lat", "lon", "location_label", "updated_at") + VALUES ('d1', $1, '2026-05-01', 'TRAVEL', 38.7, -9.1, 'Lisbon', now())`, + USER, + ); + + const error = await attempt(tx); + expect(error?.message).toMatch( + /0 home location\(s\), 2 location period\(s\) and 1 daily location\(s\)/, + ); + expect(await columnCount(tx)).toBe(9); + }); + }); + + it("drops the columns and indexes when no readable value is left, sealed rows untouched", async () => { + await inRolledBackCase(async (tx) => { + await restoreV142Shape(tx); + const sealed = new Uint8Array([2, 1, 2, 3]); + await tx.environmentTravelLocation.create({ + data: { + id: "sealed-period", + userId: USER, + startDate: "2026-07-01", + endDate: "2026-07-02", + locationEncrypted: sealed, + }, + }); + + expect(await attempt(tx)).toBeNull(); + expect(await columnCount(tx)).toBe(0); + expect(await plainIndexCount(tx)).toBe(0); + const kept = await tx.environmentTravelLocation.findUnique({ + where: { id: "sealed-period" }, + }); + expect(Buffer.from(kept!.locationEncrypted!)).toEqual( + Buffer.from(sealed), + ); + }); + }); + + it("leaves the migrated database as it found it", async () => { + expect(await columnCount(prisma as Prisma.TransactionClient)).toBe(0); + expect(await plainIndexCount(prisma as Prisma.TransactionClient)).toBe(0); + }); +}); diff --git a/tests/integration/environment-coarse-location.test.ts b/tests/integration/environment-coarse-location.test.ts index 8bab8a772..07e753ae9 100644 --- a/tests/integration/environment-coarse-location.test.ts +++ b/tests/integration/environment-coarse-location.test.ts @@ -5,16 +5,14 @@ * - setting a home and adding a travel period store the coarse value, and * the home answer carries it in the same shape as before (numbers); * - a restore of a file written with 2-decimal coordinates stores them - * coarse; - * - the rounding block of migration 0361 coarsens rows written before it, - * and running it twice changes nothing more. + * coarse. * * v1.42 (#615): the locations are stored sealed, so the coarse value is - * asserted on the opened sealed copy, and the readable columns are empty. The - * rounding still happens before sealing; nothing finer is ever stored. + * asserted on the opened sealed copy. The rounding happens before sealing; + * nothing finer is ever stored. Since v1.43 (migration 0409) the readable + * columns no longer exist, so the rounding block of migration 0361, which + * worked on them, has nothing left to test against. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; process.env.ENCRYPTION_KEY ??= @@ -100,11 +98,6 @@ describe("environment coordinates at one decimal", () => { const user = await getPrismaClient().user.findUniqueOrThrow({ where: { id: OWNER_ID }, }); - expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([ - null, - null, - null, - ]); expect(openLocation(user.homeLocationEncrypted!)).toEqual({ lat: 52.5, lon: 13.4, @@ -125,9 +118,6 @@ describe("environment coordinates at one decimal", () => { const stored = await getPrismaClient().environmentTravelLocation.findMany({ where: { userId: OWNER_ID }, }); - expect(stored.map((t) => [t.lat, t.lon, t.label])).toEqual([ - [null, null, null], - ]); expect(stored.map((t) => openLocation(t.locationEncrypted!))).toEqual([ { lat: 48.1, lon: 11.6, label: "Munich, Germany" }, ]); @@ -169,68 +159,5 @@ describe("environment coordinates at one decimal", () => { expect(days.map((d) => openLocation(d.locationEncrypted!))).toEqual([ { lat: 48.1, lon: 11.6, label: "Munich, Germany" }, ]); - expect(days.map((d) => [d.lat, d.lon, d.locationLabel])).toEqual([ - [null, null, null], - ]); - }); - - it("coarsens rows written before migration 0361, idempotently", async () => { - const prisma = getPrismaClient(); - await prisma.user.update({ - where: { id: OWNER_ID }, - data: { homeLat: 52.52, homeLon: 13.38 }, - }); - await prisma.environmentTravelLocation.create({ - data: { - userId: OWNER_ID, - startDate: "2026-08-01", - endDate: "2026-08-10", - lat: 48.14, - lon: 11.58, - label: "Munich, Germany", - }, - }); - await prisma.environmentContext.create({ - data: { - userId: OWNER_ID, - date: "2026-08-02", - lat: -33.87, - lon: 151.21, - locationLabel: "Sydney, Australia", - source: "TRAVEL", - }, - }); - - const sql = readFileSync( - join( - __dirname, - "../../prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql", - ), - "utf8", - ); - const statements = sql - .slice(sql.indexOf('UPDATE "users"')) - .split(";") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - expect(statements).toHaveLength(3); - - for (let pass = 0; pass < 2; pass++) { - for (const statement of statements) { - await prisma.$executeRawUnsafe(statement); - } - const user = await prisma.user.findUniqueOrThrow({ - where: { id: OWNER_ID }, - }); - expect([user.homeLat, user.homeLon]).toEqual([52.5, 13.4]); - const travel = await prisma.environmentTravelLocation.findFirstOrThrow({ - where: { userId: OWNER_ID }, - }); - expect([travel.lat, travel.lon]).toEqual([48.1, 11.6]); - const day = await prisma.environmentContext.findFirstOrThrow({ - where: { userId: OWNER_ID }, - }); - expect([day.lat, day.lon]).toEqual([-33.9, 151.2]); - } }); }); diff --git a/tests/integration/environment-location-encryption.test.ts b/tests/integration/environment-location-encryption.test.ts index ff5e25456..79251745e 100644 --- a/tests/integration/environment-location-encryption.test.ts +++ b/tests/integration/environment-location-encryption.test.ts @@ -1,13 +1,11 @@ /** * v1.42 (#615) — the environment locations sealed at rest, against real - * Postgres with the migrated schema (0381, 0382). + * Postgres with the migrated schema (0381, 0382, and 0409, which dropped the + * readable location columns). * * - the writers (home, period) store only the sealed copy, the overview * answers readable, and the air-quality switch round-trips through its * route; - * - the boot-time backfill finds the accounts still holding a readable - * home, period or day, seals each, clears the readable columns, and - * converges (a second pass and a second discovery find nothing); * - the backup carries the location readable in a portable file and sealed * in a disaster-recovery file, and both restore to a location that opens * to the same coarse place, with the air-quality values of the day; @@ -22,10 +20,6 @@ import { POST as postTravel } from "@/app/api/environment/travel/route"; import { PATCH as patchPreferences } from "@/app/api/environment/preferences/route"; import { GET as getOverview } from "@/app/api/environment/route"; import { setGlobalBoss } from "@/lib/jobs/boss-instance"; -import { - enqueueBootTimeFreeTextEncryptionBackfill, - runFreeTextEncryptionBackfillForUser, -} from "@/lib/jobs/free-text-encryption-backfill"; import { openLocation, sealLocation } from "@/lib/environment/location-cipher"; import { buildEnvironmentBackupSection, @@ -134,19 +128,18 @@ describe("environment writers and the overview", () => { ).toBe(201); const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } }); - expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([ - null, - null, - null, - ]); + expect(user).not.toHaveProperty("homeLat"); const travel = await prisma.environmentTravelLocation.findFirstOrThrow({ where: { userId: OWNER }, }); - expect([travel.lat, travel.lon, travel.label]).toEqual([null, null, null]); + expect(travel).not.toHaveProperty("lat"); // No readable trace of either place anywhere in the two rows. expect( JSON.stringify({ ...user, homeLocationEncrypted: null }), ).not.toMatch(/Bochum/); + expect(JSON.stringify({ ...travel, locationEncrypted: null })).not.toMatch( + /Lisbon/, + ); const overview = (await (await getOverview()).json()).data; expect(overview.home).toMatchObject({ @@ -231,93 +224,6 @@ describe("environment writers and the overview", () => { }); }); -describe("the encryption backfill (real Postgres)", () => { - it("discovers, seals, clears and converges", async () => { - const prisma = getPrismaClient(); - await seedUser("env-other"); - const stamp = new Date("2026-03-01T10:00:00.000Z"); - await prisma.user.update({ - where: { id: OWNER }, - data: { homeLat: 51.5, homeLon: 7.2, homeLabel: "Bochum, Germany" }, - }); - await prisma.environmentTravelLocation.create({ - data: { - userId: OWNER, - startDate: "2026-08-01", - endDate: "2026-08-10", - lat: 38.7, - lon: -9.1, - label: "Lisbon, Portugal", - updatedAt: stamp, - }, - }); - await prisma.environmentContext.create({ - data: { - userId: OWNER, - date: "2026-08-02", - lat: 38.7, - lon: -9.1, - locationLabel: "Lisbon, Portugal", - source: "TRAVEL", - }, - }); - - const sent: string[] = []; - setGlobalBoss({ - send: async (_q: string, payload: { userId?: string }) => { - if (payload.userId) sent.push(payload.userId); - return "job"; - }, - } as never); - const discovery = await enqueueBootTimeFreeTextEncryptionBackfill(); - expect(discovery.error).toBeNull(); - expect(sent).toEqual([OWNER]); - - const summary = await runFreeTextEncryptionBackfillForUser(OWNER); - expect(summary).toMatchObject({ - homeLocationsMigrated: 1, - travelLocationsMigrated: 1, - dayLocationsMigrated: 1, - }); - - const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } }); - expect(user.homeLat).toBeNull(); - expect(openLocation(user.homeLocationEncrypted!)).toEqual({ - lat: 51.5, - lon: 7.2, - label: "Bochum, Germany", - }); - const travel = await prisma.environmentTravelLocation.findFirstOrThrow({ - where: { userId: OWNER }, - }); - expect(travel.lat).toBeNull(); - expect(travel.updatedAt).toEqual(stamp); - expect(openLocation(travel.locationEncrypted!).label).toBe( - "Lisbon, Portugal", - ); - const day = await prisma.environmentContext.findFirstOrThrow({ - where: { userId: OWNER }, - }); - expect([day.lat, day.lon, day.locationLabel]).toEqual([null, null, null]); - expect(openLocation(day.locationEncrypted!).lat).toBe(38.7); - - // Converged: nothing readable is left to find. - expect(await runFreeTextEncryptionBackfillForUser(OWNER)).toMatchObject({ - homeLocationsMigrated: 0, - travelLocationsMigrated: 0, - dayLocationsMigrated: 0, - }); - sent.length = 0; - await enqueueBootTimeFreeTextEncryptionBackfill(); - expect(sent).toEqual([]); - const remaining = await prisma.$queryRaw<{ n: bigint }[]>` - SELECT (SELECT count(*) FROM users WHERE home_lat IS NOT NULL) - + (SELECT count(*) FROM environment_travel_locations WHERE lat IS NOT NULL) - + (SELECT count(*) FROM environment_contexts WHERE lat IS NOT NULL) AS n`; - expect(Number(remaining[0].n)).toBe(0); - }); -}); - describe("the environment backup with sealed locations", () => { async function seedDay() { const prisma = getPrismaClient(); @@ -383,7 +289,7 @@ describe("the environment backup with sealed locations", () => { const restored = await prisma.environmentContext.findFirstOrThrow({ where: { userId: OWNER }, }); - expect([restored.lat, restored.locationLabel]).toEqual([null, null]); + expect(restored).not.toHaveProperty("lat"); expect(openLocation(restored.locationEncrypted!)).toEqual({ lat: 38.7, lon: -9.1, diff --git a/tests/integration/prisma-schema-name-drift.test.ts b/tests/integration/prisma-schema-name-drift.test.ts index 9865cb93a..cff5e57c1 100644 --- a/tests/integration/prisma-schema-name-drift.test.ts +++ b/tests/integration/prisma-schema-name-drift.test.ts @@ -146,9 +146,6 @@ describe("environment context — the write the enum drift blocked", () => { create: { userId: user.id, date, - lat: 52.5, - lon: 13.4, - locationLabel: "Somewhere", source, tempMean: 12.5, fetchedAt: new Date(), @@ -174,9 +171,6 @@ describe("environment context — the write the enum drift blocked", () => { create: { userId: user.id, date: "2026-03-01", - lat: 52.5, - lon: 13.4, - locationLabel: "Somewhere", source: "HOME", fetchedAt: new Date(), }, @@ -200,9 +194,6 @@ describe("environment context — the write the enum drift blocked", () => { data: { userId: user.id, date: "2026-04-01", - lat: 52.5, - lon: 13.4, - locationLabel: "Somewhere", source: "TRAVEL", fetchedAt: new Date(), }, From 3786deb50313d5d0aab9ecb7e9afae85d46586c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 21:49:02 +0200 Subject: [PATCH 009/127] feat(api): publish the v1.43 contract with routes that answer 501 until filled Every new endpoint of the contract exists, wrapped in apiHandler, with its authentication, module gate and request validation, and answers 501 not_implemented until its behaviour lands. The OpenAPI document carries the new operations and schemas and the additive response fields on the changed routes. The four server feature flags are published as false. contract-stub-inventory.test.ts registers the stubbed routes; each one leaves the register with the change that fills it. The route, sharing, session, security, module-gate and outcome inventories name the new routes deliberately, and the error-code catalogue lists the codes the routes emit today. --- docs/api/openapi.yaml | 3605 +++++++++++++++-- src/__tests__/contract-stub-inventory.test.ts | 121 + .../openapi-route-coverage-guard.test.ts | 5 + ...openapi-security-declaration-guard.test.ts | 2 + src/__tests__/session-surface-guard.test.ts | 3 + src/__tests__/sharing-surface-guard.test.ts | 15 +- ...en-outcome-response-consumer-guard.test.ts | 18 + .../module-route-gate-inventory.test.ts | 8 + .../admin/maintenance/pulse-day-mean/route.ts | 39 + src/app/api/admin/support-report/route.ts | 17 + src/app/api/anamnesis/audit-c/[id]/route.ts | 21 + src/app/api/anamnesis/audit-c/route.ts | 47 + .../life-circumstances/ai-sections/route.ts | 33 + .../life-circumstances/confirm/route.ts | 28 + .../api/anamnesis/life-circumstances/route.ts | 19 + src/app/api/auth/claim/sso/route.ts | 37 + src/app/api/auth/me/ai-usage/route.ts | 42 + src/app/api/auth/me/route.ts | 10 +- src/app/api/biomarkers/[id]/merge/route.ts | 62 + src/app/api/dawarich/connect/route.ts | 44 + src/app/api/dawarich/disconnect/route.ts | 32 + src/app/api/dawarich/places/route.ts | 32 + src/app/api/dawarich/settings/route.ts | 32 + src/app/api/dawarich/status/route.ts | 18 + src/app/api/dawarich/sync/route.ts | 32 + src/app/api/dawarich/test/route.ts | 15 + .../travel/suggestions/[id]/dismiss/route.ts | 23 + .../environment/travel/suggestions/route.ts | 19 + src/app/api/labs/[id]/links/route.ts | 50 + .../[id]/ingredients/extract/route.ts | 32 + .../api/medications/[id]/ingredients/route.ts | 50 + src/app/api/medications/[id]/links/route.ts | 50 + src/app/api/nutrients/catalog/route.ts | 22 + src/lib/day/wire-schemas.ts | 42 + src/lib/http/not-implemented.ts | 34 + src/lib/openapi/error-codes.ts | 12 + src/lib/openapi/routes/account-sharing.ts | 33 +- src/lib/openapi/routes/ai-runs.ts | 5 +- src/lib/openapi/routes/ai-usage.ts | 118 + src/lib/openapi/routes/auth.ts | 59 + src/lib/openapi/routes/awards.ts | 7 + src/lib/openapi/routes/biomarkers.ts | 85 +- src/lib/openapi/routes/cycle.ts | 41 +- src/lib/openapi/routes/dashboard.ts | 8 + src/lib/openapi/routes/dawarich.ts | 315 ++ src/lib/openapi/routes/documents.ts | 29 +- src/lib/openapi/routes/encounters.ts | 6 +- src/lib/openapi/routes/illness.ts | 6 +- src/lib/openapi/routes/index.ts | 13 + src/lib/openapi/routes/insights/schemas.ts | 15 + src/lib/openapi/routes/integrations.ts | 7 + src/lib/openapi/routes/labs.ts | 6 +- src/lib/openapi/routes/life-circumstances.ts | 356 ++ src/lib/openapi/routes/measurements.ts | 24 + src/lib/openapi/routes/medications/index.ts | 5 + .../openapi/routes/medications/ingredients.ts | 148 + src/lib/openapi/routes/medications/schemas.ts | 15 + src/lib/openapi/routes/mood.ts | 7 + src/lib/openapi/routes/nutrients.ts | 51 + src/lib/openapi/routes/ocr.ts | 26 + src/lib/openapi/routes/profile.ts | 11 +- src/lib/openapi/routes/relation-links.ts | 142 + src/lib/openapi/routes/shared.ts | 13 + src/lib/openapi/routes/trend-verdict.ts | 140 + src/lib/openapi/routes/vaccinations.ts | 6 +- src/lib/validations/ai-usage.ts | 27 + src/lib/validations/biomarker-merge.ts | 45 + src/lib/validations/claim-sso.ts | 17 + src/lib/validations/dawarich.ts | 48 + src/lib/validations/life-circumstances.ts | 221 + src/lib/validations/medication-ingredients.ts | 52 + src/lib/validations/nutrients.ts | 65 +- src/lib/validations/pulse-day-mean.ts | 14 + src/lib/validations/relation-links.ts | 39 + 74 files changed, 6614 insertions(+), 282 deletions(-) create mode 100644 src/__tests__/contract-stub-inventory.test.ts create mode 100644 src/app/api/admin/maintenance/pulse-day-mean/route.ts create mode 100644 src/app/api/admin/support-report/route.ts create mode 100644 src/app/api/anamnesis/audit-c/[id]/route.ts create mode 100644 src/app/api/anamnesis/audit-c/route.ts create mode 100644 src/app/api/anamnesis/life-circumstances/ai-sections/route.ts create mode 100644 src/app/api/anamnesis/life-circumstances/confirm/route.ts create mode 100644 src/app/api/anamnesis/life-circumstances/route.ts create mode 100644 src/app/api/auth/claim/sso/route.ts create mode 100644 src/app/api/auth/me/ai-usage/route.ts create mode 100644 src/app/api/biomarkers/[id]/merge/route.ts create mode 100644 src/app/api/dawarich/connect/route.ts create mode 100644 src/app/api/dawarich/disconnect/route.ts create mode 100644 src/app/api/dawarich/places/route.ts create mode 100644 src/app/api/dawarich/settings/route.ts create mode 100644 src/app/api/dawarich/status/route.ts create mode 100644 src/app/api/dawarich/sync/route.ts create mode 100644 src/app/api/dawarich/test/route.ts create mode 100644 src/app/api/environment/travel/suggestions/[id]/dismiss/route.ts create mode 100644 src/app/api/environment/travel/suggestions/route.ts create mode 100644 src/app/api/labs/[id]/links/route.ts create mode 100644 src/app/api/medications/[id]/ingredients/extract/route.ts create mode 100644 src/app/api/medications/[id]/ingredients/route.ts create mode 100644 src/app/api/medications/[id]/links/route.ts create mode 100644 src/app/api/nutrients/catalog/route.ts create mode 100644 src/lib/http/not-implemented.ts create mode 100644 src/lib/openapi/routes/ai-usage.ts create mode 100644 src/lib/openapi/routes/dawarich.ts create mode 100644 src/lib/openapi/routes/life-circumstances.ts create mode 100644 src/lib/openapi/routes/medications/ingredients.ts create mode 100644 src/lib/openapi/routes/relation-links.ts create mode 100644 src/lib/openapi/routes/trend-verdict.ts create mode 100644 src/lib/validations/ai-usage.ts create mode 100644 src/lib/validations/biomarker-merge.ts create mode 100644 src/lib/validations/claim-sso.ts create mode 100644 src/lib/validations/dawarich.ts create mode 100644 src/lib/validations/life-circumstances.ts create mode 100644 src/lib/validations/medication-ingredients.ts create mode 100644 src/lib/validations/pulse-day-mean.ts create mode 100644 src/lib/validations/relation-links.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c45c0efaf..43407e243 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -456,6 +456,12 @@ paths: type: string required: true responses: + "200": + description: "v1.43 — with `Prefer: return=representation`: what the delete changed." + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDeleteOutcomeEnvelope" "204": description: Soft-deleted (no body). "401": *a1 @@ -620,6 +626,12 @@ paths: type: string required: true responses: + "200": + description: "v1.43 — with `Prefer: return=representation`: what the delete changed." + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDeleteOutcomeEnvelope" "204": description: Soft-deleted (no body). "401": *a1 @@ -1852,6 +1864,55 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/auth/claim/sso: + post: + security: [] + tags: + - Auth + summary: Claim a managed profile through single sign-on (v1.43) + description: "Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and + answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's + subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email + another account holds, is refused there. Works on a single-sign-on-only instance. 404 `profile_claim.invalid` + (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` + (username) or `auth.already_authenticated`; rate-limited per source address like the password claim." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileClaimSsoRequest" + responses: + "200": + description: Continue at `redirectTo`. + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileClaimSsoEnvelope" + "401": *a1 + "404": + description: "`profile_claim.invalid` or `profile_claim.sso_unavailable`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "`profile_claim.taken` or `auth.already_authenticated`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": &a30 + description: "Not implemented on this server yet: the contract is published, the effect is not. `meta.errorCode` = + `not_implemented`, `meta.operation` names the operation. Authentication, module gates and request validation + already answer as documented. A client reaches this only by ignoring the server feature flag that guards the + operation." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/auth/claim/preview: post: security: [] @@ -15110,7 +15171,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a38 + "202": &a39 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -15287,7 +15348,9 @@ paths: "401": *a1 "403": *a11 "404": &a28 - description: Biomarker not found (or owned by another user). + description: "Biomarker not found (or owned by another user). `meta.errorCode` = `labs.biomarker.notFound`. Since v1.43 + a marker that was merged into another answers with `meta.mergedInto` naming the marker it went into: drop + the cached marker and open that one." content: application/json: schema: @@ -15332,8 +15395,10 @@ paths: tags: - Labs summary: Delete a biomarker - description: "Hard-deletes the catalog definition. The `onDelete: SetNull` FK unlinks every reading (they keep their - legacy `analyte` / `unit`), so the history survives. Audits as `biomarker.delete`." + description: Hard-deletes the catalog definition together with every reading filed under it, in one transaction. Since + v1.43 it needs the same record authority as the edit (manage on the labs section), so a manager of a shared + record may delete and the owner is unaffected. Audits as `biomarker.delete` with the marker's name, unit and the + number of readings removed. parameters: - in: path name: id @@ -15348,9 +15413,47 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 + "403": *a11 "404": *a28 "422": *a4 "429": *a5 + /api/biomarkers/{id}/merge: + post: + tags: + - Labs + summary: Merge markers into this one (v1.43) + description: Moves every reading of the source markers onto the marker in the path, converting units where the analyte's + conversion table knows them, turns the source names into aliases and deletes the sources, in one transaction. A + source in a unit without a known conversion refuses the whole merge with 422 `labs.biomarker.merge.unitMismatch` + (`meta.sources` names each `{ id, unit, targetUnit, reason }`) and nothing moves. 404 `labs.biomarker.notFound` + with `meta.ids` when the target or a source is missing; 422 `labs.biomarker.merge.invalid` for a malformed body + or a target among the sources. No undo. Only when `features.biomarkerMerge` is true. + parameters: + - in: path + name: id + schema: + type: string + required: true + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BiomarkerMergeRequest" + responses: + "200": + description: The merge result (or the dry-run preview). + content: + application/json: + schema: + $ref: "#/components/schemas/MergeBiomarkersResponse" + "401": *a1 + "404": *a28 + "409": *a7 + "422": *a4 + "429": *a12 + "501": *a30 /api/encounters: get: tags: @@ -15563,7 +15666,7 @@ paths: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 "403": *a11 - "404": &a30 + "404": &a31 description: Visit not found (or owned by another user). content: application/json: @@ -15597,7 +15700,7 @@ paths: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a11 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -15622,7 +15725,7 @@ paths: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a11 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -15648,7 +15751,7 @@ paths: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a11 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -15678,7 +15781,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -15707,7 +15810,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/practitioners: @@ -15786,7 +15889,7 @@ paths: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 "403": *a11 - "404": &a31 + "404": &a32 description: Practitioner not found (or owned by another user). content: application/json: @@ -15820,7 +15923,7 @@ paths: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a11 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -15844,7 +15947,7 @@ paths: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a11 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -15870,7 +15973,7 @@ paths: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a11 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/vaccinations/custom: @@ -15914,7 +16017,7 @@ paths: $ref: "#/components/schemas/CreateCustomVaccineEnvelope" "401": *a1 "403": *a11 - "409": &a32 + "409": &a33 description: A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`). content: @@ -15951,13 +16054,13 @@ paths: $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" "401": *a1 "403": *a11 - "404": &a33 + "404": &a34 description: No live definition with this id on the record (`vaccination.custom.not-found`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a32 + "409": *a33 "422": *a4 "429": *a5 delete: @@ -15982,7 +16085,7 @@ paths: $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations: @@ -16084,7 +16187,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a11 - "404": &a34 + "404": &a35 description: Vaccination not found (or owned by another user). content: application/json: @@ -16119,7 +16222,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -16144,7 +16247,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -16169,7 +16272,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -16235,7 +16338,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -16293,7 +16396,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -16322,7 +16425,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes: @@ -16437,7 +16540,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": &a35 + "404": &a36 description: Illness episode not found (or owned by another user). content: application/json: @@ -16472,7 +16575,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -16496,7 +16599,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -16524,7 +16627,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a36 "409": *a7 "422": *a4 "429": *a5 @@ -16557,7 +16660,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -16608,7 +16711,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 post: @@ -16645,7 +16748,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -16672,7 +16775,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 /api/illness/insights: @@ -16888,7 +16991,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a11 - "404": &a36 + "404": &a37 description: Allergy not found (or owned by another user). content: application/json: @@ -16922,7 +17025,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a37 "422": *a4 "429": *a5 delete: @@ -16946,7 +17049,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a37 "422": *a4 "429": *a5 /api/family-history: @@ -17020,7 +17123,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": &a37 + "404": &a38 description: Family history entry not found (or owned by another user). content: application/json: @@ -17054,7 +17157,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a37 + "404": *a38 "422": *a4 "429": *a5 delete: @@ -17078,7 +17181,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a37 + "404": *a38 "422": *a4 "429": *a5 /api/documents/inbound: @@ -17743,7 +17846,7 @@ paths: required: true schema: type: string - - &a39 + - &a40 name: Prefer in: header required: false @@ -17768,9 +17871,9 @@ paths: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" - "202": *a38 + "202": *a39 "401": *a1 - "403": &a40 + "403": &a41 description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master @@ -17794,7 +17897,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a41 + "503": &a42 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -17824,7 +17927,7 @@ paths: required: true schema: type: string - - *a39 + - *a40 requestBody: required: false content: @@ -17860,12 +17963,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" - "202": *a38 + "202": *a39 "401": *a1 - "403": *a40 + "403": *a41 "422": *a4 "429": *a5 - "503": *a41 + "503": *a42 /api/documents/inbound/{id}/summary: post: tags: @@ -17915,7 +18018,7 @@ paths: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - - *a39 + - *a40 requestBody: required: false content: @@ -17951,12 +18054,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" - "202": *a38 + "202": *a39 "401": *a1 - "403": *a40 + "403": *a41 "422": *a4 "429": *a5 - "503": *a41 + "503": *a42 /api/documents/inbound/{id}/index: post: tags: @@ -17982,7 +18085,7 @@ paths: required: true schema: type: string - - *a39 + - *a40 requestBody: required: false content: @@ -18018,12 +18121,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a38 + "202": *a39 "401": *a1 - "403": *a40 + "403": *a41 "422": *a4 "429": *a5 - "503": *a41 + "503": *a42 /api/documents/inbound/{id}/chat: post: tags: @@ -18063,10 +18166,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a40 + "403": *a41 "422": *a4 "429": *a5 - "503": *a41 + "503": *a42 get: tags: - Documents @@ -18129,10 +18232,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a40 + "403": *a41 "422": *a4 "429": *a5 - "503": *a41 + "503": *a42 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -18239,7 +18342,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a43 + "409": &a44 description: A custom metric with this name already exists for the caller. content: application/json: @@ -18268,7 +18371,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a11 - "404": &a42 + "404": &a43 description: Custom metric not found (or owned by another user). content: application/json: @@ -18302,8 +18405,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a42 - "409": *a43 + "404": *a43 + "409": *a44 "422": *a4 "429": *a5 delete: @@ -18326,7 +18429,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a42 + "404": *a43 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -18370,7 +18473,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a11 - "404": *a42 + "404": *a43 "422": *a4 "429": *a5 post: @@ -18399,7 +18502,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a42 + "404": *a43 "409": *a7 "422": *a4 "429": *a12 @@ -18435,7 +18538,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a44 + "404": &a45 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -18468,7 +18571,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a44 + "404": *a45 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -18755,6 +18858,44 @@ paths: "401": *a1 "422": *a4 "429": *a5 + /api/nutrients/catalog: + get: + tags: + - Nutrients + summary: The nutrient catalogue (v1.43) + description: Every nutrient code with its canonical unit, label in the account language, input units and upper intake + level. Behind the opt-in `nutrients` module (403 `module.disabled` when off). + responses: + "200": + description: The catalogue. + content: + application/json: + schema: + $ref: "#/components/schemas/NutrientCatalogEnvelope" + "401": *a1 + "403": &a46 + description: >- + The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not + retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is + on. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a30 /api/nutrients/batch: post: tags: @@ -18880,26 +19021,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a45 - description: >- - The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not - retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is - on. - - - Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). - Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections - do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it - is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's - audit trail instead. On the cookie transport the same code answers one further case: a session that has - entered a shared record and sends a request without its record-context assertion, which is a client older - than the fence — leave the record and reload. A request that names no record but the caller's own never - reaches this response, so a client that never switches and never sends the per-request account selector will - not see it. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a46 "422": *a4 "429": *a5 /api/nutrients/daily: @@ -18935,7 +19057,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a45 + "403": *a46 "422": *a4 "429": *a5 /api/nutrients/water: @@ -18964,7 +19086,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a45 + "403": *a46 "409": *a7 "422": *a4 "429": *a5 @@ -19466,26 +19588,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a46 + "400": &a47 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a47 + "413": &a48 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a48 + "415": &a49 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a49 + "422": &a50 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -19671,11 +19793,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a46 + "400": *a47 "401": *a1 - "413": *a47 - "415": *a48 - "422": *a49 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -19818,11 +19940,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a46 + "400": *a47 "401": *a1 - "413": *a47 - "415": *a48 - "422": *a49 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -19983,11 +20105,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a46 + "400": *a47 "401": *a1 - "413": *a47 - "415": *a48 - "422": *a49 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -20063,21 +20185,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a50 + "422": &a51 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a51 + "429": &a52 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a52 + "502": &a53 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -20122,11 +20244,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a46 + "400": *a47 "401": *a1 - "413": *a47 - "415": *a48 - "422": *a49 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -20202,9 +20324,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a50 - "429": *a51 - "502": *a52 + "422": *a51 + "429": *a52 + "502": *a53 /api/strava/credentials: get: tags: @@ -20240,11 +20362,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a46 + "400": *a47 "401": *a1 - "413": *a47 - "415": *a48 - "422": *a49 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -20322,9 +20444,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a50 - "429": *a51 - "502": *a52 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/status: get: tags: @@ -20360,7 +20482,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a51 + "429": *a52 /api/integrations/fitbit/test: post: tags: @@ -20378,9 +20500,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a50 - "429": *a51 - "502": *a52 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/google-health/resume: post: tags: @@ -20397,7 +20519,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a51 + "429": *a52 /api/integrations/google-health/test: post: tags: @@ -20430,9 +20552,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a50 - "429": *a51 - "502": *a52 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/whoop/resume: post: tags: @@ -20449,7 +20571,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a51 + "429": *a52 /api/integrations/whoop/test: post: tags: @@ -20466,9 +20588,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a50 - "429": *a51 - "502": *a52 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/withings/resume: post: tags: @@ -20486,7 +20608,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a51 + "429": *a52 /api/integrations/withings/test: post: tags: @@ -20504,8 +20626,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a50 - "429": *a51 + "422": *a51 + "429": *a52 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -20907,7 +21029,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a53 + "403": &a54 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -20932,7 +21054,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "422": *a4 "429": *a5 post: @@ -21034,7 +21156,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "404": description: No such invitation for this account. content: @@ -21078,7 +21200,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "404": description: No such grant on this account's record. content: @@ -21119,7 +21241,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "404": description: No such grant held by this account. content: @@ -21501,7 +21623,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -21881,7 +22003,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a54 + "403": &a55 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -21914,7 +22036,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a54 + "403": *a55 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -21941,7 +22063,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a54 + "403": *a55 "422": *a4 "429": *a5 post: @@ -21974,7 +22096,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a54 + "403": *a55 "413": description: Body exceeds 16 KiB. content: @@ -22017,7 +22139,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a54 + "403": *a55 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -22076,7 +22198,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a55 + - &a56 name: family in: path required: true @@ -22120,7 +22242,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a55 + - *a56 requestBody: required: true content: @@ -22693,13 +22815,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a56 + "403": &a57 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a57 + "404": &a58 description: No stored copy with this id. content: application/json: @@ -22773,8 +22895,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a56 - "404": *a57 + "403": *a57 + "404": *a58 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -22818,7 +22940,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a56 + "403": *a57 /api/admin/backups/restores/{jobId}: get: tags: @@ -22841,7 +22963,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a56 + "403": *a57 "404": description: No restore job with this id. content: @@ -22860,7 +22982,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a58 + security: &a59 - cookieAuth: [] responses: "200": @@ -22870,7 +22992,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a59 + "403": &a60 description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the Documents module is off." @@ -22893,9 +23015,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a58 + security: *a59 parameters: - - &a60 + - &a61 name: system in: path required: true @@ -22934,8 +23056,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a59 - "404": &a61 + "403": *a60 + "404": &a62 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -22954,7 +23076,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a62 + "502": &a63 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -22973,9 +23095,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a58 + security: *a59 parameters: - - *a60 + - *a61 responses: "200": description: Removed, or there was nothing to remove. @@ -22984,7 +23106,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a59 + "403": *a60 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -22995,9 +23117,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a58 + security: *a59 parameters: - - *a60 + - *a61 responses: "200": description: The source answered. @@ -23006,9 +23128,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": &a63 + "403": *a60 + "404": *a62 + "422": &a64 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -23017,7 +23139,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a62 + "502": *a63 /api/documents/sources/{system}/tags: get: tags: @@ -23025,9 +23147,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a58 + security: *a59 parameters: - - *a60 + - *a61 responses: "200": description: The tags. @@ -23036,11 +23158,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": *a63 + "403": *a60 + "404": *a62 + "422": *a64 "429": *a5 - "502": *a62 + "502": *a63 /api/documents/sources/{system}/search: get: tags: @@ -23054,9 +23176,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a58 + security: *a59 parameters: - - *a60 + - *a61 - name: q in: query required: false @@ -23097,11 +23219,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": *a63 + "403": *a60 + "404": *a62 + "422": *a64 "429": *a5 - "502": *a62 + "502": *a63 /api/documents/sources/{system}/import: post: tags: @@ -23120,9 +23242,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a58 + security: *a59 parameters: - - *a60 + - *a61 requestBody: required: true content: @@ -23180,7 +23302,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a59 + "403": *a60 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -23211,7 +23333,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a63 + "422": *a64 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -23219,7 +23341,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a62 + "502": *a63 /api/symptoms/definitions: get: tags: @@ -23243,7 +23365,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a64 + "403": &a65 description: >- `illness.disabled`: the record has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module. @@ -23286,7 +23408,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 + "403": *a65 "409": *a7 "422": *a4 "429": *a5 @@ -23316,8 +23438,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 - "404": &a65 + "403": *a65 + "404": &a66 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -23351,8 +23473,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 - "404": *a65 + "403": *a65 + "404": *a66 "422": *a4 "429": *a5 /api/symptoms/events: @@ -23401,7 +23523,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a64 + "403": *a65 "422": *a4 "429": *a5 post: @@ -23427,8 +23549,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": *a65 + "403": *a65 + "404": *a66 "409": *a7 "422": *a4 "429": *a5 @@ -23458,8 +23580,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": &a66 + "403": *a65 + "404": &a67 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -23486,8 +23608,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": *a66 + "403": *a65 + "404": *a67 "422": *a4 "429": *a5 /api/day/index: @@ -23637,7 +23759,7 @@ paths: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 - "403": &a67 + "403": &a68 description: >- `module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it off server-wide. @@ -23674,7 +23796,7 @@ paths: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 - "403": *a67 + "403": *a68 "422": *a4 "429": *a5 /api/life-events: @@ -23692,7 +23814,7 @@ paths: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 - "403": &a68 + "403": &a69 description: >- Owner-only in v1.42: no share reaches life events, not a `profile` share, a whole-record share or MANAGE, and not a guardian acting for a managed profile. @@ -23732,7 +23854,7 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 - "403": *a68 + "403": *a69 "409": *a7 "422": *a4 "429": *a12 @@ -23763,8 +23885,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "403": *a68 - "404": &a69 + "403": *a69 + "404": &a70 description: No such life event on this account. content: application/json: @@ -23791,8 +23913,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "403": *a68 - "404": *a69 + "403": *a69 + "404": *a70 "422": *a4 "429": *a5 /api/search: @@ -23841,6 +23963,640 @@ paths: "403": *a11 "422": *a4 "429": *a5 + /api/auth/me/ai-usage: + put: + tags: + - Profile + summary: Set the AI usage level (v1.43) + description: "Own account only: a cookie session or a full-scope token. Answers the resolved `AiUsageState`. 422 + `ai.usage.invalid` for a malformed body; 422 `ai.usage.aboveCeiling` with `meta.maxLevel` for a level above the + operator's ceiling; 429 `ai.usage.rateLimited` after 30 changes in an hour." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AiUsagePutRequest" + responses: + "200": + description: The resolved AI usage state after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/PutAiUsageResponse" + "401": *a1 + "422": + description: "`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 + "501": *a30 + /api/medications/{id}/ingredients: + put: + tags: + - Medications + summary: Replace a supplement's composition (v1.43) + description: "Stores the composition per unit in canonical units and re-counts every taken dose of this medication into + the day's micronutrients (`recompute.queued`). Needs the `nutrients` module (403 `module.disabled`, + `meta.module: \"nutrients\"`). 404 `medications.notFound`; 422 `medications.ingredients.invalid` (body, + duplicate or unknown code); 422 `medications.ingredients.unitMismatch` with `meta.index` and + `meta.allowedUnits`." + parameters: + - in: path + name: id + schema: + type: string + required: true + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationIngredientsPutRequest" + responses: + "200": + description: The stored composition. + content: + application/json: + schema: + $ref: "#/components/schemas/PutMedicationIngredientsResponse" + "401": *a1 + "403": *a19 + "404": + description: Medication not found. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": *a7 + "422": *a4 + "429": *a5 + "501": *a30 + /api/medications/{id}/ingredients/extract: + post: + tags: + - Medications + summary: Read a supplement label (v1.43) + description: Multipart `file` (JPEG, PNG, HEIC, WebP or PDF, at most 10 MB). 202 with an AI run; poll `GET + /api/ai-runs/{id}` (kind `SUPPLEMENT_LABEL_EXTRACT`, result `SupplementLabelExtractResult`). Needs + `ai.capabilities.supplementLabel.available` (refused like every AI capability, reason `usage_level` included) + and the `nutrients` module. 422 `medications.ingredients.extract.fileType`, 413 + `medications.ingredients.extract.fileTooLarge`, 429 `medications.ingredients.extract.rateLimited` (six an hour). + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + required: + - file + responses: + "202": *a39 + "401": *a1 + "403": *a19 + "413": + description: "`medications.ingredients.extract.fileTooLarge`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a30 + "503": + description: "`aiRuns.workerUnavailable`: the run could not be handed to the background worker. Nothing was read and + nothing was charged; try again." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/anamnesis/life-circumstances: + get: + tags: + - Anamnesis + summary: Life circumstances (v1.43) + description: The life-circumstance sections of the record. Delegates read it under the `profile` grant, like the facts. + responses: + "200": + description: The life circumstances. + content: + application/json: + schema: + $ref: "#/components/schemas/LifeCircumstancesEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + "501": *a30 + /api/anamnesis/life-circumstances/ai-sections: + patch: + tags: + - Anamnesis + summary: Switch life-circumstance sections for the AI (v1.43) + description: Sections not named keep their state. Own account only. 422 `anamnesis.lifeSections.invalid`; 409 + `about_me_conflict` for a stale `baseUpdatedAt`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LifeAiSectionsPatchRequest" + responses: + "200": + description: The switches after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/LifeAiSectionsEnvelope" + "401": *a1 + "409": + description: "`about_me_conflict`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a30 + /api/anamnesis/life-circumstances/confirm: + post: + tags: + - Anamnesis + summary: Confirm a life-circumstance section is still true (v1.43) + description: Stores the confirmation instant only, never a new revision. Own account only. 422 + `anamnesis.lifeConfirm.invalid`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LifeConfirmRequest" + responses: + "200": + description: The confirmed section. + content: + application/json: + schema: + $ref: "#/components/schemas/LifeConfirmEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + "501": *a30 + /api/anamnesis/audit-c: + post: + tags: + - Anamnesis + summary: Record an AUDIT-C (v1.43) + description: Scores the three items on the server. A replay with the same `externalId` answers 200 with the stored + result. 422 `anamnesis.auditC.invalid`; 429 `anamnesis.auditC.rateLimited` after 30 in an hour. + parameters: + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AuditCRequest" + responses: + "200": + description: A replay of an entry already stored. + content: + application/json: + schema: + $ref: "#/components/schemas/AuditCReplayEnvelope" + "201": + description: The stored and scored entry. + content: + application/json: + schema: + $ref: "#/components/schemas/AuditCCreatedEnvelope" + "401": *a1 + "409": *a7 + "422": *a4 + "429": *a5 + "501": *a30 + /api/anamnesis/audit-c/{id}: + delete: + tags: + - Anamnesis + summary: Remove an AUDIT-C entry (v1.43) + description: Soft delete. 404 `anamnesis.auditC.notFound`. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The removed entry. + content: + application/json: + schema: + $ref: "#/components/schemas/AuditCRemovedEnvelope" + "401": *a1 + "404": + description: "`anamnesis.auditC.notFound`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a30 + /api/dawarich/connect: + post: + tags: + - Integrations + summary: Connect Dawarich (v1.43) + description: Checks the key against the instance, stores the connection sealed and starts a 30-day backfill. 422 + `dawarich.invalid`, `private_origin_not_approved`, `dawarich.credentials_rejected` or `dawarich.unreachable`; + 429 `dawarich.rate_limited` (ten a minute). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DawarichConnectRequest" + responses: + "200": + description: Connected. + content: + application/json: + schema: + $ref: "#/components/schemas/DawarichConnectEnvelope" + "401": *a1 + "403": *a19 + "422": *a4 + "429": *a5 + "501": *a30 + /api/dawarich/status: + get: + tags: + - Integrations + summary: Dawarich connection status (v1.43) + responses: + "200": + description: The connection. + content: + application/json: + schema: + $ref: "#/components/schemas/DawarichStatusEnvelope" + "401": *a1 + "403": *a19 + "422": *a4 + "429": *a5 + "501": *a30 + /api/dawarich/places: + get: + tags: + - Integrations + summary: Places on the Dawarich instance (v1.43) + description: Read live from Dawarich to pick the home place (at most 200, filtered by `q`); never stored. 409 + `dawarich.not_connected`; 502 `dawarich.unreachable`. + parameters: + - in: query + name: q + schema: + type: string + maxLength: 80 + responses: + "200": + description: The places. + content: + application/json: + schema: + $ref: "#/components/schemas/DawarichPlacesEnvelope" + "401": *a1 + "403": *a19 + "409": + description: "`dawarich.not_connected`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a30 + "502": + description: "`dawarich.unreachable`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/dawarich/settings: + patch: + tags: + - Integrations + summary: Dawarich home place and country (v1.43) + description: 422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DawarichSettings" + responses: + "200": + description: The settings after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/DawarichSettingsEnvelope" + "401": *a1 + "403": *a19 + "422": *a4 + "429": *a5 + "501": *a30 + /api/dawarich/test: + post: + tags: + - Integrations + summary: Probe the Dawarich connection (v1.43) + description: Answers the shared integration probe shape; a failed probe is an error status. The probe classes are those + of the other `/test` routes. + responses: + "200": + description: The probe reached the instance. + content: + application/json: + schema: + $ref: "#/components/schemas/DawarichProbeEnvelope" + "401": *a1 + "403": *a19 + "422": *a4 + "429": *a5 + "501": *a30 + /api/dawarich/sync: + post: + tags: + - Integrations + summary: Fetch Dawarich daily values again (v1.43) + description: From `fromDate` (at most 365 days back). 409 `dawarich.not_connected`; 422 `dawarich.invalid`. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + fromDate: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + additionalProperties: false + responses: + "202": + description: Queued. + content: + application/json: + schema: + $ref: "#/components/schemas/DawarichSyncEnvelope" + "401": *a1 + "403": *a19 + "409": + description: "`dawarich.not_connected`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a30 + /api/dawarich/disconnect: + post: + tags: + - Integrations + summary: Disconnect Dawarich (v1.43) + description: "Forgets the connection; with `deleteData: true` the stored days go too." + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + deleteData: + default: false + type: boolean + additionalProperties: false + responses: + "200": + description: Disconnected. + content: + application/json: + schema: + $ref: "#/components/schemas/DawarichDisconnectEnvelope" + "401": *a1 + "403": *a19 + "422": *a4 + "429": *a5 + "501": *a30 + /api/environment/travel/suggestions: + get: + tags: + - Environment + summary: Suggested trips from Dawarich travel days (v1.43) + responses: + "200": + description: The suggestions. + content: + application/json: + schema: + $ref: "#/components/schemas/TravelSuggestionsEnvelope" + "401": *a1 + "403": *a19 + "422": *a4 + "429": *a5 + "501": *a30 + /api/environment/travel/suggestions/{id}/dismiss: + post: + tags: + - Environment + summary: Dismiss a suggested trip (v1.43) + description: 404 `environment.travel_suggestion.not_found`. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Dismissed. + content: + application/json: + schema: + $ref: "#/components/schemas/TravelSuggestionDismissEnvelope" + "401": *a1 + "403": *a19 + "404": + description: "`environment.travel_suggestion.not_found`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a30 + /api/medications/{id}/links: + post: + tags: + - Links + summary: File records against a medication (v1.43) + description: Idempotent; at most 100 ids. An id that names nothing this account owns comes back in `unknown` instead of + failing. 404 when the medication is not found; 422 `medication.link.invalid` for a malformed body. Own account + only, as the other link routes. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationLinkRequest" + responses: + "200": + description: How many links changed, and which ids were unknown. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationLinkResponse" + "401": *a1 + "404": + description: The medication was not found. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a30 + delete: + tags: + - Links + summary: Unfile records from a medication (v1.43) + description: Idempotent; at most 100 ids. An id that names nothing this account owns comes back in `unknown` instead of + failing. 404 when the medication is not found; 422 `medication.link.invalid` for a malformed body. Own account + only, as the other link routes. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationLinkRequest" + responses: + "200": + description: How many links changed, and which ids were unknown. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationUnlinkResponse" + "401": *a1 + "404": + description: The medication was not found. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a30 + /api/labs/{id}/links: + post: + tags: + - Links + summary: File records against a lab result (v1.43) + description: Idempotent; at most 100 ids. An id that names nothing this account owns comes back in `unknown` instead of + failing. 404 when the lab result is not found; 422 `labs.link.invalid` for a malformed body. Own account only, + as the other link routes. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LabResultLinkRequest" + responses: + "200": + description: How many links changed, and which ids were unknown. + content: + application/json: + schema: + $ref: "#/components/schemas/LabResultLinkResponse" + "401": *a1 + "404": + description: The lab result was not found. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a30 + delete: + tags: + - Links + summary: Unfile records from a lab result (v1.43) + description: Idempotent; at most 100 ids. An id that names nothing this account owns comes back in `unknown` instead of + failing. 404 when the lab result is not found; 422 `labs.link.invalid` for a malformed body. Own account only, + as the other link routes. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LabResultLinkRequest" + responses: + "200": + description: How many links changed, and which ids were unknown. + content: + application/json: + schema: + $ref: "#/components/schemas/LabResultUnlinkResponse" + "401": *a1 + "404": + description: The lab result was not found. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a30 /api/bugreport: post: security: [] @@ -24717,6 +25473,24 @@ components: server-side recovery. required: - passphrase + ProfileClaimSsoRequest: + type: object + properties: + token: + type: string + minLength: 1 + maxLength: 80 + username: + type: string + minLength: 3 + maxLength: 30 + pattern: ^[a-zA-Z0-9_-]+$ + required: + - token + - username + additionalProperties: false + description: "`token` is the `hlp_` token from the handover link, in the body; `username` follows the registration rules + and may not start with `managed-`." ProfileClaimPreviewRequest: type: object properties: @@ -30541,6 +31315,26 @@ components: description: Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. Set `hidden` to drop / restore the marker in the active list + lab-entry pickers. A rename that collides with another of the caller's markers is rejected 409. + BiomarkerMergeRequest: + type: object + properties: + sourceIds: + minItems: 1 + maxItems: 10 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + dryRun: + default: false + type: boolean + required: + - sourceIds + additionalProperties: false + description: Fold `sourceIds` (1..10, unique, without the target) into the marker in the path. `dryRun` (default false) + answers the same result and writes nothing; a client shows it before asking to confirm, because a merge cannot + be undone. CreateEncounterRequest: type: object properties: @@ -34290,6 +35084,213 @@ components: maxLength: 2000 - type: "null" additionalProperties: false + AiUsagePutRequest: + type: object + properties: + level: + anyOf: + - type: string + enum: + - frugal + - standard + - full + - type: "null" + required: + - level + additionalProperties: false + description: Set the account's own AI usage level; `null` returns to the instance default. A level above `maxLevel` is + refused, never capped silently. + MedicationIngredientsPutRequest: + type: object + properties: + ingredients: + maxItems: 40 + type: array + items: + type: object + properties: + nutrient: + type: string + minLength: 1 + maxLength: 40 + amount: + type: number + exclusiveMinimum: 0 + unit: + type: string + minLength: 1 + maxLength: 8 + required: + - nutrient + - amount + - unit + additionalProperties: false + source: + default: MANUAL + type: string + enum: + - MANUAL + - LABEL_AI + required: + - ingredients + additionalProperties: false + description: "Replace the composition: 0..40 entries, one per nutrient code, `amount` > 0 in one of the code's + `inputUnits` (`GET /api/nutrients/catalog`). `source` is `MANUAL` (default) or `LABEL_AI` after a label proposal + was reviewed." + LifeAiSectionsPatchRequest: + type: object + properties: + sections: + type: object + propertyNames: + type: string + enum: + - SMOKING_STATUS + - ALCOHOL_PATTERN + - SHIFT_SCHEDULE + - ACTIVITY + - WORK + - HABITS + - GOALS + additionalProperties: + type: boolean + baseUpdatedAt: + 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: + - sections + additionalProperties: false + LifeConfirmRequest: + type: object + properties: + section: + type: string + enum: + - tobacco + - alcohol + - activity + - work + - habits + - goals + required: + - section + additionalProperties: false + AuditCRequest: + type: object + properties: + items: + type: array + prefixItems: + - type: integer + minimum: 0 + maximum: 4 + - type: integer + minimum: 0 + maximum: 4 + - type: integer + minimum: 0 + maximum: 4 + takenAt: + 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)))$ + tz: + type: string + minLength: 1 + maxLength: 64 + locale: + type: string + minLength: 2 + maxLength: 16 + source: + type: string + enum: + - WEB + - IOS + externalId: + type: string + minLength: 1 + maxLength: 128 + required: + - items + - source + additionalProperties: false + DawarichConnectRequest: + type: object + properties: + url: + type: string + maxLength: 2048 + format: uri + apiKey: + type: string + minLength: 1 + maxLength: 512 + required: + - url + - apiKey + additionalProperties: false + description: "`url` (http or https, at most 2048) and `apiKey` (1..512); both sealed at rest. A private host needs its + origin in `DAWARICH_PRIVATE_ORIGINS`." + DawarichSettings: + type: object + properties: + homePlaceId: + anyOf: + - type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + - type: "null" + homeCountry: + anyOf: + - type: string + pattern: ^[A-Z]{2}$ + - type: "null" + additionalProperties: false + description: The home place (an id on the Dawarich instance) and the home country (ISO 3166-1 alpha-2). Without a home + country the most frequent country of the last 365 days is used, and without one there is no travel day. + MedicationLinkRequest: + type: object + properties: + targetKind: + type: string + enum: + - conditionEpisode + - document + - encounter + targetIds: + minItems: 1 + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + required: + - targetKind + - targetIds + additionalProperties: false + LabResultLinkRequest: + type: object + properties: + targetKind: + type: string + enum: + - document + - encounter + targetIds: + minItems: 1 + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + required: + - targetKind + - targetIds + additionalProperties: false MedicationTreatmentClass: type: string enum: @@ -34503,26 +35504,28 @@ components: (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own may carry no code at all. ai — - ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable; ai_provider — - ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, - aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — - anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — - auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, - auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, - auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, - auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, - backup.key.missing, backup.payload.undecryptable, backup.restore.active, backup.section.missing, - backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — - coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, - coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, - coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, - coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, - coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — cycle.bulk.invalid, - cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, - cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, - cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, - cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; day — day.invalid; demo — - demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, + ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.invalid, + ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, + aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — + analytics.invalid_query; anamnesis — anamnesis.auditC.invalid, anamnesis.auditC.rateLimited, + anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue, + anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.admin.required, + auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, + auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required, + auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.stepup.required, + auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, + backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large; + biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded, + coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, + coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, + coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, + coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; + consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, + cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, + cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; + cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — + disable-coach.body.invalid_json; dawarich — dawarich.invalid, dawarich.rate_limited; day — day.invalid; + demo — demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, @@ -34550,12 +35553,12 @@ components: import.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — - labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound, - labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, - labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, - labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, - labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, - labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, + labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.merge.invalid, + labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, + labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, + labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, + labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, + labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found; managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, @@ -34565,24 +35568,24 @@ components: measurement.bulk-delete.invalid, measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; medication — medication.intake.import.invalid_format, - medication.intake.notTracked; medications — medications.category.invalid, + medication.intake.notTracked, medication.link.invalid; medications — medications.category.invalid, medications.category.limitReached, medications.category.unknown, medications.course.currentOpen, medications.course.invalid, medications.course.invalidRange, medications.course.notFound, medications.course.oneShot, medications.course.overlap, medications.course.windowRequired, - medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid, - medications.intake.bulk.too_large, medications.intake.force_slot.invalid, - medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed, - medications.intake.taken_at.before_start, medications.mirror.limit_exceeded; mentalHealth — - mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json, - modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, - mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp, - mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range, - mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json; - notification — notification.action.already_used, notification.action.expired, - notification.action.invalid, notification.action.module_disabled; nutrient — nutrient.batch.invalid, - nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, - nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, - onboarding.complete.invalid, onboarding.complete.recordNotFound, + medications.ingredients.invalid, medications.intake.bulk.apple_health_not_mirrored, + medications.intake.bulk.invalid, medications.intake.bulk.too_large, + medications.intake.force_slot.invalid, medications.intake.force_slot.occupied, + medications.intake.injection_site.disallowed, medications.intake.taken_at.before_start, + medications.mirror.limit_exceeded; mentalHealth — mentalHealth.rateLimited; module — module.disabled; + modules — modules.body.invalid_json, modules.invalid; mood — mood.bulk-delete.invalid, + mood.bulk.invalid, mood.bulk.too_large, mood.create.external_id_not_delegable, mood.create.invalid, + mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid, mood.not_found, + mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid; notification-prefs — + notification-prefs.body.invalid_json; notification — notification.action.already_used, + notification.action.expired, notification.action.invalid, notification.action.module_disabled; nutrient + — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, + nutrient.water.invalid, nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, + onboarding.answers.rateLimited, onboarding.complete.invalid, onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown, passkey.verification.failed; personal_records — @@ -34614,9 +35617,10 @@ components: — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, - modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, - private_origin_not_approved, private_origin_not_grantable, rate_limited_self, sync_in_progress, - too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" + modules_conflict, mood_tag_layout_conflict, not_configured, not_implemented, + notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable, + rate_limited_self, sync_in_progress, too_many_rows, unreadable_json, upstream_invalid_json, + vapid_not_configured" type: string capability: description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, @@ -34988,7 +35992,16 @@ components: type: object properties: data: - $ref: "#/components/schemas/CycleDayLogDTO" + allOf: + - $ref: "#/components/schemas/CycleDayLogDTO" + - type: object + properties: + restored: + description: v1.43 — starts given back because this edit cleared the flow on a start day. + type: array + items: + $ref: "#/components/schemas/CycleRestoredStart" + additionalProperties: false error: type: "null" meta: @@ -35001,6 +36014,52 @@ components: - data - error additionalProperties: false + CycleRestoredStart: + type: object + properties: + cycleId: + type: string + startDate: + type: string + required: + - cycleId + - startDate + additionalProperties: false + description: A cycle start that was folded into another start, or given back after the start it was folded into went away. + CycleDeleteOutcomeEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleDeleteOutcome" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleDeleteOutcome: + type: object + properties: + removedCycleId: + anyOf: + - type: string + - type: "null" + restored: + type: array + items: + $ref: "#/components/schemas/CycleRestoredStart" + required: + - removedCycleId + - restored + additionalProperties: false + description: "What a delete changed, answered with 200 when the request sent `Prefer: return=representation` (RFC 7240; + the response carries `Preference-Applied`). Without the header the delete answers 204 as before." CycleBulkEnvelope: type: object properties: @@ -35093,6 +36152,11 @@ components: anyOf: - $ref: "#/components/schemas/CycleDayLogDTO" - type: "null" + absorbed: + description: 'v1.43 — on `action: "start"`: a later start within the period length folded into this one, or null.' + anyOf: + - $ref: "#/components/schemas/CycleRestoredStart" + - type: "null" required: - cycle - dayLog @@ -37056,6 +38120,10 @@ components: type: string disableCoach: type: boolean + aiUsageLevel: + anyOf: + - type: string + - type: "null" coachPrefsJson: {} documentsAutoAiRead: type: boolean @@ -39808,6 +40876,16 @@ components: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE + lifeConfirmedJson: + anyOf: + - type: object + propertyNames: + type: string + maxLength: 64 + additionalProperties: + type: string + maxLength: 64 + - type: "null" emergencyBloodType: default: null anyOf: @@ -40938,6 +42016,7 @@ components: - GAD7 - WHO5 - SCI + - AUDIT_C locale: type: string minLength: 1 @@ -42045,6 +43124,29 @@ components: omissions there is a real, undeclared gap: roughly thirty of the models the backup plan marks as backed up have neither a reader here nor a restore branch yet, and `src/lib/export/backup-plan.ts` is the enumeration of which. `schemaVersion` is `2` on everything written today; the parser still accepts `1`." + ProfileClaimSsoEnvelope: + type: object + properties: + data: + type: object + properties: + redirectTo: + type: string + required: + - redirectTo + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false ProfileClaimPreviewEnvelope: type: object properties: @@ -42093,6 +43195,19 @@ components: additionalProperties: false description: Each guardian and the access they keep after the claim, as proposed. The new owner confirms or changes it after signing in. + methods: + description: "v1.43 — how this link can be redeemed: `password` and/or `sso`. Absent on older servers." + type: array + items: + type: string + enum: + - password + - sso + ssoProviderName: + description: v1.43 — the identity provider's display name for the "Continue with …" button; null without one. + anyOf: + - type: string + - type: "null" required: - displayName - expiresAt @@ -44784,6 +45899,21 @@ components: - count additionalProperties: false description: Summary over the returned points; all-zero when empty. + direction: + description: v1.43 — which way a change of this metric counts. + anyOf: + - $ref: "#/components/schemas/TrendVerdict" + - type: "null" + change: + description: v1.43 — first and last day value; null under two days. + anyOf: + - $ref: "#/components/schemas/SeriesChange" + - type: "null" + referenceLines: + description: v1.43 — lines to draw; empty when there is nothing to draw. + type: array + items: + $ref: "#/components/schemas/SeriesReferenceLine" required: - kind - unit @@ -44798,6 +45928,104 @@ components: the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets." + TrendVerdict: + type: object + properties: + direction: + type: string + enum: + - up-good + - up-bad + - neutral + - hold + description: "How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` the value is inside + its band so staying is progress, `neutral` no judgement." + targetPosition: + anyOf: + - type: string + enum: + - below + - inside + - above + - type: "null" + description: Where the value sits against the band; null without one. + basis: + type: string + enum: + - personal_target + - profile_default + - metric_default + description: "What the verdict was judged against: the person's own target, a profile-derived default (e.g. the + age-based blood pressure target), or the metric's default direction." + required: + - direction + - targetPosition + - basis + additionalProperties: false + description: Which way a change of a metric counts as progress for this person. The generalisation of `tiles.weightTrend`. + SeriesChange: + type: object + properties: + from: + type: number + to: + type: number + delta: + type: number + sentiment: + $ref: "#/components/schemas/TrendSentiment" + required: + - from + - to + - delta + - sentiment + additionalProperties: false + description: First and last day value in the window (by the metric's day statistic) and how the change counts. Null + under two days. + TrendSentiment: + type: string + enum: + - positive + - negative + - neutral + description: The verdict applied to one delivered change, with the noise floor applied. Colour the change from this. + SeriesReferenceLine: + type: object + properties: + value: + type: number + description: In the unit of the response. + kind: + type: string + enum: + - lower + - upper + source: + type: string + enum: + - personal + - clinical + appliesTo: + type: string + enum: + - value + - secondary + description: "`secondary` is the diastolic value of a blood pressure." + labelKey: + type: string + label: + type: string + description: In the account language. + required: + - value + - kind + - source + - appliesTo + - labelKey + - label + additionalProperties: false + description: A horizontal line to draw on the chart. A personal line replaces the clinical one for the same edge. Empty + list when there is nothing to draw. ListMeasurementsResponse: type: object properties: @@ -45976,6 +47204,12 @@ components: 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)))$ + benchmark: + description: v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null + otherwise. + anyOf: + - $ref: "#/components/schemas/RecordBenchmark" + - type: "null" required: - id - userId @@ -45992,6 +47226,27 @@ components: additionalProperties: false description: One detected personal best. Rows are written by the detection worker, never by a client — there is no write endpoint on this resource. + RecordBenchmark: + type: object + properties: + low: + type: number + high: + type: number + unit: + type: string + source: + type: string + sourceLabel: + type: string + required: + - low + - high + - unit + - source + - sourceLabel + additionalProperties: false + description: An age- and sex-aware comparison band from a cited norm, in the record's unit. ListWorkoutsResponse: type: object properties: @@ -47970,6 +49225,10 @@ components: description: v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate (the same math the low-stock notification engine runs, so the wire and the push can never disagree). NULL = inventory tracking off or no consuming cadence derivable; 0 = tracking on, supply ran out. Read-only — computed, not stored. + hasIngredients: + description: v1.43 — whether a composition per unit is stored (`GET /api/medications/{id}` carries it as `ingredients`). + Absent on older servers. + type: boolean required: - id - name @@ -48316,6 +49575,16 @@ components: - type: "null" description: v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate; NULL = tracking off or no consuming cadence. + ingredients: + description: v1.43 — the composition per unit, empty when none is stored. Absent on older servers. + type: array + items: + $ref: "#/components/schemas/MedicationIngredient" + related: + description: v1.43 — records related to this one, resolved on the server. Absent on older servers. + type: array + items: + $ref: "#/components/schemas/RelatedLink" required: - id - name @@ -48357,6 +49626,86 @@ components: additionalProperties: false description: Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics. + MedicationIngredient: + type: object + properties: + nutrient: + type: string + description: Nutrient catalogue code. + amountPerUnit: + type: number + description: Amount in one unit (tablet, capsule), canonical unit. + unit: + type: string + description: The catalogue's canonical unit for the code. + enteredAmount: + anyOf: + - type: number + - type: "null" + description: The amount as entered when it was entered in another unit. + enteredUnit: + anyOf: + - type: string + - type: "null" + description: The input unit it was entered in (e.g. `IU`). + labelKey: + type: string + label: + type: string + description: The nutrient's name in the account language. + required: + - nutrient + - amountPerUnit + - unit + - enteredAmount + - enteredUnit + - labelKey + - label + additionalProperties: false + description: What one unit of a medication (usually a supplement) contains. Every taken dose adds `amountPerUnit × + unitsPerDose` to the day's micronutrients under the source `SUPPLEMENT`. + RelatedLink: + type: object + properties: + kind: + type: string + enum: + - labResult + - encounter + - document + - conditionEpisode + - medication + - vaccination + id: + type: string + label: + type: string + date: + anyOf: + - type: string + - type: "null" + description: "`YYYY-MM-DD` or an ISO instant, as the record carries it." + href: + type: string + description: The web path of the related record, the same form as a push `url`. + via: + type: string + enum: + - direct + - encounter + - efficacyTarget + description: "`direct` for a stored link; `encounter` when the relation runs through a visit both are filed against; + `efficacyTarget` for a medication whose efficacy target is this lab marker." + required: + - kind + - id + - label + - date + - href + - via + additionalProperties: false + description: One record related to the one shown. Only kinds whose module is on and that the caller's sharing grant + covers are listed. Skip an unknown `kind`. ListMedicationCategoriesResponse: type: object properties: @@ -58628,6 +59977,13 @@ components: tiles: type: object properties: + directions: + description: v1.43 — per measurement type, which way a change counts. `weightTrend` stays. + type: object + propertyNames: + type: string + additionalProperties: + $ref: "#/components/schemas/TrendVerdict" summaries: type: object propertyNames: @@ -61744,6 +63100,16 @@ components: - compliance30 additionalProperties: false additionalProperties: false + direction: + description: v1.43 — which way a change of this target counts. + anyOf: + - $ref: "#/components/schemas/TrendVerdict" + - type: "null" + trendSentiment: + description: v1.43 — the verdict applied to this tile's change. + anyOf: + - $ref: "#/components/schemas/TrendSentiment" + - type: "null" required: - type - label @@ -64376,6 +65742,12 @@ components: additionalProperties: false description: "Mood against five health channels, keyed `sleep`, `steps`, `pulse`, `weight`, `bloodPressureSystolic`. Every key is always present; an empty channel carries `result: null` with no points." + patterns: + description: v1.43 — the patterns that passed the gate over the `?days` window, on the account's mood day. Empty means + none was found. + type: array + items: + $ref: "#/components/schemas/MoodPattern" required: - summary - heatmap @@ -64565,6 +65937,80 @@ components: - low - medium - high + MoodPattern: + type: object + properties: + id: + type: string + description: "Stable: `previous_day`, `weekend`, `best_weekday`, `notes`, `multiple_entries` or `delayed_tag:`." + kind: + type: string + enum: + - previous_day + - weekend + - best_weekday + - notes + - delayed_tag + - multiple_entries + strength: + type: string + enum: + - weak + - moderate + - strong + direction: + type: string + enum: + - higher + - lower + - none + values: + type: object + properties: + subject: + type: number + comparison: + type: number + delta: + type: number + weekday: + type: integer + minimum: 0 + maximum: 6 + tagKey: + type: string + tagLabel: + type: string + required: + - subject + - comparison + - delta + additionalProperties: false + sampleSize: + type: object + properties: + subject: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + comparison: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - subject + - comparison + additionalProperties: false + required: + - id + - kind + - strength + - direction + - values + - sampleSize + additionalProperties: false + description: A mood pattern that passed the server's gate (at least 8 days on each side, Welch test, false discovery + control). Skip an unknown `kind`. MoodPrognosisEnvelope: type: object properties: @@ -67693,7 +69139,16 @@ components: type: object properties: data: - $ref: "#/components/schemas/LabResultDetail" + allOf: + - $ref: "#/components/schemas/LabResultDetail" + - type: object + properties: + related: + description: v1.43 — records related to this one, resolved on the server. Absent on older servers. + type: array + items: + $ref: "#/components/schemas/RelatedLink" + additionalProperties: false error: type: "null" meta: @@ -68190,6 +69645,7 @@ components: - DOCUMENT_SUMMARY - DOCUMENT_SUGGEST - DOCUMENT_EXTRACT + - SUPPLEMENT_LABEL_EXTRACT documentId: anyOf: - type: string @@ -68210,12 +69666,14 @@ components: - $ref: "#/components/schemas/DocumentSummaryResponse" - $ref: "#/components/schemas/DocumentSuggestResponse" - $ref: "#/components/schemas/DocumentExtractRunResult" + - $ref: "#/components/schemas/SupplementLabelExtractResult" - type: "null" description: "Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`, - `DOCUMENT_EXTRACT` carries `DocumentExtractRunResult` (not the extract route's detail body). Decode by - `kind`; treat an unknown `kind` as a run this client did not start." + `DOCUMENT_EXTRACT` carries `DocumentExtractRunResult` (not the extract route's detail body), + `SUPPLEMENT_LABEL_EXTRACT` (v1.43) carries `SupplementLabelExtractResult`. Decode by `kind`; treat an + unknown `kind` as a run this client did not start." error: anyOf: - type: object @@ -68413,6 +69871,45 @@ components: anyOf: - type: string - type: "null" + biomarkerId: + description: v1.43 — the marker this row was matched to automatically (by name, by an alias of the account, or by a + catalogue abbreviation); null without a match. + anyOf: + - type: string + - type: "null" + matchKind: + description: v1.43 — how `biomarkerId` was found. + anyOf: + - type: string + enum: + - name + - alias + - catalog + - type: "null" + suggestions: + description: v1.43 — up to three markers to choose from when nothing matched; empty when `biomarkerId` is set. + maxItems: 3 + type: array + items: + type: object + properties: + biomarkerId: + type: string + name: + type: string + unit: + type: string + matchKind: + type: string + enum: + - catalog + - similar + required: + - biomarkerId + - name + - unit + - matchKind + additionalProperties: false required: - analyte - value @@ -68509,6 +70006,56 @@ components: - factsStaged - status additionalProperties: false + SupplementLabelExtractResult: + type: object + properties: + ingredients: + type: array + items: + type: object + properties: + nutrient: + type: string + amount: + type: number + unit: + type: string + confidence: + type: string + enum: + - high + - low + printedText: + type: string + required: + - nutrient + - amount + - unit + - confidence + - printedText + additionalProperties: false + unmatched: + type: array + items: + type: object + properties: + printedText: + type: string + required: + - printedText + additionalProperties: false + servingNote: + anyOf: + - type: string + - type: "null" + description: e.g. "per 2 capsules"; shown, never stored. + required: + - ingredients + - unmatched + - servingNote + additionalProperties: false + description: "The `result` of an AI run of kind `SUPPLEMENT_LABEL_EXTRACT`: a proposal only. Nothing is stored until the + composition is saved through `PUT /api/medications/{id}/ingredients`." ListBiomarkersEnvelope: type: object properties: @@ -68569,6 +70116,12 @@ components: - type: "null" hidden: type: boolean + aliases: + description: v1.43 — other spellings that resolve to this marker (folded in by a merge, chosen at an import, or typed on + the marker), sorted, without the marker's own name. Absent on older servers. + type: array + items: + type: string createdAt: type: string updatedAt: @@ -68669,6 +70222,97 @@ components: - data - error additionalProperties: false + MergeBiomarkersResponse: + type: object + properties: + data: + $ref: "#/components/schemas/BiomarkerMergeResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + BiomarkerMergeResult: + type: object + properties: + dryRun: + type: boolean + target: + description: The marker after the merge (unchanged on a dry run). + $ref: "#/components/schemas/Biomarker" + sources: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + unit: + type: string + readingCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + conversion: + type: string + enum: + - none + - converted + - refused + refusal: + anyOf: + - type: string + enum: + - unknown_analyte + - unknown_unit + - not_finite + - type: "null" + required: + - id + - name + - unit + - readingCount + - conversion + - refusal + additionalProperties: false + movedReadings: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + convertedReadings: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + aliasesAdded: + type: array + items: + type: string + description: Names that become aliases of the target. + mergeId: + anyOf: + - type: string + - type: "null" + description: "`null` on a dry run." + required: + - dryRun + - target + - sources + - movedReadings + - convertedReadings + - aliasesAdded + - mergeId + additionalProperties: false + description: What a merge did, or would do on a dry run. Readings in another unit are converted where the analyte's + conversion table knows it; the printed value stays in `sourceValue` / `sourceUnit` on each reading. ListEncountersEnvelope: type: object properties: @@ -69267,7 +70911,16 @@ components: type: object properties: data: - $ref: "#/components/schemas/Encounter" + allOf: + - $ref: "#/components/schemas/Encounter" + - type: object + properties: + related: + description: v1.43 — records related to this one, resolved on the server. Absent on older servers. + type: array + items: + $ref: "#/components/schemas/RelatedLink" + additionalProperties: false error: type: "null" meta: @@ -69959,7 +71612,16 @@ components: type: object properties: data: - $ref: "#/components/schemas/Vaccination" + allOf: + - $ref: "#/components/schemas/Vaccination" + - type: object + properties: + related: + description: v1.43 — records related to this one, resolved on the server. Absent on older servers. + type: array + items: + $ref: "#/components/schemas/RelatedLink" + additionalProperties: false error: type: "null" meta: @@ -70331,7 +71993,16 @@ components: type: object properties: data: - $ref: "#/components/schemas/IllnessEpisode" + allOf: + - $ref: "#/components/schemas/IllnessEpisode" + - type: object + properties: + related: + description: v1.43 — records related to this one, resolved on the server. Absent on older servers. + type: array + items: + $ref: "#/components/schemas/RelatedLink" + additionalProperties: false error: type: "null" meta: @@ -71991,7 +73662,16 @@ components: type: object properties: data: - $ref: "#/components/schemas/InboundDocumentDetail" + allOf: + - $ref: "#/components/schemas/InboundDocumentDetail" + - type: object + properties: + related: + description: v1.43 — records related to this one, resolved on the server. Absent on older servers. + type: array + items: + $ref: "#/components/schemas/RelatedLink" + additionalProperties: false error: type: "null" meta: @@ -72251,6 +73931,45 @@ components: anyOf: - type: string - type: "null" + biomarkerId: + description: v1.43, OBSERVATION only — the marker matched automatically (name, alias or catalogue abbreviation); null + without a match. + anyOf: + - type: string + - type: "null" + matchKind: + description: v1.43, OBSERVATION only — how `biomarkerId` was found. + anyOf: + - type: string + enum: + - name + - alias + - catalog + - type: "null" + suggestions: + description: v1.43, OBSERVATION only — up to three markers to choose from when nothing matched. + maxItems: 3 + type: array + items: + type: object + properties: + biomarkerId: + type: string + name: + type: string + unit: + type: string + matchKind: + type: string + enum: + - catalog + - similar + required: + - biomarkerId + - name + - unit + - matchKind + additionalProperties: false required: - id - factType @@ -73266,6 +74985,99 @@ components: - progress additionalProperties: false description: The persisted completion flag and resume point after the write. + NutrientCatalogEnvelope: + type: object + properties: + data: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/NutrientCatalogEntry" + required: + - entries + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NutrientCatalogEntry: + type: object + properties: + code: + type: string + unit: + type: string + description: The canonical unit amounts are stored in. + labelKey: + type: string + label: + type: string + description: The nutrient's name in the account language. + syncable: + type: boolean + description: "Whether HealthKit may send this code in the nutrient batch. `dha` and `epa` are not syncable: they appear + in responses only." + inputUnits: + type: array + items: + type: object + properties: + unit: + type: string + factorToCanonical: + type: number + required: + - unit + - factorToCanonical + additionalProperties: false + description: "Units an amount may be entered in, with the factor to the canonical unit (vitamin D: `IU` = 0.025 µg)." + upperLimit: + anyOf: + - $ref: "#/components/schemas/NutrientUpperLimit" + - type: "null" + required: + - code + - unit + - labelKey + - label + - syncable + - inputUnits + - upperLimit + additionalProperties: false + description: One nutrient of the catalogue as the composition form uses it. Clients build the form from this and never + convert units themselves. + NutrientUpperLimit: + type: object + properties: + value: + type: number + unit: + type: string + scope: + type: string + enum: + - total + - supplemental + source: + type: string + required: + - value + - unit + - scope + - source + additionalProperties: false + description: The tolerable upper intake level, with the unit, whether it applies to the total intake or to the + supplemental share only, and the cited source. `null` where no level is established. NutrientBatchEnvelope: type: object properties: @@ -73392,6 +75204,19 @@ components: type: integer minimum: 1 maximum: 9007199254740991 + latestBySource: + description: v1.43 — `latestAmount` split by source. + $ref: "#/components/schemas/NutrientAmountBySource" + upperLimit: + description: v1.43 — the nutrient's upper intake level, or null. + anyOf: + - $ref: "#/components/schemas/NutrientUpperLimit" + - type: "null" + latestAboveUpperLimit: + description: v1.43 — whether the latest day is above the upper limit; null without one. + anyOf: + - type: boolean + - type: "null" required: - nutrient - unit @@ -73424,6 +75249,18 @@ components: additionalProperties: false description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying data inside the window. Nutrients without data in the window are omitted." + NutrientAmountBySource: + type: object + properties: + APPLE_HEALTH: + type: number + MANUAL: + type: number + SUPPLEMENT: + type: number + additionalProperties: false + description: "The amount split by source: synced from Apple Health, entered by hand, and counted from taken supplement + doses. A source with nothing is absent." NutrientDailySeriesEnvelope: type: object properties: @@ -73461,6 +75298,14 @@ components: type: string amount: type: number + bySource: + description: v1.43 — the day's amount split by source. + $ref: "#/components/schemas/NutrientAmountBySource" + aboveUpperLimit: + description: v1.43 — whether the day is above the upper limit; null without one. + anyOf: + - type: boolean + - type: "null" required: - day - amount @@ -73491,6 +75336,11 @@ components: - source additionalProperties: false - type: "null" + upperLimit: + description: v1.43 — the nutrient's upper intake level, or null. + anyOf: + - $ref: "#/components/schemas/NutrientUpperLimit" + - type: "null" required: - nutrient - unit @@ -73736,6 +75586,15 @@ components: 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" + lastBackfillAt: + description: 'v1.43 — when a batch with `syncTrigger: "backfill"` last carried this type. A backfill moves only this and + `syncProgress`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt` or the account-level sync stamps, + so catching up history does not read as a run of healthy daily syncs. Absent on older servers.' + 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" required: - type - lastSeenAt @@ -76743,6 +78602,14 @@ components: - type: "null" description: The metric's newest reading, for the 'last reading N days ago' caption when it is older than the sparkline window. + direction: + description: v1.43 — which way a change of this metric counts. + anyOf: + - $ref: "#/components/schemas/TrendVerdict" + - type: "null" + trendSentiment: + description: v1.43 — the verdict applied to this card's `trend`. + $ref: "#/components/schemas/TrendSentiment" required: - id - kind @@ -77331,7 +79198,17 @@ components: properties: available: type: boolean - description: False on an instance that allows single sign-on only, where a handover cannot be created. + description: Whether a handover can be created on this instance. Up to v1.42 false on an instance that allows single + sign-on only; from v1.43 true whenever `methods` is not empty. + methods: + description: "v1.43 — how a link can be redeemed here: `password` (not on a single-sign-on-only instance) and `sso` + (whenever an identity provider is configured). Absent on older servers." + type: array + items: + type: string + enum: + - password + - sso open: anyOf: - type: object @@ -77565,12 +79442,29 @@ components: const: true description: "Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose written against one is refused with `medication.intake.notTracked`." + cumulativeHourBuckets: + description: "v1.43 — `POST /api/measurements/batch` takes hourly `stats::T` rows for steps, active + energy, walking and running distance and flights climbed. Send them only when this is `true`: an older + server stores every hour as its own measurement." + type: boolean + syncTriggerBackfill: + description: 'v1.43 — `syncTrigger: "backfill"` is accepted on the measurement batch and the intake bulk. Send it only + when this is `true`: an older server refuses the whole batch.' + type: boolean + biomarkerMerge: + description: v1.43 — `POST /api/biomarkers/{id}/merge` is available. + type: boolean + lifeCircumstances: + description: v1.43 — the life-circumstances routes under `/api/anamnesis/life-circumstances` and + `/api/anamnesis/audit-c` are available. + type: boolean required: - trackIntake additionalProperties: false - description: What this server understands, for a client released apart from it. Constant across accounts; a flag appears - once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that - meets a flag it does not know ignores it. + description: What this server understands, for a client released apart from it. Constant across accounts. A flag that is + absent or `false` means this server does not have the feature; a client checks for `true` before it calls a + route or sends a request form the flag names. Absent on servers older than v1.42.0. A client that meets a + flag it does not know ignores it. accountAccess: $ref: "#/components/schemas/AccountAccess" moduleAccess: @@ -78031,6 +79925,9 @@ components: $ref: "#/components/schemas/AiCapabilities" provider: $ref: "#/components/schemas/AiProviderState" + usage: + description: v1.43 — the account's AI usage level. Absent on older servers; hide the setting then. + $ref: "#/components/schemas/AiUsageState" required: - capabilities - provider @@ -78127,6 +80024,94 @@ components: - responseTimeoutMs additionalProperties: false description: The account-level companion to the capability map. + AiUsageState: + type: object + properties: + level: + description: The level in effect for this record. + $ref: "#/components/schemas/AiUsageLevel" + preference: + anyOf: + - $ref: "#/components/schemas/AiUsageLevel" + - type: "null" + description: The account's stored choice; `null` follows the instance default. + defaultLevel: + description: The level an account without its own choice runs at. + $ref: "#/components/schemas/AiUsageLevel" + maxLevel: + description: The highest level the operator allows on this server. + $ref: "#/components/schemas/AiUsageLevel" + source: + type: string + enum: + - user + - default + - admin_cap + description: "Where `level` comes from: the stored choice, the instance default, or the operator's ceiling capping a + stored choice above it (the choice is kept)." + options: + minItems: 3 + maxItems: 3 + type: array + items: + type: object + properties: + level: + $ref: "#/components/schemas/AiUsageLevel" + allowed: + type: boolean + description: "`level` is at or below `maxLevel`." + estimatedTokensPerDay: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: An estimate, from this account's measured use scaled to the level. `null` without three days of history. + Show it as an estimate; never compute one on the client. + required: + - level + - allowed + - estimatedTokensPerDay + additionalProperties: false + description: Always three entries, in the order frugal, standard, full. + measuredTokensPerDay: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Mean tokens per day over the last seven full days (Coach and background work together); `null` without + history. + measuredDays: + type: integer + minimum: 0 + maximum: 7 + description: How many days stand behind `measuredTokensPerDay`. + required: + - level + - preference + - defaultLevel + - maxLevel + - source + - options + - measuredTokensPerDay + - measuredDays + additionalProperties: false + description: The account's AI usage level, resolved on the server. Clients render the picker from `options` and never + derive allowance or estimates themselves. Absent on servers older than v1.43. + AiUsageLevel: + type: string + enum: + - frugal + - standard + - full + description: "How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at + most every 24 h after a sync and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a + short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic + document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a + hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). + The interactive Coach is the same at every level." CoachReasoningState: type: object properties: @@ -82122,6 +84107,12 @@ components: - reason additionalProperties: false description: Sections the caller cannot see. A section of a switched-off module is left out entirely rather than listed. + movement: + description: v1.43 — the day's movement context; absent for a delegate, with the `environment` module off, with no + Dawarich connection, and on older servers. + anyOf: + - $ref: "#/components/schemas/DayMovement" + - type: "null" required: - date - tz @@ -82450,6 +84441,80 @@ components: - source - band additionalProperties: false + DayMovement: + type: object + properties: + source: + type: string + const: dawarich + distance: + type: object + properties: + unit: + type: string + enum: + - km + - mi + description: Resolved from the account's unit preference. + total: + anyOf: + - type: number + - type: "null" + driving: + anyOf: + - type: number + - type: "null" + train: + anyOf: + - type: number + - type: "null" + required: + - unit + - total + - driving + - train + additionalProperties: false + placesVisited: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + awayFromHomeMinutes: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: "`null` means no coverage that day, not zero." + travel: + anyOf: + - type: object + properties: + isTravelDay: + type: boolean + countryCode: + anyOf: + - type: string + - type: "null" + required: + - isTravelDay + - countryCode + additionalProperties: false + - type: "null" + fetchedAt: + 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: + - source + - distance + - placesVisited + - awayFromHomeMinutes + - travel + - fetchedAt + additionalProperties: false + description: Distances to one decimal in `distance.unit`. Hide a null field; never show it as zero. TimelineEnvelope: type: object properties: @@ -83105,6 +85170,1012 @@ components: - text - marks additionalProperties: false + PutAiUsageResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AiUsageState" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PutMedicationIngredientsResponse: + type: object + properties: + data: + type: object + properties: + ingredients: + type: array + items: + $ref: "#/components/schemas/MedicationIngredient" + recompute: + type: object + properties: + queued: + type: boolean + required: + - queued + additionalProperties: false + required: + - ingredients + - recompute + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LifeCircumstancesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/LifeCircumstances" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LifeCircumstances: + type: object + properties: + sections: + type: array + items: + type: object + properties: + key: + type: string + enum: + - tobacco + - alcohol + - activity + - work + - habits + - goals + aiSection: + type: string + enum: + - SMOKING_STATUS + - ALCOHOL_PATTERN + - ACTIVITY + - WORK + - HABITS + - GOALS + aiIncluded: + type: boolean + lastConfirmedAt: + 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" + reviewDue: + type: boolean + description: Older than 365 days; show the quiet yearly check. + facts: + type: array + items: + $ref: "#/components/schemas/LifeFactRevision" + required: + - key + - aiSection + - aiIncluded + - lastConfirmedAt + - reviewDue + - facts + additionalProperties: false + description: Always all six, in this order. + extraAiSections: + type: object + properties: + SHIFT_SCHEDULE: + type: boolean + required: + - SHIFT_SCHEDULE + additionalProperties: false + derived: + type: object + properties: + packYears: + type: object + properties: + value: + anyOf: + - type: number + - type: "null" + status: + type: string + enum: + - computed + - not_applicable + - missing_inputs + required: + - value + - status + additionalProperties: false + activityMinutesPerWeek: + type: object + properties: + value: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + meetsWho150: + anyOf: + - type: boolean + - type: "null" + measuredStepsAvailable: + type: boolean + required: + - value + - meetsWho150 + - measuredStepsAvailable + additionalProperties: false + freeWeekdays: + type: object + properties: + days: + type: array + items: + type: integer + minimum: 1 + maximum: 7 + source: + type: string + enum: + - fact + - default_weekend + required: + - days + - source + additionalProperties: false + screeningHints: + type: array + items: + type: object + properties: + id: + type: string + enum: + - lung_ldct + - aaa_ultrasound + status: + type: string + enum: + - suggested + - not_eligible + - unknown + reasonKeys: + type: array + items: + type: string + region: + type: string + enum: + - DE + - US + sourceLabel: + type: string + sourceUrl: + type: string + required: + - id + - status + - reasonKeys + - region + - sourceLabel + - sourceUrl + additionalProperties: false + required: + - packYears + - activityMinutesPerWeek + - freeWeekdays + - screeningHints + additionalProperties: false + auditC: + type: object + properties: + latest: + anyOf: + - $ref: "#/components/schemas/AuditCResult" + - type: "null" + history: + type: array + items: + type: object + properties: + id: + type: string + takenAt: + 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)))$ + totalScore: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - id + - takenAt + - totalScore + additionalProperties: false + description: Newest first, at most 20. + offerDue: + type: boolean + instrument: + type: object + properties: + items: + type: array + items: + type: object + properties: + stem: + type: string + options: + type: array + items: + type: object + properties: + value: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + label: + type: string + required: + - value + - label + additionalProperties: false + required: + - stem + - options + additionalProperties: false + description: The three items verbatim in the account language; render as delivered. + locale: + type: string + validatedLocale: + type: boolean + standardDrink: + type: string + attribution: + type: string + required: + - items + - locale + - validatedLocale + - standardDrink + - attribution + additionalProperties: false + required: + - latest + - history + - offerDue + - instrument + additionalProperties: false + takeOver: + type: array + items: + type: object + properties: + field: + type: string + enum: + - conditions + - allergies + freeTextPresent: + type: boolean + structuredCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + available: + type: boolean + required: + - field + - freeTextPresent + - structuredCount + - available + additionalProperties: false + required: + - sections + - extraAiSections + - derived + - auditC + - takeOver + additionalProperties: false + description: The six life-circumstance sections with their answers, AI switches and review state, the derived values + with their sources, AUDIT-C, and the take-over hints for the free-text conditions and allergies. Every number is + resolved on the server. + LifeFactRevision: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - SMOKING_STATUS + - SHIFT_SCHEDULE + - TOBACCO_DETAIL + - ACTIVITY_HABIT + - WORK_CONTEXT + - FREE_WEEKDAYS + - HABITS + - HEALTH_GOALS + value: + description: "`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: + 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`." + unreadable: + type: boolean + description: The sealed value could not be opened on this host. + validFrom: + 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)))$ + validUntil: + type: "null" + provenance: + type: string + enum: + - USER_REPORTED + - USER_CORRECTION + createdAt: + 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: + - id + - kind + - value + - unreadable + - validFrom + - validUntil + - provenance + - createdAt + additionalProperties: false + description: The current revision of one life-circumstance fact. + AuditCResult: + type: object + properties: + id: + type: string + takenAt: + 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)))$ + items: + type: array + prefixItems: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + totalScore: + type: integer + minimum: 0 + maximum: 12 + locale: + type: string + cutoff: + anyOf: + - type: object + properties: + value: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + sex: + type: string + enum: + - male + - female + source: + type: string + enum: + - S3_ALCOHOL_DE + - KNHANES_KR + - BRADLEY_2007 + sourceLabel: + type: string + sourceUrl: + type: string + required: + - value + - sex + - source + - sourceLabel + - sourceUrl + additionalProperties: false + - type: "null" + description: The screening cut-off for the entry's language and the profile's sex (German S3 guideline, Korean KNHANES, + else Bradley 2007). `null` when the profile has no sex; show the sum only. + aboveCutoff: + anyOf: + - type: boolean + - type: "null" + validatedLocale: + type: boolean + description: Whether the item wording shown was a validated translation for `locale`. + required: + - id + - takenAt + - items + - totalScore + - locale + - cutoff + - aboveCutoff + - validatedLocale + additionalProperties: false + description: One AUDIT-C entry, scored on the server. A screening result, not a diagnosis. + LifeAiSectionsEnvelope: + type: object + properties: + data: + type: object + properties: + aiSections: + type: object + propertyNames: + type: string + enum: + - SMOKING_STATUS + - ALCOHOL_PATTERN + - SHIFT_SCHEDULE + - ACTIVITY + - WORK + - HABITS + - GOALS + additionalProperties: + type: boolean + updatedAt: + 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: + - aiSections + - updatedAt + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LifeConfirmEnvelope: + type: object + properties: + data: + type: object + properties: + section: + type: string + enum: + - tobacco + - alcohol + - activity + - work + - habits + - goals + lastConfirmedAt: + 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: + - section + - lastConfirmedAt + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AuditCReplayEnvelope: + type: object + properties: + data: + type: object + properties: + result: + $ref: "#/components/schemas/AuditCResult" + required: + - result + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AuditCCreatedEnvelope: + type: object + properties: + data: + type: object + properties: + result: + $ref: "#/components/schemas/AuditCResult" + required: + - result + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AuditCRemovedEnvelope: + type: object + properties: + data: + type: object + properties: + id: + type: string + removedAt: + 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: + - id + - removedAt + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DawarichConnectEnvelope: + type: object + properties: + data: + type: object + properties: + connected: + type: boolean + const: true + required: + - connected + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DawarichStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DawarichStatus" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DawarichStatus: + type: object + properties: + connected: + type: boolean + configured: + type: boolean + homePlaceSet: + type: boolean + homeCountry: + anyOf: + - type: string + - type: "null" + daysStored: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + oldestDay: + anyOf: + - type: string + - type: "null" + latestDay: + anyOf: + - type: string + - type: "null" + health: + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" + required: + - connected + - configured + - homePlaceSet + - homeCountry + - daysStored + - oldestDay + - latestDay + - health + additionalProperties: false + description: The Dawarich connection. `health` is the shape every connection publishes; there are no legacy ledger fields. + DawarichPlacesEnvelope: + type: object + properties: + data: + type: object + properties: + places: + type: array + items: + type: object + properties: + id: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + name: + type: string + required: + - id + - name + additionalProperties: false + required: + - places + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DawarichSettingsEnvelope: + type: object + properties: + data: + type: object + properties: + homePlaceId: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + homeCountry: + anyOf: + - type: string + - type: "null" + required: + - homePlaceId + - homeCountry + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DawarichProbeEnvelope: + type: object + properties: + data: + type: object + properties: + ok: + type: boolean + const: true + lastSyncedAt: + 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" + latencyMs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - ok + - lastSyncedAt + - latencyMs + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DawarichSyncEnvelope: + type: object + properties: + data: + type: object + properties: + enqueued: + type: boolean + required: + - enqueued + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DawarichDisconnectEnvelope: + type: object + properties: + data: + type: object + properties: + disconnected: + type: boolean + const: true + deletedDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - disconnected + - deletedDays + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TravelSuggestionsEnvelope: + type: object + properties: + data: + type: object + properties: + suggestions: + type: array + items: + $ref: "#/components/schemas/TravelSuggestion" + required: + - suggestions + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TravelSuggestion: + type: object + properties: + id: + type: string + startDate: + type: string + endDate: + type: string + countryCode: + type: string + required: + - id + - startDate + - endDate + - countryCode + additionalProperties: false + description: A run of consecutive travel days no stored trip covers. Accept it through `POST /api/environment/travel` + (the person picks the place). + TravelSuggestionDismissEnvelope: + type: object + properties: + data: + type: object + properties: + dismissed: + type: boolean + const: true + required: + - dismissed + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationLinkResponse: + type: object + properties: + data: + $ref: "#/components/schemas/RelationLinkResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RelationLinkResult: + type: object + properties: + changed: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + unknown: + type: array + items: + type: string + description: Ids that name nothing this account owns; nothing failed. + required: + - changed + - unknown + additionalProperties: false + MedicationUnlinkResponse: + type: object + properties: + data: + $ref: "#/components/schemas/RelationLinkResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LabResultLinkResponse: + type: object + properties: + data: + $ref: "#/components/schemas/RelationLinkResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LabResultUnlinkResponse: + type: object + properties: + data: + $ref: "#/components/schemas/RelationLinkResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false __schema0: anyOf: - type: string diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts new file mode 100644 index 000000000..102729345 --- /dev/null +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -0,0 +1,121 @@ +/** + * The routes that publish a contract and do not yet carry its effect. + * + * v1.43 fixed its API contract before the features behind it were built: the + * routes exist, authenticate, gate and validate as documented, and answer + * `501 not_implemented` (`src/lib/http/not-implemented.ts`) until their + * feature lands. That is safe only while nothing reaches them, and nothing + * should: every client path sits behind a `features` flag that stays `false`. + * + * This list is the debt register. A feature that lands removes its route here + * in the same change; a new stub has to be named here to exist. The release is + * not ready to tag while the list is not empty, which the last case below + * makes impossible to miss: it states the count it was written against, so + * the change that empties the list also has to touch this file. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const API_ROOT = join(process.cwd(), "src", "app", "api"); + +/** Route file (under `src/app/api`) to the operations it still stubs. */ +const CONTRACT_STUBS: Readonly> = { + "admin/maintenance/pulse-day-mean/route.ts": [ + "admin.pulse_day_mean.get", + "admin.pulse_day_mean.post", + ], + "admin/support-report/route.ts": ["admin.support_report.get"], + "anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"], + "anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"], + "anamnesis/life-circumstances/ai-sections/route.ts": [ + "anamnesis.life_circumstances.ai_sections.patch", + ], + "anamnesis/life-circumstances/confirm/route.ts": [ + "anamnesis.life_circumstances.confirm", + ], + "anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"], + "auth/claim/sso/route.ts": ["profile_claim.sso.start"], + "auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"], + "biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"], + "dawarich/connect/route.ts": ["dawarich.connect"], + "dawarich/disconnect/route.ts": ["dawarich.disconnect"], + "dawarich/places/route.ts": ["dawarich.places.get"], + "dawarich/settings/route.ts": ["dawarich.settings.patch"], + "dawarich/status/route.ts": ["dawarich.status.get"], + "dawarich/sync/route.ts": ["dawarich.sync"], + "dawarich/test/route.ts": ["dawarich.test"], + "environment/travel/suggestions/[id]/dismiss/route.ts": [ + "environment.travel_suggestion.dismiss", + ], + "environment/travel/suggestions/route.ts": [ + "environment.travel_suggestions.get", + ], + "labs/[id]/links/route.ts": ["labs.link."], + "medications/[id]/ingredients/extract/route.ts": [ + "medications.ingredients.extract", + ], + "medications/[id]/ingredients/route.ts": ["medications.ingredients.put"], + "medications/[id]/links/route.ts": ["medication.link."], + "nutrients/catalog/route.ts": ["nutrients.catalog.get"], +}; + +/** The size of the register when it was written; see the header. */ +const REGISTER_SIZE = 24; + +function routeFiles(): string[] { + return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] }) + .filter((rel) => rel.endsWith("route.ts")) + .filter((rel) => !rel.includes("__tests__")); +} + +describe("contract stubs answering 501", () => { + const routes = routeFiles(); + const found = routes + .filter((rel) => + /\bnotImplemented\s*\(/.test(readFileSync(join(API_ROOT, rel), "utf8")), + ) + .sort(); + + it("sweeps the whole route tree", () => { + // The register can and should reach zero; the tree it is checked against + // cannot. A matcher that read nothing would make the register look clean. + expect(routes.length).toBeGreaterThan(400); + }); + + it("are exactly the registered ones", () => { + expect(found).toEqual(Object.keys(CONTRACT_STUBS).sort()); + }); + + it("each still names the operations it was registered with", () => { + for (const [rel, operations] of Object.entries(CONTRACT_STUBS)) { + const source = readFileSync(join(API_ROOT, rel), "utf8"); + for (const operation of operations) { + expect(source, `${rel} no longer stubs ${operation}`).toContain( + `notImplemented(${operation.endsWith(".") ? "`" : '"'}${operation}`, + ); + } + } + }); + + it("authenticate before they answer", () => { + for (const rel of found) { + const source = readFileSync(join(API_ROOT, rel), "utf8"); + expect( + /\b(requireAuth|requireRecordAuth|requireAdmin|claimGate)\s*\(/.test( + source, + ), + `${rel} answers 501 without authenticating first`, + ).toBe(true); + } + }); + + it("are still the register the release was planned with", () => { + // Empty before the release is tagged: set REGISTER_SIZE to 0 with the + // change that fills the last route. + expect(Object.keys(CONTRACT_STUBS)).toHaveLength(REGISTER_SIZE); + }); +}); diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 3b23a8804..a1210c215 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -258,6 +258,10 @@ const UNPUBLISHED: Readonly> = { kind: "adminConsole", methods: ["POST"], }, + "/api/admin/maintenance/pulse-day-mean": { + kind: "adminConsole", + methods: ["GET", "POST"], + }, "/api/admin/monitoring/glitchtip-test": { kind: "adminConsole", methods: ["POST"], @@ -284,6 +288,7 @@ const UNPUBLISHED: Readonly> = { methods: ["POST"], }, "/api/admin/status": { kind: "adminConsole", methods: ["GET"] }, + "/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] }, "/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] }, "/api/admin/users": { kind: "adminConsole", methods: ["GET"] }, "/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] }, diff --git a/src/__tests__/openapi-security-declaration-guard.test.ts b/src/__tests__/openapi-security-declaration-guard.test.ts index ab57f201b..a5898affc 100644 --- a/src/__tests__/openapi-security-declaration-guard.test.ts +++ b/src/__tests__/openapi-security-declaration-guard.test.ts @@ -83,6 +83,8 @@ const UNAUTHENTICATED: Readonly> = { "Previews a managed-profile handover link for a person who has no credential yet; the one-time token in the body is the credential.", "/api/auth/claim": "Turns a managed profile into the claimant's own account and creates their first credential; the one-time token in the body authorises it.", + "/api/auth/claim/sso": + "Starts claiming a managed profile through the identity provider; the claimant has no credential yet, and the one-time token in the body authorises it.", // The tombstones, sourced from the registry rather than restated one by one. // A retired path answers 410 from the proxy before any handler runs, and that // answer never reads the request's cookies or Authorization header — so it is diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index ebf46edd7..d27126a2b 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -96,6 +96,9 @@ describe("S1 — the direct session-resolution set is frozen", () => { "refuses to claim a profile over a live session", "app/api/auth/claim/preview/route.ts": "refuses to preview a handover link over a live session", + // v1.43 (#959) — the single-sign-on claim start, same argument. + "app/api/auth/claim/sso/route.ts": + "refuses to start a single-sign-on claim over a live session", // Anonymous IdP callback. Probes for an already-valid session so a // replayed callback redirects instead of minting a second identity. "app/api/auth/oidc/callback/route.ts": diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index e75df50bc..cf8be76b0 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -887,6 +887,10 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", }, + "app/api/anamnesis/life-circumstances/route.ts": { + domain: "profile", + why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches, confirmations and AUDIT-C are written through their own routes, none of them delegable.", + }, "app/api/profile/summary/route.ts": { domain: "profile", why: "The bounded shared profile read combines only the record's allergies, family history, and current lifestyle facts. It carries no settings, AI configuration, mutations, or encrypted free text.", @@ -931,6 +935,10 @@ const DELEGABLE_ROUTES: Record = { domain: "measurements", why: "The record's sleep rhythm, built from `buildSleepRhythm(user.id)` and gated on the record's sleep module.", }, + "app/api/nutrients/catalog/route.ts": { + domain: "measurements", + why: "The nutrient catalogue as the composition form reads it (v1.43): codes, units and upper limits, labels in the record's language. Reference data the record's module gate opens; nothing of the record itself.", + }, "app/api/nutrients/route.ts": { domain: "measurements", why: "The record's nutrient overview. It reads the last ingest audit row to date the data — integration-adjacent metadata, and no credential, endpoint or token crosses the wire.", @@ -2104,8 +2112,13 @@ const ACTOR_ROUTES: Record = { * * v1.42.4 -- the Labs list's per-marker overview joins the record list * beside `GET /api/labs`, the rows it groups. 255 -> 256. + * + * v1.43 -- two reads join the record list: the life circumstances beside the + * facts (`profile`) and the nutrient catalogue beside the nutrient reads + * (`measurements`). The biomarker DELETE joins the manage literal on a module + * that was already there. 256 -> 258. */ -const FROZEN_ENTRY_COUNT = 256; +const FROZEN_ENTRY_COUNT = 258; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts index 8f96081bb..b74ebaea5 100644 --- a/src/__tests__/written-outcome-response-consumer-guard.test.ts +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -754,9 +754,20 @@ const OCR_COLLECTION_CONSUMERS: Readonly> = { skipped: ["result.skipped.length"], }; +/** + * `/api//sync` routes that only queue a background job and answer + * 202 `{ enqueued }`: nothing is written while the request runs, so there is + * no written outcome to resolve. Each entry says why. + */ +const QUEUE_ONLY_SYNC_ROUTES: Readonly> = { + dawarich: + "Dawarich daily values are fetched by the dawarich-sync job; the route answers 202 { enqueued } and the outcome reaches the card through the connection health, not this response.", +}; + function discoveredSyncRoutes(): Array<{ id: string; path: string }> { return readdirSync(API_ROOT, { withFileTypes: true }).flatMap((entry) => { if (!entry.isDirectory()) return []; + if (entry.name in QUEUE_ONLY_SYNC_ROUTES) return []; const path = join(API_ROOT, entry.name, "sync", "route.ts"); return existsSync(path) ? [{ id: entry.name, path }] : []; }); @@ -1078,6 +1089,13 @@ describe("written-outcome response consumer guard", () => { ).toBe(true); } } + for (const [id, reason] of Object.entries(QUEUE_ONLY_SYNC_ROUTES)) { + expect( + existsSync(join(API_ROOT, id, "sync", "route.ts")), + `${id} is listed as a queue-only sync route but has none`, + ).toBe(true); + expect(reason.length).toBeGreaterThan(40); + } const discovered = discoveredSyncRoutes().sort((a, b) => a.id.localeCompare(b.id), ); diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 431e119ca..f04a449c1 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -252,6 +252,11 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "src/app/api/labs/restore/route.ts", "src/app/api/biomarkers/route.ts", "src/app/api/biomarkers/[id]/route.ts", + // v1.43 — merging markers and filing a reading against its report or visit + // are the same row-store operations: the history and its links must stay + // consistent while the Labs surface is hidden. + "src/app/api/biomarkers/[id]/merge/route.ts", + "src/app/api/labs/[id]/links/route.ts", // v1.18.9 — the Lab-OCR ingestion routes share the LabResult data-layer // reasoning: the labs module gates the SURFACE (the Labs page + scan // affordance), not the row store. The extract route is read-only vision @@ -315,6 +320,9 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // SURFACE (the detail-page tab + the insights summary), not the row store. "src/app/api/medications/[id]/efficacy/route.ts", "src/app/api/medications/[id]/efficacy/target/route.ts", + // v1.43 — a medication's links to its visits, documents and indication are + // raw link CRUD over the person's own rows, like the vaccination links. + "src/app/api/medications/[id]/links/route.ts", // ── INFRA / UI-ONLY ─────────────────────────────────────────────── // Static FHIR CapabilityStatement — server metadata, no user data. "src/app/api/fhir/metadata/route.ts", diff --git a/src/app/api/admin/maintenance/pulse-day-mean/route.ts b/src/app/api/admin/maintenance/pulse-day-mean/route.ts new file mode 100644 index 000000000..f5bb44f50 --- /dev/null +++ b/src/app/api/admin/maintenance/pulse-day-mean/route.ts @@ -0,0 +1,39 @@ +/** + * `GET/POST /api/admin/maintenance/pulse-day-mean` — the pulse day-mean + * method (#1107 follow-up). + * + * GET answers the active method, when it was applied and the last dry run + * (status and result: per-account labels only, never an id). POST + * `{ action: "dry_run" | "apply" }` answers 202 `{ enqueued }`: a dry run + * computes both methods for every account and day and writes nothing but its + * result; `apply` needs a successful dry run from the last seven days (409 + * `maintenance.pulseDayMean.dryRunRequired`), switches the method and + * rebuilds the pulse rollups. 409 `maintenance.pulseDayMean.busy` while a run + * is active. Admin only, cookie session only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { pulseDayMeanActionSchema } from "@/lib/validations/pulse-day-mean"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + await requireAdmin(); + return notImplemented("admin.pulse_day_mean.get"); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + await requireAdmin(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + const parsed = pulseDayMeanActionSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + return notImplemented("admin.pulse_day_mean.post"); +}); diff --git a/src/app/api/admin/support-report/route.ts b/src/app/api/admin/support-report/route.ts new file mode 100644 index 000000000..08fec25cb --- /dev/null +++ b/src/app/api/admin/support-report/route.ts @@ -0,0 +1,17 @@ +/** + * `GET /api/admin/support-report` — a redacted plain-text report an operator + * can paste into an issue: build, database, configuration as yes/no, jobs, + * connections by health state, AI ledger and usage levels, notifications, + * backups and the most frequent recent error actions. Never an email address, + * a user id, a value, a token or a foreign host name; accounts appear as + * "Account 1..n". Admin only, cookie session only. + */ +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { notImplemented } from "@/lib/http/not-implemented"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + await requireAdmin(); + return notImplemented("admin.support_report.get"); +}); diff --git a/src/app/api/anamnesis/audit-c/[id]/route.ts b/src/app/api/anamnesis/audit-c/[id]/route.ts new file mode 100644 index 000000000..1315b01ee --- /dev/null +++ b/src/app/api/anamnesis/audit-c/[id]/route.ts @@ -0,0 +1,21 @@ +/** + * `DELETE /api/anamnesis/audit-c/{id}` — remove an AUDIT-C entry (#614). + * Soft delete; answers `{ id, removedAt }`, 404 `anamnesis.auditC.notFound`. + * + * Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C + * lands the stub resolves the caller's own record only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { notImplemented } from "@/lib/http/not-implemented"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const DELETE = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + await requireAuth(); + await params; + return notImplemented("anamnesis.audit_c.delete"); + }, +); diff --git a/src/app/api/anamnesis/audit-c/route.ts b/src/app/api/anamnesis/audit-c/route.ts new file mode 100644 index 000000000..f95cf5dc0 --- /dev/null +++ b/src/app/api/anamnesis/audit-c/route.ts @@ -0,0 +1,47 @@ +/** + * `POST /api/anamnesis/audit-c` — record an AUDIT-C (#614). + * + * `{ items: [0..4, 0..4, 0..4], takenAt?, tz?, locale?, source, externalId? }`. + * 201 `{ result: AuditCResult }` with the sum and the screening cut-off for + * the entry's language and the profile's sex (null without one); a replay + * with the same `externalId` answers 200 with the same result. Stored beside + * the mental-health screeners but never listed by + * `/api/mental-health/assessments`. At most 30 an hour. + * + * Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C + * lands the stub resolves the caller's own record only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { withIdempotency } from "@/lib/idempotency"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { auditCRequestSchema } from "@/lib/validations/life-circumstances"; + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC)); + +async function postAuditC(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const rl = await checkRateLimit(`audit-c:${user.id}`, 30, 60 * 60 * 1000); + if (!rl.allowed) { + return apiError("Too many entries, try again later", 429, { + errorCode: "anamnesis.auditC.rateLimited", + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + const parsed = auditCRequestSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "anamnesis.auditC.invalid", + }); + } + + return notImplemented("anamnesis.audit_c.create"); +} diff --git a/src/app/api/anamnesis/life-circumstances/ai-sections/route.ts b/src/app/api/anamnesis/life-circumstances/ai-sections/route.ts new file mode 100644 index 000000000..b630a0c70 --- /dev/null +++ b/src/app/api/anamnesis/life-circumstances/ai-sections/route.ts @@ -0,0 +1,33 @@ +/** + * `PATCH /api/anamnesis/life-circumstances/ai-sections` — switch which + * life-circumstance sections may reach the AI (#614). + * + * `{ sections: {
: boolean }, baseUpdatedAt? }` with at least one + * key, over `SMOKING_STATUS`, `ALCOHOL_PATTERN`, `SHIFT_SCHEDULE`, `ACTIVITY`, + * `WORK`, `HABITS`, `GOALS`. Sections not named keep their state. A stale + * `baseUpdatedAt` is 409 `about_me_conflict`, the token `about-me` uses. + * Own account only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances"; + +export const PATCH = apiHandler(async (request: NextRequest) => { + await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + const parsed = lifeAiSectionsPatchSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "anamnesis.lifeSections.invalid", + }); + } + + return notImplemented("anamnesis.life_circumstances.ai_sections.patch"); +}); diff --git a/src/app/api/anamnesis/life-circumstances/confirm/route.ts b/src/app/api/anamnesis/life-circumstances/confirm/route.ts new file mode 100644 index 000000000..65652c842 --- /dev/null +++ b/src/app/api/anamnesis/life-circumstances/confirm/route.ts @@ -0,0 +1,28 @@ +/** + * `POST /api/anamnesis/life-circumstances/confirm` — "still true" for one + * section (#614). `{ section }`; stores the confirmation instant only, never a + * new revision, and answers `{ section, lastConfirmedAt }`. Own account only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { lifeConfirmSchema } from "@/lib/validations/life-circumstances"; + +export const POST = apiHandler(async (request: NextRequest) => { + await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + const parsed = lifeConfirmSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "anamnesis.lifeConfirm.invalid", + }); + } + + return notImplemented("anamnesis.life_circumstances.confirm"); +}); diff --git a/src/app/api/anamnesis/life-circumstances/route.ts b/src/app/api/anamnesis/life-circumstances/route.ts new file mode 100644 index 000000000..5731e1245 --- /dev/null +++ b/src/app/api/anamnesis/life-circumstances/route.ts @@ -0,0 +1,19 @@ +/** + * `GET /api/anamnesis/life-circumstances` — the six life-circumstance + * sections (#614) with their current answers, AI switches, review state, + * the derived values (pack-years, activity minutes, free weekdays, screening + * hints) and AUDIT-C with its instrument text in the account's language. + * + * The new fact kinds are served here and only here: `GET /api/anamnesis/facts` + * keeps its three kinds for clients that decode that list closed. Delegates + * read it under the `profile` grant, like the facts. + */ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { notImplemented } from "@/lib/http/not-implemented"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + await requireRecordAuth("read", "profile"); + return notImplemented("anamnesis.life_circumstances.get"); +}); diff --git a/src/app/api/auth/claim/sso/route.ts b/src/app/api/auth/claim/sso/route.ts new file mode 100644 index 000000000..01b591758 --- /dev/null +++ b/src/app/api/auth/claim/sso/route.ts @@ -0,0 +1,37 @@ +/** + * `POST /api/auth/claim/sso` — start claiming a managed profile through + * single sign-on (#959). + * + * Anonymous, like the password claim beside it. `{ token, username }`: the + * link is checked and the username reserved for the round trip, a sealed + * `hl_claim` cookie (10 minutes, `httpOnly`, `Secure` through + * `shouldEmitSecureCookie()`) carries `{ handoverId, username, exp }`, and the + * answer names where to go: `{ redirectTo: "/api/auth/oidc/login?intent=claim" }`. + * The OIDC callback binds the identity provider's subject to the profile. The + * token never rides a URL. Works on a single-sign-on-only server; 404 + * `profile_claim.sso_unavailable` when no identity provider is configured. + */ +import { NextRequest } from "next/server"; + +import { apiHandler } from "@/lib/api-handler"; +import { returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { getSession } from "@/lib/auth/session"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { claimGate } from "@/lib/managed-profiles/claim-gate"; +import { claimSsoSchema } from "@/lib/validations/claim-sso"; + +export const POST = apiHandler(async (request: NextRequest) => { + // Refuses a signed-in caller and rate-limits the address, as the password + // claim does. + const gate = await claimGate(request, "claim", (await getSession()) !== null); + if (gate.refusal) return gate.refusal; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + const parsed = claimSsoSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + return notImplemented("profile_claim.sso.start"); +}); diff --git a/src/app/api/auth/me/ai-usage/route.ts b/src/app/api/auth/me/ai-usage/route.ts new file mode 100644 index 000000000..1ceeb2d7d --- /dev/null +++ b/src/app/api/auth/me/ai-usage/route.ts @@ -0,0 +1,42 @@ +/** + * `PUT /api/auth/me/ai-usage` — the account's own AI usage level (#1217). + * + * `{ level: "frugal" | "standard" | "full" | null }`; `null` follows the + * instance default. A level above the operator's ceiling is refused with 422 + * `ai.usage.aboveCeiling`, never capped silently. Answers the resolved + * `AiUsageState`, the same block `GET /api/auth/me` publishes as `ai.usage`. + * Own account only (cookie or a full-scope token), at most 30 writes an hour. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { aiUsagePutSchema } from "@/lib/validations/ai-usage"; + +export const dynamic = "force-dynamic"; + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit(`ai-usage:${user.id}`, 30, 60 * 60 * 1000); + if (!rl.allowed) { + return apiError("Too many changes, try again later", 429, { + errorCode: "ai.usage.rateLimited", + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + const parsed = aiUsagePutSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "ai.usage.invalid", + }); + } + + return notImplemented("auth.me.ai_usage.put"); +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 2b46fbfe7..601873978 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -83,7 +83,15 @@ export const dynamic = "force-dynamic"; * by design: a feature the server supports is supported for every account, * and a per-account answer belongs in `modules` or `ai`, not here. */ -const SERVER_FEATURES = { trackIntake: true } as const; +const SERVER_FEATURES = { + trackIntake: true, + // v1.43 — each turns true in the change that makes its route or request + // form work; until then a client must not send it (contract §0.6). + cumulativeHourBuckets: false, + syncTriggerBackfill: false, + biomarkerMerge: false, + lifeCircumstances: false, +} as const; export const GET = apiHandler(async () => { const auth = await requireActorAuth(); diff --git a/src/app/api/biomarkers/[id]/merge/route.ts b/src/app/api/biomarkers/[id]/merge/route.ts new file mode 100644 index 000000000..d6d2b2fdd --- /dev/null +++ b/src/app/api/biomarkers/[id]/merge/route.ts @@ -0,0 +1,62 @@ +/** + * `POST /api/biomarkers/{id}/merge` — fold other markers into this one (D1223). + * + * `{id}` is the marker that stays. `{ sourceIds, dryRun }`: the readings of + * every source move onto the target in one transaction, converted where the + * analyte's conversion table knows the unit, the source names become aliases + * (`MERGE`), one `biomarker_merges` row is written per source and the sources + * are deleted. A pair of units without a known conversion refuses the whole + * merge (422 `labs.biomarker.merge.unitMismatch`); nothing moves. `dryRun` + * answers the same result and writes nothing. There is no undo. + * + * Contract authority: `requireRecordAuth("manage", "labs")` with the shared + * record-write ceiling. Until the merge lands the stub resolves the caller's + * own record only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { withIdempotency } from "@/lib/idempotency"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; +import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + withIdempotency<[NextRequest, RouteParams]>(mergeBiomarkers), +); + +async function mergeBiomarkers( + request: NextRequest, + { params }: RouteParams, +): Promise { + const { user } = await requireAuth(); + const { id } = await params; + + const writeRl = await checkRecordWriteRateLimit(user.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + const parsed = biomarkerMergeSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "labs.biomarker.merge.invalid", + }); + } + if (parsed.data.sourceIds.includes(id)) { + return apiError("The target cannot be one of the sources", 422, { + errorCode: "labs.biomarker.merge.invalid", + }); + } + + return notImplemented("labs.biomarker.merge"); +} diff --git a/src/app/api/dawarich/connect/route.ts b/src/app/api/dawarich/connect/route.ts new file mode 100644 index 000000000..8d576f837 --- /dev/null +++ b/src/app/api/dawarich/connect/route.ts @@ -0,0 +1,44 @@ +/** + * `POST /api/dawarich/connect` — connect a Dawarich instance (movement + * context, daily values only). + * + * `{ url, apiKey }`; both sealed at rest. The URL must resolve to a public + * host unless its origin is listed in `DAWARICH_PRIVATE_ORIGINS` (422 + * `private_origin_not_approved`). The key is checked against the instance + * before anything is stored, then a 30-day backfill starts. Part of the + * `environment` module; own account only, ten attempts a minute. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { dawarichConnectSchema } from "@/lib/validations/dawarich"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit(`dawarich-connect:${user.id}`, 10, 60_000); + if (!rl.allowed) { + return apiError("Too many attempts, try again later", 429, { + errorCode: "dawarich.rate_limited", + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + const parsed = dawarichConnectSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "dawarich.invalid", + }); + } + + return notImplemented("dawarich.connect"); +}); diff --git a/src/app/api/dawarich/disconnect/route.ts b/src/app/api/dawarich/disconnect/route.ts new file mode 100644 index 000000000..0c23b0b71 --- /dev/null +++ b/src/app/api/dawarich/disconnect/route.ts @@ -0,0 +1,32 @@ +/** + * `POST /api/dawarich/disconnect` — forget the connection; with + * `deleteData: true` the stored days go too. Answers + * `{ disconnected: true, deletedDays }`. Part of the `environment` module; + * own account only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { dawarichDisconnectSchema } from "@/lib/validations/dawarich"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + const parsed = dawarichDisconnectSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "dawarich.invalid", + }); + } + + return notImplemented("dawarich.disconnect"); +}); diff --git a/src/app/api/dawarich/places/route.ts b/src/app/api/dawarich/places/route.ts new file mode 100644 index 000000000..1da593388 --- /dev/null +++ b/src/app/api/dawarich/places/route.ts @@ -0,0 +1,32 @@ +/** + * `GET /api/dawarich/places?q=` — the instance's places, to pick the home + * place from. Answered from Dawarich live (at most 200, filtered by `q`) and + * never stored. 409 `dawarich.not_connected`, 502 `dawarich.unreachable`. + * Part of the `environment` module; own account only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { returnAllZodIssues } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { dawarichPlacesQuerySchema } from "@/lib/validations/dawarich"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const parsed = dawarichPlacesQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "dawarich.invalid", + }); + } + + return notImplemented("dawarich.places.get"); +}); diff --git a/src/app/api/dawarich/settings/route.ts b/src/app/api/dawarich/settings/route.ts new file mode 100644 index 000000000..c57828c61 --- /dev/null +++ b/src/app/api/dawarich/settings/route.ts @@ -0,0 +1,32 @@ +/** + * `PATCH /api/dawarich/settings` — the home place (an id on the Dawarich + * instance) and the home country (ISO 3166-1 alpha-2) the daily values are + * measured against. 422 `dawarich.home_place_unknown` for a place id the + * instance does not have. Part of the `environment` module; own account only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { dawarichSettingsSchema } from "@/lib/validations/dawarich"; + +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + const parsed = dawarichSettingsSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "dawarich.invalid", + }); + } + + return notImplemented("dawarich.settings.patch"); +}); diff --git a/src/app/api/dawarich/status/route.ts b/src/app/api/dawarich/status/route.ts new file mode 100644 index 000000000..1b9040f3e --- /dev/null +++ b/src/app/api/dawarich/status/route.ts @@ -0,0 +1,18 @@ +/** + * `GET /api/dawarich/status` — whether Dawarich is connected, the home place + * and country state, how many days are stored and the connection `health` + * (the same shape every connection publishes; no legacy ledger fields). + * Part of the `environment` module; own account only. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + return notImplemented("dawarich.status.get"); +}); diff --git a/src/app/api/dawarich/sync/route.ts b/src/app/api/dawarich/sync/route.ts new file mode 100644 index 000000000..2468ea8fc --- /dev/null +++ b/src/app/api/dawarich/sync/route.ts @@ -0,0 +1,32 @@ +/** + * `POST /api/dawarich/sync` — fetch the daily values again from `fromDate` + * (at most 365 days back; default: the nightly window). 202 + * `{ enqueued }`; 409 `dawarich.not_connected`. Part of the `environment` + * module; own account only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { dawarichSyncSchema } from "@/lib/validations/dawarich"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + const parsed = dawarichSyncSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "dawarich.invalid", + }); + } + + return notImplemented("dawarich.sync"); +}); diff --git a/src/app/api/dawarich/test/route.ts b/src/app/api/dawarich/test/route.ts new file mode 100644 index 000000000..d0314ca0c --- /dev/null +++ b/src/app/api/dawarich/test/route.ts @@ -0,0 +1,15 @@ +/** + * `POST /api/dawarich/test` — probe the connected Dawarich instance and answer + * the shared integration probe shape. Part of the `environment` module; own + * account only. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + return notImplemented("dawarich.test"); +}); diff --git a/src/app/api/environment/travel/suggestions/[id]/dismiss/route.ts b/src/app/api/environment/travel/suggestions/[id]/dismiss/route.ts new file mode 100644 index 000000000..bced75b5a --- /dev/null +++ b/src/app/api/environment/travel/suggestions/[id]/dismiss/route.ts @@ -0,0 +1,23 @@ +/** + * `POST /api/environment/travel/suggestions/{id}/dismiss` — never suggest + * this run of travel days again. Answers `{ dismissed: true }`; 404 + * `environment.travel_suggestion.not_found`. Part of the `environment` + * module; own account only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + await params; + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + return notImplemented("environment.travel_suggestion.dismiss"); + }, +); diff --git a/src/app/api/environment/travel/suggestions/route.ts b/src/app/api/environment/travel/suggestions/route.ts new file mode 100644 index 000000000..ef5fbf646 --- /dev/null +++ b/src/app/api/environment/travel/suggestions/route.ts @@ -0,0 +1,19 @@ +/** + * `GET /api/environment/travel/suggestions` — runs of consecutive Dawarich + * travel days no stored trip covers yet, as `{ id, startDate, endDate, + * countryCode }`. Accepting one goes through `POST /api/environment/travel` + * (the person picks the place); dismissing through the sibling route. Part of + * the `environment` module; own account only. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + return notImplemented("environment.travel_suggestions.get"); +}); diff --git a/src/app/api/labs/[id]/links/route.ts b/src/app/api/labs/[id]/links/route.ts new file mode 100644 index 000000000..6ea66439a --- /dev/null +++ b/src/app/api/labs/[id]/links/route.ts @@ -0,0 +1,50 @@ +/** + * `POST /api/labs/{id}/links` — file documents (the report it was read + * from) or visits against a lab result. + * `DELETE /api/labs/{id}/links` — unfile them. + * + * `{ targetKind, targetIds }` in, `{ changed, unknown }` out, through the + * shared link service like the visit and vaccination link routes; an id that + * names nothing this account owns comes back in `unknown`. Own account only, + * as the other link routes. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { labResultLinkSchema } from "@/lib/validations/relation-links"; + +type RouteParams = { params: Promise<{ id: string }> }; + +async function handle( + request: NextRequest, + { params }: RouteParams, + verb: "link" | "unlink", +): Promise { + await requireAuth(); + await params; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = labResultLinkSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "labs.link.invalid", + }); + } + + return notImplemented(`labs.link.${verb}`); +} + +export const POST = apiHandler( + async (request: NextRequest, context: RouteParams) => + handle(request, context, "link"), +); + +export const DELETE = apiHandler( + async (request: NextRequest, context: RouteParams) => + handle(request, context, "unlink"), +); diff --git a/src/app/api/medications/[id]/ingredients/extract/route.ts b/src/app/api/medications/[id]/ingredients/extract/route.ts new file mode 100644 index 000000000..01ebe3873 --- /dev/null +++ b/src/app/api/medications/[id]/ingredients/extract/route.ts @@ -0,0 +1,32 @@ +/** + * `POST /api/medications/{id}/ingredients/extract` — read a supplement label. + * + * Multipart `file` (JPEG, PNG, HEIC, WebP or PDF, at most 10 MB). Answers 202 + * with an AI run; `GET /api/ai-runs/{id}` (`SUPPLEMENT_LABEL_EXTRACT`) carries + * a proposal of ingredients, unmatched lines and a serving note. Nothing is + * stored until the composition is saved through the PUT beside this route. + * Gated by the `supplementLabel` AI capability (a `frugal` usage level makes + * it unavailable), six runs an hour, budget reserved like the other document + * AI runs. Needs the `nutrients` module. + * + * Contract authority: `requireRecordAuth("manage", "medications")`. Until the + * extraction lands the stub resolves the caller's own record only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + await params; + const gate = await requireModuleEnabled(user.id, "nutrients"); + if (!gate.enabled) return gate.response; + + return notImplemented("medications.ingredients.extract"); + }, +); diff --git a/src/app/api/medications/[id]/ingredients/route.ts b/src/app/api/medications/[id]/ingredients/route.ts new file mode 100644 index 000000000..5a025adce --- /dev/null +++ b/src/app/api/medications/[id]/ingredients/route.ts @@ -0,0 +1,50 @@ +/** + * `PUT /api/medications/{id}/ingredients` — a supplement's composition per + * unit (tablet, capsule). + * + * `{ ingredients: [{ nutrient, amount, unit }], source }` replaces the set. + * Amounts are stored in the catalogue's canonical unit (an input unit such as + * `IU` is kept beside it as `enteredAmount` / `enteredUnit`). The change + * re-counts every taken dose of this medication into the day's micronutrients + * (`recompute.queued`). Needs the `nutrients` module. + * + * Contract authority: `requireRecordAuth("manage", "medications")`. Until the + * composition lands the stub resolves the caller's own record only. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { withIdempotency } from "@/lib/idempotency"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { medicationIngredientsPutSchema } from "@/lib/validations/medication-ingredients"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const PUT = apiHandler( + withIdempotency<[NextRequest, RouteParams]>(putIngredients), +); + +async function putIngredients( + request: NextRequest, + { params }: RouteParams, +): Promise { + const { user } = await requireAuth(); + await params; + const gate = await requireModuleEnabled(user.id, "nutrients"); + if (!gate.enabled) return gate.response; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = medicationIngredientsPutSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "medications.ingredients.invalid", + }); + } + + return notImplemented("medications.ingredients.put"); +} diff --git a/src/app/api/medications/[id]/links/route.ts b/src/app/api/medications/[id]/links/route.ts new file mode 100644 index 000000000..80aae09de --- /dev/null +++ b/src/app/api/medications/[id]/links/route.ts @@ -0,0 +1,50 @@ +/** + * `POST /api/medications/{id}/links` — file conditions (the indication), + * documents or visits against a medication. + * `DELETE /api/medications/{id}/links` — unfile them. + * + * `{ targetKind, targetIds }` in, `{ changed, unknown }` out, through the + * shared link service like the visit and vaccination link routes; an id that + * names nothing this account owns comes back in `unknown`. Own account only, + * as the other link routes. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { medicationLinkSchema } from "@/lib/validations/relation-links"; + +type RouteParams = { params: Promise<{ id: string }> }; + +async function handle( + request: NextRequest, + { params }: RouteParams, + verb: "link" | "unlink", +): Promise { + await requireAuth(); + await params; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = medicationLinkSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "medication.link.invalid", + }); + } + + return notImplemented(`medication.link.${verb}`); +} + +export const POST = apiHandler( + async (request: NextRequest, context: RouteParams) => + handle(request, context, "link"), +); + +export const DELETE = apiHandler( + async (request: NextRequest, context: RouteParams) => + handle(request, context, "unlink"), +); diff --git a/src/app/api/nutrients/catalog/route.ts b/src/app/api/nutrients/catalog/route.ts new file mode 100644 index 000000000..fa47186ec --- /dev/null +++ b/src/app/api/nutrients/catalog/route.ts @@ -0,0 +1,22 @@ +/** + * `GET /api/nutrients/catalog` — the nutrient catalogue as the forms use it. + * + * One entry per code: canonical unit, label in the account's language, + * whether HealthKit may send it (`syncable`), the input units with their + * factor to the canonical unit, and the tolerable upper intake level with its + * scope and source (or null). Clients build the composition form from this + * and never convert units themselves. + */ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { notImplemented } from "@/lib/http/not-implemented"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("read", "measurements"); + const gate = await requireModuleEnabled(user.id, "nutrients"); + if (!gate.enabled) return gate.response; + + return notImplemented("nutrients.catalog.get"); +}); diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 1866a89d9..4649265ae 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -184,6 +184,42 @@ const dayNotable = z }) .meta({ id: "DayNotable" }); +/** + * v1.43 — the day's movement context from Dawarich: daily values only, never + * a coordinate or a place name. The record owner's only, like the + * environment readings; a delegate never receives it. + */ +export const dayMovementSchema = z + .object({ + source: z.literal("dawarich"), + distance: z.object({ + unit: z + .enum(["km", "mi"]) + .describe("Resolved from the account's unit preference."), + total: z.number().nullable(), + driving: z.number().nullable(), + train: z.number().nullable(), + }), + placesVisited: z.number().int().nullable(), + awayFromHomeMinutes: z + .number() + .int() + .nullable() + .describe("`null` means no coverage that day, not zero."), + travel: z + .object({ + isTravelDay: z.boolean(), + countryCode: z.string().nullable(), + }) + .nullable(), + fetchedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "DayMovement", + description: + "Distances to one decimal in `distance.unit`. Hide a null field; never show it as zero.", + }); + export const dayResponseSchema = z .object({ date: dateKeySchema, @@ -212,6 +248,12 @@ export const dayResponseSchema = z .describe( "Sections the caller cannot see. A section of a switched-off module is left out entirely rather than listed.", ), + movement: dayMovementSchema + .nullable() + .optional() + .describe( + "v1.43 — the day's movement context; absent for a delegate, with the `environment` module off, with no Dawarich connection, and on older servers.", + ), }) .meta({ id: "Day" }); diff --git a/src/lib/http/not-implemented.ts b/src/lib/http/not-implemented.ts new file mode 100644 index 000000000..de53c7690 --- /dev/null +++ b/src/lib/http/not-implemented.ts @@ -0,0 +1,34 @@ +/** + * The answer of a route whose contract is published but whose behaviour has + * not landed on this server yet. + * + * A route answers this only after it has authenticated the caller, resolved + * the record, applied its module gate and validated the request exactly as + * the published contract says, so a client already sees the real refusals + * (401, 403, 404, 422, 429). What is missing is the effect, and the body says + * so plainly instead of pretending: `501` with `errorCode: "not_implemented"`. + * Nothing calls these routes in normal use: every client path to them sits + * behind a server feature flag that stays `false` until the route is filled. + * + * `contract-stub-inventory.test.ts` freezes the set of route files that still + * answer this; it has to be empty before a release is tagged. + */ +import { apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; + +export const NOT_IMPLEMENTED_ERROR_CODE = "not_implemented"; + +/** + * 501 for an operation whose contract exists and whose effect does not yet. + * `operation` is a stable `..` name for the wide event. + */ +export function notImplemented(operation: string): Response { + annotate({ + action: { name: "contract.not_implemented" }, + meta: { operation }, + }); + return apiError("Not implemented on this server yet", 501, { + errorCode: NOT_IMPLEMENTED_ERROR_CODE, + operation, + }); +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 1edc9e04c..b2acc7860 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -43,6 +43,8 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "ai.provider.none", "ai.record.notPermitted", "ai.unavailable", + "ai.usage.invalid", + "ai.usage.rateLimited", ], ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"], aiRuns: [ @@ -54,9 +56,13 @@ export const ERROR_CODE_CATALOGUE: Readonly> = allergy: ["allergy.invalid"], analytics: ["analytics.invalid_query"], anamnesis: [ + "anamnesis.auditC.invalid", + "anamnesis.auditC.rateLimited", "anamnesis.fact.conflict", "anamnesis.fact.currentExists", "anamnesis.fact.invalidValue", + "anamnesis.lifeConfirm.invalid", + "anamnesis.lifeSections.invalid", ], auth: [ "auth.admin.required", @@ -118,6 +124,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], "cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"], "disable-coach": ["disable-coach.body.invalid_json"], + dawarich: ["dawarich.invalid", "dawarich.rate_limited"], day: ["day.invalid"], demo: ["demo.readOnly"], documents: [ @@ -211,10 +218,12 @@ export const ERROR_CODE_CATALOGUE: Readonly> = labs: [ "labs.biomarker.create.invalid", "labs.biomarker.duplicate", + "labs.biomarker.merge.invalid", "labs.biomarker.notFound", "labs.biomarker.update.invalid", "labs.biomarker.update.referenceRangeInvalid", "labs.create.invalid", + "labs.link.invalid", "labs.ocr.budgetExceeded", "labs.ocr.extractFailed", "labs.ocr.fileTooLarge", @@ -257,6 +266,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = medication: [ "medication.intake.import.invalid_format", "medication.intake.notTracked", + "medication.link.invalid", ], medications: [ "medications.category.invalid", @@ -269,6 +279,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "medications.course.oneShot", "medications.course.overlap", "medications.course.windowRequired", + "medications.ingredients.invalid", "medications.intake.bulk.apple_health_not_mirrored", "medications.intake.bulk.invalid", "medications.intake.bulk.too_large", @@ -435,6 +446,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "modules_conflict", "mood_tag_layout_conflict", "not_configured", + "not_implemented", "notification_prefs_conflict", "oidc_only", "private_origin_not_approved", diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index bf07bf079..8f0d1ff35 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -20,6 +20,7 @@ * * Part of the OpenAPI route table; aggregated in `./index.ts`. */ +import { HANDOVER_METHODS } from "@/lib/validations/claim-sso"; import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; @@ -521,9 +522,31 @@ const accountPayload = z .describe( "Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose written against one is refused with `medication.intake.notTracked`.", ), + cumulativeHourBuckets: z + .boolean() + .optional() + .describe( + "v1.43 — `POST /api/measurements/batch` takes hourly `stats::T` rows for steps, active energy, walking and running distance and flights climbed. Send them only when this is `true`: an older server stores every hour as its own measurement.", + ), + syncTriggerBackfill: z + .boolean() + .optional() + .describe( + 'v1.43 — `syncTrigger: "backfill"` is accepted on the measurement batch and the intake bulk. Send it only when this is `true`: an older server refuses the whole batch.', + ), + biomarkerMerge: z + .boolean() + .optional() + .describe("v1.43 — `POST /api/biomarkers/{id}/merge` is available."), + lifeCircumstances: z + .boolean() + .optional() + .describe( + "v1.43 — the life-circumstances routes under `/api/anamnesis/life-circumstances` and `/api/anamnesis/audit-c` are available.", + ), }) .describe( - "What this server understands, for a client released apart from it. Constant across accounts; a flag appears once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it.", + "What this server understands, for a client released apart from it. Constant across accounts. A flag that is absent or `false` means this server does not have the feature; a client checks for `true` before it calls a route or sends a request form the flag names. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it.", ), accountAccess: accountAccessBlock, moduleAccess: moduleAccessMap, @@ -605,7 +628,13 @@ const managedProfileHandoverStatus = z available: z .boolean() .describe( - "False on an instance that allows single sign-on only, where a handover cannot be created.", + "Whether a handover can be created on this instance. Up to v1.42 false on an instance that allows single sign-on only; from v1.43 true whenever `methods` is not empty.", + ), + methods: z + .array(z.enum(HANDOVER_METHODS)) + .optional() + .describe( + "v1.43 — how a link can be redeemed here: `password` (not on a single-sign-on-only instance) and `sso` (whenever an identity provider is configured). Absent on older servers.", ), open: z .object({ diff --git a/src/lib/openapi/routes/ai-runs.ts b/src/lib/openapi/routes/ai-runs.ts index d2eeefba6..1be07ade9 100644 --- a/src/lib/openapi/routes/ai-runs.ts +++ b/src/lib/openapi/routes/ai-runs.ts @@ -23,6 +23,7 @@ import { documentSuggestResponse, documentSummaryResponse, } from "./documents"; +import { supplementLabelExtractResult } from "./medications"; import { ocrExtractResponse } from "./ocr"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; @@ -35,6 +36,7 @@ const aiRun = z "DOCUMENT_SUMMARY", "DOCUMENT_SUGGEST", "DOCUMENT_EXTRACT", + "SUPPLEMENT_LABEL_EXTRACT", ]), documentId: z .string() @@ -48,10 +50,11 @@ const aiRun = z documentSummaryResponse, documentSuggestResponse, documentExtractRunResult, + supplementLabelExtractResult, ]) .nullable() .describe( - "Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`, `DOCUMENT_EXTRACT` carries `DocumentExtractRunResult` (not the extract route's detail body). Decode by `kind`; treat an unknown `kind` as a run this client did not start.", + "Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`, `DOCUMENT_EXTRACT` carries `DocumentExtractRunResult` (not the extract route's detail body), `SUPPLEMENT_LABEL_EXTRACT` (v1.43) carries `SupplementLabelExtractResult`. Decode by `kind`; treat an unknown `kind` as a run this client did not start.", ), error: z .object({ diff --git a/src/lib/openapi/routes/ai-usage.ts b/src/lib/openapi/routes/ai-usage.ts new file mode 100644 index 000000000..58f7f7504 --- /dev/null +++ b/src/lib/openapi/routes/ai-usage.ts @@ -0,0 +1,118 @@ +/** + * OpenAPI route table — AI usage levels (v1.43, #1217). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The request + * body reuses the runtime schema from `@/lib/validations/ai-usage`; + * `AiUsageState` is the block `GET /api/auth/me` publishes as `ai.usage` + * (see `AiAccountBlock` in `./profile`), and the PUT answers the same shape. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { AI_USAGE_LEVELS, aiUsagePutSchema } from "@/lib/validations/ai-usage"; + +import { + dataEnvelope, + errorEnvelope, + notImplementedResponse, + stdResponses, +} from "./shared"; + +export const aiUsageLevel = z.enum(AI_USAGE_LEVELS).meta({ + id: "AiUsageLevel", + description: + "How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at most every 24 h after a sync and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The interactive Coach is the same at every level.", +}); + +export const aiUsageState = z + .object({ + level: aiUsageLevel.describe("The level in effect for this record."), + preference: aiUsageLevel + .nullable() + .describe( + "The account's stored choice; `null` follows the instance default.", + ), + defaultLevel: aiUsageLevel.describe( + "The level an account without its own choice runs at.", + ), + maxLevel: aiUsageLevel.describe( + "The highest level the operator allows on this server.", + ), + source: z + .enum(["user", "default", "admin_cap"]) + .describe( + "Where `level` comes from: the stored choice, the instance default, or the operator's ceiling capping a stored choice above it (the choice is kept).", + ), + options: z + .array( + z.object({ + level: aiUsageLevel, + allowed: z.boolean().describe("`level` is at or below `maxLevel`."), + estimatedTokensPerDay: z + .number() + .int() + .nullable() + .describe( + "An estimate, from this account's measured use scaled to the level. `null` without three days of history. Show it as an estimate; never compute one on the client.", + ), + }), + ) + .length(3) + .describe("Always three entries, in the order frugal, standard, full."), + measuredTokensPerDay: z + .number() + .int() + .nullable() + .describe( + "Mean tokens per day over the last seven full days (Coach and background work together); `null` without history.", + ), + measuredDays: z + .number() + .int() + .min(0) + .max(7) + .describe("How many days stand behind `measuredTokensPerDay`."), + }) + .meta({ + id: "AiUsageState", + description: + "The account's AI usage level, resolved on the server. Clients render the picker from `options` and never derive allowance or estimates themselves. Absent on servers older than v1.43.", + }); + +const aiUsagePutRequest = aiUsagePutSchema.meta({ + id: "AiUsagePutRequest", + description: + "Set the account's own AI usage level; `null` returns to the instance default. A level above `maxLevel` is refused, never capped silently.", +}); + +export const aiUsagePaths: NonNullable = { + "/api/auth/me/ai-usage": { + put: { + tags: ["Profile"], + summary: "Set the AI usage level (v1.43)", + description: + "Own account only: a cookie session or a full-scope token. Answers the resolved `AiUsageState`. 422 `ai.usage.invalid` for a malformed body; 422 `ai.usage.aboveCeiling` with `meta.maxLevel` for a level above the operator's ceiling; 429 `ai.usage.rateLimited` after 30 changes in an hour.", + requestBody: { + required: true, + content: { "application/json": { schema: aiUsagePutRequest } }, + }, + responses: { + "200": { + description: "The resolved AI usage state after the change.", + content: { + "application/json": { + schema: dataEnvelope(aiUsageState, "PutAiUsageResponse"), + }, + }, + }, + ...stdResponses, + "422": { + description: + "`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...notImplementedResponse, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index a088663d1..4103ae971 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -10,6 +10,7 @@ * Schemas come from `src/lib/validations/*` where shared with the * runtime request parsing, so the wire contract stays single-source. */ +import { HANDOVER_METHODS, claimSsoSchema } from "@/lib/validations/claim-sso"; import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { @@ -42,6 +43,7 @@ import { } from "@/lib/validations/step-up"; import { dataEnvelope, + notImplementedResponse, profileEmailRateLimitResponse, stdResponses, errorEnvelope, @@ -730,9 +732,28 @@ const claimPreview = z .describe( "Each guardian and the access they keep after the claim, as proposed. The new owner confirms or changes it after signing in.", ), + methods: z + .array(z.enum(HANDOVER_METHODS)) + .optional() + .describe( + "v1.43 — how this link can be redeemed: `password` and/or `sso`. Absent on older servers.", + ), + ssoProviderName: z + .string() + .nullable() + .optional() + .describe( + 'v1.43 — the identity provider\'s display name for the "Continue with …" button; null without one.', + ), }) .meta({ id: "ProfileClaimPreview" }); +const claimSsoRequest = claimSsoSchema.meta({ + id: "ProfileClaimSsoRequest", + description: + "`token` is the `hlp_` token from the handover link, in the body; `username` follows the registration rules and may not start with `managed-`.", +}); + const claimRequest = claimSchema.meta({ id: "ProfileClaimRequest", description: @@ -740,6 +761,44 @@ const claimRequest = claimSchema.meta({ }); export const authPaths: NonNullable = { + "/api/auth/claim/sso": { + post: { + // No credential: the token in the body is the credential. + security: [], + tags: ["Auth"], + summary: "Claim a managed profile through single sign-on (v1.43)", + description: + "Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim.", + requestBody: { + required: true, + content: { "application/json": { schema: claimSsoRequest } }, + }, + responses: { + "200": { + description: "Continue at `redirectTo`.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ redirectTo: z.string() }), + "ProfileClaimSsoEnvelope", + ), + }, + }, + }, + "404": { + description: + "`profile_claim.invalid` or `profile_claim.sso_unavailable`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: "`profile_claim.taken` or `auth.already_authenticated`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + ...notImplementedResponse, + }, + }, + }, "/api/auth/claim/preview": { post: { // No credential: the person claiming the profile has none yet. The diff --git a/src/lib/openapi/routes/awards.ts b/src/lib/openapi/routes/awards.ts index d81221375..d61b80c41 100644 --- a/src/lib/openapi/routes/awards.ts +++ b/src/lib/openapi/routes/awards.ts @@ -9,6 +9,7 @@ * under the owner's name — and both are read-only in fact as well as in * declaration. */ +import { recordBenchmark } from "./trend-verdict"; import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; @@ -54,6 +55,12 @@ const personalRecord = z source: measurementSourceEnum, externalId: z.string().nullable(), createdAt: z.iso.datetime({ offset: true }), + benchmark: recordBenchmark + .nullable() + .optional() + .describe( + "v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null otherwise.", + ), }) .meta({ id: "PersonalRecord", diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 9b8fabf2b..123627175 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -18,12 +18,18 @@ import { createBiomarkerSchema, updateBiomarkerSchema, } from "@/lib/validations/biomarkers"; +import { + BIOMARKER_MERGE_CONVERSIONS, + BIOMARKER_MERGE_REFUSALS, + biomarkerMergeSchema, +} from "@/lib/validations/biomarker-merge"; import { dataEnvelope, errorEnvelope, idempotencyKeyParameter, idempotentWrite, + notImplementedResponse, recordRefusal, recordWriteRateLimitResponse, stdResponses, @@ -57,6 +63,12 @@ const biomarkerRow = z hasContext: z.boolean(), context: z.string().nullable(), hidden: z.boolean(), + aliases: z + .array(z.string()) + .optional() + .describe( + "v1.43 — other spellings that resolve to this marker (folded in by a merge, chosen at an import, or typed on the marker), sorted, without the marker's own name. Absent on older servers.", + ), createdAt: z.string(), updatedAt: z.string(), }) @@ -79,11 +91,47 @@ const conflict = { const notFound = { "404": { - description: "Biomarker not found (or owned by another user).", + description: + "Biomarker not found (or owned by another user). `meta.errorCode` = `labs.biomarker.notFound`. Since v1.43 a marker that was merged into another answers with `meta.mergedInto` naming the marker it went into: drop the cached marker and open that one.", content: { "application/json": { schema: errorEnvelope } }, }, }; +const biomarkerMergeRequest = biomarkerMergeSchema.meta({ + id: "BiomarkerMergeRequest", + description: + "Fold `sourceIds` (1..10, unique, without the target) into the marker in the path. `dryRun` (default false) answers the same result and writes nothing; a client shows it before asking to confirm, because a merge cannot be undone.", +}); + +const biomarkerMergeResult = z + .object({ + dryRun: z.boolean(), + target: biomarkerRow.describe( + "The marker after the merge (unchanged on a dry run).", + ), + sources: z.array( + z.object({ + id: z.string(), + name: z.string(), + unit: z.string(), + readingCount: z.number().int(), + conversion: z.enum(BIOMARKER_MERGE_CONVERSIONS), + refusal: z.enum(BIOMARKER_MERGE_REFUSALS).nullable(), + }), + ), + movedReadings: z.number().int(), + convertedReadings: z.number().int(), + aliasesAdded: z + .array(z.string()) + .describe("Names that become aliases of the target."), + mergeId: z.string().nullable().describe("`null` on a dry run."), + }) + .meta({ + id: "BiomarkerMergeResult", + description: + "What a merge did, or would do on a dry run. Readings in another unit are converted where the analyte's conversion table knows it; the printed value stays in `sourceValue` / `sourceUnit` on each reading.", + }); + export const biomarkerPaths: NonNullable = { "/api/biomarkers": { get: { @@ -181,9 +229,10 @@ export const biomarkerPaths: NonNullable = { tags: ["Labs"], summary: "Delete a biomarker", description: - "Hard-deletes the catalog definition. The `onDelete: SetNull` FK unlinks every reading (they keep their legacy `analyte` / `unit`), so the history survives. Audits as `biomarker.delete`.", + "Hard-deletes the catalog definition together with every reading filed under it, in one transaction. Since v1.43 it needs the same record authority as the edit (manage on the labs section), so a manager of a shared record may delete and the owner is unaffected. Audits as `biomarker.delete` with the marker's name, unit and the number of readings removed.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Deletion succeeded.", content: { @@ -200,4 +249,36 @@ export const biomarkerPaths: NonNullable = { }, }, }, + "/api/biomarkers/{id}/merge": { + post: { + tags: ["Labs"], + summary: "Merge markers into this one (v1.43)", + description: + "Moves every reading of the source markers onto the marker in the path, converting units where the analyte's conversion table knows them, turns the source names into aliases and deletes the sources, in one transaction. A source in a unit without a known conversion refuses the whole merge with 422 `labs.biomarker.merge.unitMismatch` (`meta.sources` names each `{ id, unit, targetUnit, reason }`) and nothing moves. 404 `labs.biomarker.notFound` with `meta.ids` when the target or a source is missing; 422 `labs.biomarker.merge.invalid` for a malformed body or a target among the sources. No undo. Only when `features.biomarkerMerge` is true.", + parameters: [idempotencyKeyParameter], + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: biomarkerMergeRequest } }, + }, + responses: { + ...idempotentWrite(), + "200": { + description: "The merge result (or the dry-run preview).", + content: { + "application/json": { + schema: dataEnvelope( + biomarkerMergeResult, + "MergeBiomarkersResponse", + ), + }, + }, + }, + ...notFound, + ...stdResponses, + ...recordWriteRateLimitResponse, + ...notImplementedResponse, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index e7580035c..354bf29a0 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -5,6 +5,7 @@ * Schemas come from `src/lib/validations/*` where shared with the * runtime request parsing, so the wire contract stays single-source. */ +import { cycleDeleteOutcome, cycleRestoredStart } from "./trend-verdict"; import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { @@ -327,9 +328,20 @@ const cycleHistoryResponse = z.object({ }), }); +const cycleDeleteOutcomeEnvelope = dataEnvelope( + cycleDeleteOutcome, + "CycleDeleteOutcomeEnvelope", +); + const cyclePeriodResponse = z.object({ cycle: menstrualCycleDto.nullable(), dayLog: cycleDayLogDto.nullable(), + absorbed: cycleRestoredStart + .nullable() + .optional() + .describe( + 'v1.43 — on `action: "start"`: a later start within the period length folded into this one, or null.', + ), }); const cyclePhaseCrosstabRow = z @@ -767,7 +779,20 @@ export const cyclePaths: NonNullable = { description: "Day-log updated.", content: { "application/json": { - schema: dataEnvelope(cycleDayLogDto, "CycleDayLogPatchEnvelope"), + schema: dataEnvelope( + z.intersection( + cycleDayLogDto, + z.object({ + restored: z + .array(cycleRestoredStart) + .optional() + .describe( + "v1.43 — starts given back because this edit cleared the flow on a start day.", + ), + }), + ), + "CycleDayLogPatchEnvelope", + ), }, }, }, @@ -786,6 +811,13 @@ export const cyclePaths: NonNullable = { "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + "200": { + description: + "v1.43 — with `Prefer: return=representation`: what the delete changed.", + content: { + "application/json": { schema: cycleDeleteOutcomeEnvelope }, + }, + }, "204": { description: "Soft-deleted (no body)." }, "404": { description: "Day-log not found / not owned.", @@ -941,6 +973,13 @@ export const cyclePaths: NonNullable = { "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + "200": { + description: + "v1.43 — with `Prefer: return=representation`: what the delete changed.", + content: { + "application/json": { schema: cycleDeleteOutcomeEnvelope }, + }, + }, "204": { description: "Soft-deleted (no body)." }, "404": { description: "Cycle not found / not owned.", diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index 83932aaf7..09ae45169 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -14,6 +14,7 @@ * the single-source `@/lib/dashboard-layout` module so the contract cannot * drift. Part of the OpenAPI route table; aggregated in `./index.ts`. */ +import { trendSentiment, trendVerdict } from "./trend-verdict"; import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; @@ -243,6 +244,13 @@ const dashboardMetricCard = z .describe( "The metric's newest reading, for the 'last reading N days ago' caption when it is older than the sparkline window.", ), + direction: trendVerdict + .nullable() + .optional() + .describe("v1.43 — which way a change of this metric counts."), + trendSentiment: trendSentiment + .optional() + .describe("v1.43 — the verdict applied to this card's `trend`."), }) .meta({ id: "DashboardMetricCard", diff --git a/src/lib/openapi/routes/dawarich.ts b/src/lib/openapi/routes/dawarich.ts new file mode 100644 index 000000000..06a89ea72 --- /dev/null +++ b/src/lib/openapi/routes/dawarich.ts @@ -0,0 +1,315 @@ +/** + * OpenAPI route table — Dawarich movement context (v1.43). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies + * reuse the runtime schemas from `@/lib/validations/dawarich`. Daily values + * only; no coordinate or place name is stored. Every route belongs to the + * `environment` module and is the caller's own account. The day block + * (`DayMovement`) is published with `GET /api/day/{date}`. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + dawarichConnectSchema, + dawarichDisconnectSchema, + dawarichSettingsSchema, + dawarichSyncSchema, +} from "@/lib/validations/dawarich"; + +import { connectionHealth } from "./integrations"; +import { + dataEnvelope, + errorEnvelope, + moduleDisabledResponse, + notImplementedResponse, + stdResponses, +} from "./shared"; + +const dawarichConnectRequest = dawarichConnectSchema.meta({ + id: "DawarichConnectRequest", + description: + "`url` (http or https, at most 2048) and `apiKey` (1..512); both sealed at rest. A private host needs its origin in `DAWARICH_PRIVATE_ORIGINS`.", +}); + +const dawarichSettings = dawarichSettingsSchema.meta({ + id: "DawarichSettings", + description: + "The home place (an id on the Dawarich instance) and the home country (ISO 3166-1 alpha-2). Without a home country the most frequent country of the last 365 days is used, and without one there is no travel day.", +}); + +const dawarichStatus = z + .object({ + connected: z.boolean(), + configured: z.boolean(), + homePlaceSet: z.boolean(), + homeCountry: z.string().nullable(), + daysStored: z.number().int(), + oldestDay: z.string().nullable(), + latestDay: z.string().nullable(), + health: connectionHealth.nullable(), + }) + .meta({ + id: "DawarichStatus", + description: + "The Dawarich connection. `health` is the shape every connection publishes; there are no legacy ledger fields.", + }); + +const travelSuggestion = z + .object({ + id: z.string(), + startDate: z.string(), + endDate: z.string(), + countryCode: z.string(), + }) + .meta({ + id: "TravelSuggestion", + description: + "A run of consecutive travel days no stored trip covers. Accept it through `POST /api/environment/travel` (the person picks the place).", + }); + +const responses = ( + ok: Record, + extra: Record = {}, +) => ({ + ...ok, + ...moduleDisabledResponse, + ...extra, + ...stdResponses, + ...notImplementedResponse, +}); + +const errorOnly = (description: string) => ({ + description, + content: { "application/json": { schema: errorEnvelope } }, +}); + +export const dawarichPaths: NonNullable = { + "/api/dawarich/connect": { + post: { + tags: ["Integrations"], + summary: "Connect Dawarich (v1.43)", + description: + "Checks the key against the instance, stores the connection sealed and starts a 30-day backfill. 422 `dawarich.invalid`, `private_origin_not_approved`, `dawarich.credentials_rejected` or `dawarich.unreachable`; 429 `dawarich.rate_limited` (ten a minute).", + requestBody: { + required: true, + content: { "application/json": { schema: dawarichConnectRequest } }, + }, + responses: responses({ + "200": { + description: "Connected.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ connected: z.literal(true) }), + "DawarichConnectEnvelope", + ), + }, + }, + }, + }), + }, + }, + "/api/dawarich/status": { + get: { + tags: ["Integrations"], + summary: "Dawarich connection status (v1.43)", + responses: responses({ + "200": { + description: "The connection.", + content: { + "application/json": { + schema: dataEnvelope(dawarichStatus, "DawarichStatusEnvelope"), + }, + }, + }, + }), + }, + }, + "/api/dawarich/places": { + get: { + tags: ["Integrations"], + summary: "Places on the Dawarich instance (v1.43)", + description: + "Read live from Dawarich to pick the home place (at most 200, filtered by `q`); never stored. 409 `dawarich.not_connected`; 502 `dawarich.unreachable`.", + requestParams: { + query: z.object({ q: z.string().max(80).optional() }), + }, + responses: responses( + { + "200": { + description: "The places.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + places: z.array( + z.object({ id: z.number().int(), name: z.string() }), + ), + }), + "DawarichPlacesEnvelope", + ), + }, + }, + }, + }, + { + "409": errorOnly("`dawarich.not_connected`."), + "502": errorOnly("`dawarich.unreachable`."), + }, + ), + }, + }, + "/api/dawarich/settings": { + patch: { + tags: ["Integrations"], + summary: "Dawarich home place and country (v1.43)", + description: + "422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have.", + requestBody: { + required: true, + content: { "application/json": { schema: dawarichSettings } }, + }, + responses: responses({ + "200": { + description: "The settings after the change.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + homePlaceId: z.number().int().nullable(), + homeCountry: z.string().nullable(), + }), + "DawarichSettingsEnvelope", + ), + }, + }, + }, + }), + }, + }, + "/api/dawarich/test": { + post: { + tags: ["Integrations"], + summary: "Probe the Dawarich connection (v1.43)", + description: + "Answers the shared integration probe shape; a failed probe is an error status. The probe classes are those of the other `/test` routes.", + responses: responses({ + "200": { + description: "The probe reached the instance.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + ok: z.literal(true), + lastSyncedAt: z.iso.datetime({ offset: true }).nullable(), + latencyMs: z.number().int(), + }), + "DawarichProbeEnvelope", + ), + }, + }, + }, + }), + }, + }, + "/api/dawarich/sync": { + post: { + tags: ["Integrations"], + summary: "Fetch Dawarich daily values again (v1.43)", + description: + "From `fromDate` (at most 365 days back). 409 `dawarich.not_connected`; 422 `dawarich.invalid`.", + requestBody: { + required: false, + content: { "application/json": { schema: dawarichSyncSchema } }, + }, + responses: responses( + { + "202": { + description: "Queued.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ enqueued: z.boolean() }), + "DawarichSyncEnvelope", + ), + }, + }, + }, + }, + { "409": errorOnly("`dawarich.not_connected`.") }, + ), + }, + }, + "/api/dawarich/disconnect": { + post: { + tags: ["Integrations"], + summary: "Disconnect Dawarich (v1.43)", + description: + "Forgets the connection; with `deleteData: true` the stored days go too.", + requestBody: { + required: false, + content: { "application/json": { schema: dawarichDisconnectSchema } }, + }, + responses: responses({ + "200": { + description: "Disconnected.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + disconnected: z.literal(true), + deletedDays: z.number().int(), + }), + "DawarichDisconnectEnvelope", + ), + }, + }, + }, + }), + }, + }, + "/api/environment/travel/suggestions": { + get: { + tags: ["Environment"], + summary: "Suggested trips from Dawarich travel days (v1.43)", + responses: responses({ + "200": { + description: "The suggestions.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ suggestions: z.array(travelSuggestion) }), + "TravelSuggestionsEnvelope", + ), + }, + }, + }, + }), + }, + }, + "/api/environment/travel/suggestions/{id}/dismiss": { + post: { + tags: ["Environment"], + summary: "Dismiss a suggested trip (v1.43)", + description: "404 `environment.travel_suggestion.not_found`.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: responses( + { + "200": { + description: "Dismissed.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ dismissed: z.literal(true) }), + "TravelSuggestionDismissEnvelope", + ), + }, + }, + }, + }, + { "404": errorOnly("`environment.travel_suggestion.not_found`.") }, + ), + }, + }, +}; diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 419876ce4..9a7167387 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -14,6 +14,7 @@ * stores until the confirm route commits the user's approvals; a low-confidence * fact fails closed. */ +import { withRelated } from "./relation-links"; import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; @@ -169,6 +170,32 @@ const extractedFact = z provenance: factProvenance, committedRecordId: z.string().nullable(), committedRecordType: z.string().nullable(), + biomarkerId: z + .string() + .nullable() + .optional() + .describe( + "v1.43, OBSERVATION only — the marker matched automatically (name, alias or catalogue abbreviation); null without a match.", + ), + matchKind: z + .enum(["name", "alias", "catalog"]) + .nullable() + .optional() + .describe("v1.43, OBSERVATION only — how `biomarkerId` was found."), + suggestions: z + .array( + z.object({ + biomarkerId: z.string(), + name: z.string(), + unit: z.string(), + matchKind: z.enum(["catalog", "similar"]), + }), + ) + .max(3) + .optional() + .describe( + "v1.43, OBSERVATION only — up to three markers to choose from when nothing matched.", + ), }) .meta({ id: "ExtractedFact", @@ -862,7 +889,7 @@ export const inboundDocumentPaths: NonNullable = { content: { "application/json": { schema: dataEnvelope( - inboundDocumentDetail, + withRelated(inboundDocumentDetail), "InboundDocumentDetailEnvelope", ), }, diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 9c9f1be62..0b1c06033 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -14,6 +14,7 @@ * reaches no response, because every preventive-care read excludes it. The * published contract stays at two members deliberately. */ +import { withRelated } from "./relation-links"; import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; @@ -465,7 +466,10 @@ export const encounterPaths: NonNullable = { description: "The visit.", content: { "application/json": { - schema: dataEnvelope(encounter, "GetEncounterEnvelope"), + schema: dataEnvelope( + withRelated(encounter), + "GetEncounterEnvelope", + ), }, }, }, diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 513a75011..f9868b872 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -13,6 +13,7 @@ * the module off (or an operator-disabled instance) 403s with * `errorCode:"illness.disabled"` even with a valid Bearer token. */ +import { withRelated } from "./relation-links"; import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; @@ -350,7 +351,10 @@ export const illnessPaths: NonNullable = { description: "The episode.", content: { "application/json": { - schema: dataEnvelope(illnessEpisode, "GetIllnessEpisodeEnvelope"), + schema: dataEnvelope( + withRelated(illnessEpisode), + "GetIllnessEpisodeEnvelope", + ), }, }, }, diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 6f870b2ff..0ab861c6b 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -28,6 +28,10 @@ import { recordScopeParameter, } from "./account-sharing"; import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; +import { aiUsagePaths } from "./ai-usage"; +import { dawarichPaths } from "./dawarich"; +import { lifeCircumstancesPaths } from "./life-circumstances"; +import { relationLinkPaths } from "./relation-links"; import { adminBackupPaths } from "./admin-backups"; import { allergyPaths } from "./allergies"; import { analyticsPaths } from "./analytics"; @@ -71,6 +75,7 @@ import { measurementPaths } from "./measurements"; import { measurementReminderPaths } from "./measurement-reminders"; import { mentalHealthPaths } from "./mental-health"; import { + medicationIngredientPaths, medicationPaths, medicationResource, medicationDoseHistoryImportFatalReasonEnum, @@ -187,6 +192,14 @@ export const openApiPaths: NonNullable = { // order is load-bearing). ...dayPaths, ...searchPaths, + // v1.43 — the new contract surfaces: AI usage levels, supplement + // composition, life circumstances and AUDIT-C, Dawarich movement context and + // the relation links (appended, spread order is load-bearing). + ...aiUsagePaths, + ...medicationIngredientPaths, + ...lifeCircumstancesPaths, + ...dawarichPaths, + ...relationLinkPaths, // The environmental-context overview. Its own module because nothing else // owns the surface (appended, spread order is load-bearing). // diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 6f77494fd..cf1812beb 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -5,6 +5,7 @@ * Schemas come from `src/lib/validations/*` where shared with the * runtime request parsing, so the wire contract stays single-source. */ +import { trendSentiment, trendVerdict } from "../trend-verdict"; import { z } from "zod/v4"; import { measurementSourceEnum, measurementTypeEnum } from "../shared"; import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; @@ -1258,6 +1259,12 @@ export const dashboardSnapshotResponse = z "Server-computed band / target math resolved from the user's profile facts, so the client reads these numbers instead of recomputing them. Every sub-field is null only when the driving profile fact is missing (no date of birth for blood pressure, no height and no weight target for weight).", ), tiles: z.object({ + directions: z + .record(z.string(), trendVerdict) + .optional() + .describe( + "v1.43 — per measurement type, which way a change counts. `weightTrend` stays.", + ), summaries: dataSummaryRecord, lastSeenByType: z.record(z.string(), z.unknown()), mood: z.object({ @@ -2478,6 +2485,14 @@ const targetItem = z .describe( "Per-tile extras. Only the medication-compliance tile carries any today; every other tile omits the key.", ), + direction: trendVerdict + .nullable() + .optional() + .describe("v1.43 — which way a change of this target counts."), + trendSentiment: trendSentiment + .nullable() + .optional() + .describe("v1.43 — the verdict applied to this tile's change."), }) .meta({ id: "TargetItem", diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 1773fe2ac..3ee256f62 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -252,6 +252,13 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry .describe( 'When a batch with `syncTrigger: "background"` last carried this type (since v1.42.4). A later `foreground`, `push` or `manual` batch moves `lastReceivedAt` and `lastTrigger` but leaves this standing, so opening the app does not hide an earlier background arrival. Null when the type has never arrived in the background as far as the server recorded: before v1.42.4 only the latest arrival was kept, so a type whose latest arrival then was a background one starts with that date and every other type starts null. Same omission rules as `lastReceivedAt`.', ), + lastBackfillAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe( + 'v1.43 — when a batch with `syncTrigger: "backfill"` last carried this type. A backfill moves only this and `syncProgress`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt` or the account-level sync stamps, so catching up history does not read as a run of healthy daily syncs. Absent on older servers.', + ), }) .meta({ id: "AppleHealthMetricFreshnessEntry", diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 27a25f337..8e7527928 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -7,6 +7,7 @@ * here (the route serialises a derived `rangeStatus` + `hasNote` the input * schema doesn't carry). */ +import { withRelated } from "./relation-links"; import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; @@ -233,7 +234,10 @@ export const labsPaths: NonNullable = { description: "Lab-result detail.", content: { "application/json": { - schema: dataEnvelope(labResultDetail, "GetLabResultResponse"), + schema: dataEnvelope( + withRelated(labResultDetail), + "GetLabResultResponse", + ), }, }, }, diff --git a/src/lib/openapi/routes/life-circumstances.ts b/src/lib/openapi/routes/life-circumstances.ts new file mode 100644 index 000000000..dd936caf6 --- /dev/null +++ b/src/lib/openapi/routes/life-circumstances.ts @@ -0,0 +1,356 @@ +/** + * OpenAPI route table — life circumstances and AUDIT-C (v1.43, #614). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies + * reuse the runtime schemas from `@/lib/validations/life-circumstances`. Every + * route here is new: the new fact kinds never appear on + * `GET /api/anamnesis/facts`, and AUDIT-C never on + * `/api/mental-health/assessments`, because older app builds decode both + * lists closed. Only when `features.lifeCircumstances` is true. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + AUDIT_C_CUTOFF_SOURCES, + LIFE_AI_SECTIONS, + LIFE_FACT_KINDS, + LIFE_SECTION_KEYS, + auditCRequestSchema, + lifeAiSectionsPatchSchema, + lifeConfirmSchema, +} from "@/lib/validations/life-circumstances"; + +import { + dataEnvelope, + errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, + notImplementedResponse, + recordRefusal, + stdResponses, +} from "./shared"; + +const lifeFactRevision = z + .object({ + id: z.string(), + kind: z.enum(["SMOKING_STATUS", "SHIFT_SCHEDULE", ...LIFE_FACT_KINDS]), + value: z + .unknown() + .describe( + "`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`.", + ), + unreadable: z + .boolean() + .describe("The sealed value could not be opened on this host."), + validFrom: z.iso.datetime({ offset: true }), + validUntil: z.null(), + provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]), + createdAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "LifeFactRevision", + description: "The current revision of one life-circumstance fact.", + }); + +const auditCCutoff = z.object({ + value: z.number().int(), + sex: z.enum(["male", "female"]), + source: z.enum(AUDIT_C_CUTOFF_SOURCES), + sourceLabel: z.string(), + sourceUrl: z.string(), +}); + +const auditCResult = z + .object({ + id: z.string(), + takenAt: z.iso.datetime({ offset: true }), + items: z.tuple([z.number().int(), z.number().int(), z.number().int()]), + totalScore: z.number().int().min(0).max(12), + locale: z.string(), + cutoff: auditCCutoff + .nullable() + .describe( + "The screening cut-off for the entry's language and the profile's sex (German S3 guideline, Korean KNHANES, else Bradley 2007). `null` when the profile has no sex; show the sum only.", + ), + aboveCutoff: z.boolean().nullable(), + validatedLocale: z + .boolean() + .describe( + "Whether the item wording shown was a validated translation for `locale`.", + ), + }) + .meta({ + id: "AuditCResult", + description: + "One AUDIT-C entry, scored on the server. A screening result, not a diagnosis.", + }); + +const screeningHint = z.object({ + id: z.enum(["lung_ldct", "aaa_ultrasound"]), + status: z.enum(["suggested", "not_eligible", "unknown"]), + reasonKeys: z.array(z.string()), + region: z.enum(["DE", "US"]), + sourceLabel: z.string(), + sourceUrl: z.string(), +}); + +const lifeCircumstances = z + .object({ + sections: z + .array( + z.object({ + key: z.enum(LIFE_SECTION_KEYS), + aiSection: z.enum([ + "SMOKING_STATUS", + "ALCOHOL_PATTERN", + "ACTIVITY", + "WORK", + "HABITS", + "GOALS", + ]), + aiIncluded: z.boolean(), + lastConfirmedAt: z.iso.datetime({ offset: true }).nullable(), + reviewDue: z + .boolean() + .describe("Older than 365 days; show the quiet yearly check."), + facts: z.array(lifeFactRevision), + }), + ) + .describe("Always all six, in this order."), + extraAiSections: z.object({ SHIFT_SCHEDULE: z.boolean() }), + derived: z.object({ + packYears: z.object({ + value: z.number().nullable(), + status: z.enum(["computed", "not_applicable", "missing_inputs"]), + }), + activityMinutesPerWeek: z.object({ + value: z.number().int().nullable(), + meetsWho150: z.boolean().nullable(), + measuredStepsAvailable: z.boolean(), + }), + freeWeekdays: z.object({ + days: z.array(z.number().int().min(1).max(7)), + source: z.enum(["fact", "default_weekend"]), + }), + screeningHints: z.array(screeningHint), + }), + auditC: z.object({ + latest: auditCResult.nullable(), + history: z + .array( + z.object({ + id: z.string(), + takenAt: z.iso.datetime({ offset: true }), + totalScore: z.number().int(), + }), + ) + .describe("Newest first, at most 20."), + offerDue: z.boolean(), + instrument: z.object({ + items: z + .array( + z.object({ + stem: z.string(), + options: z.array( + z.object({ value: z.number().int(), label: z.string() }), + ), + }), + ) + .describe( + "The three items verbatim in the account language; render as delivered.", + ), + locale: z.string(), + validatedLocale: z.boolean(), + standardDrink: z.string(), + attribution: z.string(), + }), + }), + takeOver: z.array( + z.object({ + field: z.enum(["conditions", "allergies"]), + freeTextPresent: z.boolean(), + structuredCount: z.number().int(), + available: z.boolean(), + }), + ), + }) + .meta({ + id: "LifeCircumstances", + description: + "The six life-circumstance sections with their answers, AI switches and review state, the derived values with their sources, AUDIT-C, and the take-over hints for the free-text conditions and allergies. Every number is resolved on the server.", + }); + +const lifeAiSectionsPatchRequest = lifeAiSectionsPatchSchema.meta({ + id: "LifeAiSectionsPatchRequest", +}); +const lifeConfirmRequest = lifeConfirmSchema.meta({ + id: "LifeConfirmRequest", +}); +const auditCRequest = auditCRequestSchema.meta({ id: "AuditCRequest" }); + +export const lifeCircumstancesPaths: NonNullable = { + "/api/anamnesis/life-circumstances": { + get: { + tags: ["Anamnesis"], + summary: "Life circumstances (v1.43)", + description: + "The life-circumstance sections of the record. Delegates read it under the `profile` grant, like the facts.", + responses: { + ...recordRefusal(), + "200": { + description: "The life circumstances.", + content: { + "application/json": { + schema: dataEnvelope( + lifeCircumstances, + "LifeCircumstancesEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...notImplementedResponse, + }, + }, + }, + "/api/anamnesis/life-circumstances/ai-sections": { + patch: { + tags: ["Anamnesis"], + summary: "Switch life-circumstance sections for the AI (v1.43)", + description: + "Sections not named keep their state. Own account only. 422 `anamnesis.lifeSections.invalid`; 409 `about_me_conflict` for a stale `baseUpdatedAt`.", + requestBody: { + required: true, + content: { + "application/json": { schema: lifeAiSectionsPatchRequest }, + }, + }, + responses: { + "200": { + description: "The switches after the change.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + aiSections: z.partialRecord( + z.enum(LIFE_AI_SECTIONS), + z.boolean(), + ), + updatedAt: z.iso.datetime({ offset: true }), + }), + "LifeAiSectionsEnvelope", + ), + }, + }, + }, + "409": { + description: "`about_me_conflict`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + ...notImplementedResponse, + }, + }, + }, + "/api/anamnesis/life-circumstances/confirm": { + post: { + tags: ["Anamnesis"], + summary: "Confirm a life-circumstance section is still true (v1.43)", + description: + "Stores the confirmation instant only, never a new revision. Own account only. 422 `anamnesis.lifeConfirm.invalid`.", + requestBody: { + required: true, + content: { "application/json": { schema: lifeConfirmRequest } }, + }, + responses: { + "200": { + description: "The confirmed section.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + section: z.enum(LIFE_SECTION_KEYS), + lastConfirmedAt: z.iso.datetime({ offset: true }), + }), + "LifeConfirmEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...notImplementedResponse, + }, + }, + }, + "/api/anamnesis/audit-c": { + post: { + tags: ["Anamnesis"], + summary: "Record an AUDIT-C (v1.43)", + description: + "Scores the three items on the server. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid`; 429 `anamnesis.auditC.rateLimited` after 30 in an hour.", + parameters: [idempotencyKeyParameter], + requestBody: { + required: true, + content: { "application/json": { schema: auditCRequest } }, + }, + responses: { + ...idempotentWrite(), + "200": { + description: "A replay of an entry already stored.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ result: auditCResult }), + "AuditCReplayEnvelope", + ), + }, + }, + }, + "201": { + description: "The stored and scored entry.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ result: auditCResult }), + "AuditCCreatedEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...notImplementedResponse, + }, + }, + }, + "/api/anamnesis/audit-c/{id}": { + delete: { + tags: ["Anamnesis"], + summary: "Remove an AUDIT-C entry (v1.43)", + description: "Soft delete. 404 `anamnesis.auditC.notFound`.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "The removed entry.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + id: z.string(), + removedAt: z.iso.datetime({ offset: true }), + }), + "AuditCRemovedEnvelope", + ), + }, + }, + }, + "404": { + description: "`anamnesis.auditC.notFound`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + ...notImplementedResponse, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 295ad924b..5385ac359 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -5,6 +5,12 @@ * Schemas come from `src/lib/validations/*` where shared with the * runtime request parsing, so the wire contract stays single-source. */ +import { + seriesChange, + seriesReferenceLine, + trendVerdict, + recordBenchmark, +} from "./trend-verdict"; import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { updateMeasurementSchema } from "@/lib/validations/measurement"; @@ -657,6 +663,12 @@ const personalRecordResource = z .describe( "When the detection worker wrote the row — distinct from `achievedAt`, which is when the reading happened.", ), + benchmark: recordBenchmark + .nullable() + .optional() + .describe( + "v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null otherwise.", + ), }) .meta({ id: "PersonalRecord", @@ -758,6 +770,18 @@ const seriesResponse = z count: z.number().int().nonnegative(), }) .describe("Summary over the returned points; all-zero when empty."), + direction: trendVerdict + .nullable() + .optional() + .describe("v1.43 — which way a change of this metric counts."), + change: seriesChange + .nullable() + .optional() + .describe("v1.43 — first and last day value; null under two days."), + referenceLines: z + .array(seriesReferenceLine) + .optional() + .describe("v1.43 — lines to draw; empty when there is nothing to draw."), }) .meta({ id: "MeasurementsSeriesResponse", diff --git a/src/lib/openapi/routes/medications/index.ts b/src/lib/openapi/routes/medications/index.ts index a716e225a..23b007399 100644 --- a/src/lib/openapi/routes/medications/index.ts +++ b/src/lib/openapi/routes/medications/index.ts @@ -8,3 +8,8 @@ export { medicationDoseHistoryImportFatalReasonEnum, } from "./schemas"; export { medicationPaths } from "./paths"; +export { + medicationIngredient, + medicationIngredientPaths, + supplementLabelExtractResult, +} from "./ingredients"; diff --git a/src/lib/openapi/routes/medications/ingredients.ts b/src/lib/openapi/routes/medications/ingredients.ts new file mode 100644 index 000000000..91c8d3bc8 --- /dev/null +++ b/src/lib/openapi/routes/medications/ingredients.ts @@ -0,0 +1,148 @@ +/** + * OpenAPI route table — supplement composition (v1.43). + * + * Part of the medication route table; aggregated in `../index.ts`. The PUT + * body reuses the runtime schema from + * `@/lib/validations/medication-ingredients`. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { medicationIngredientsPutSchema } from "@/lib/validations/medication-ingredients"; + +import { + dataEnvelope, + errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, + moduleDisabledResponse, + notImplementedResponse, + stdResponses, +} from "../shared"; +import { aiRunAccepted } from "../ai-run-accepted"; + +export const medicationIngredient = z + .object({ + nutrient: z.string().describe("Nutrient catalogue code."), + amountPerUnit: z + .number() + .describe("Amount in one unit (tablet, capsule), canonical unit."), + unit: z.string().describe("The catalogue's canonical unit for the code."), + enteredAmount: z + .number() + .nullable() + .describe("The amount as entered when it was entered in another unit."), + enteredUnit: z + .string() + .nullable() + .describe("The input unit it was entered in (e.g. `IU`)."), + labelKey: z.string(), + label: z.string().describe("The nutrient's name in the account language."), + }) + .meta({ + id: "MedicationIngredient", + description: + "What one unit of a medication (usually a supplement) contains. Every taken dose adds `amountPerUnit × unitsPerDose` to the day's micronutrients under the source `SUPPLEMENT`.", + }); + +const medicationIngredientsPutRequest = medicationIngredientsPutSchema.meta({ + id: "MedicationIngredientsPutRequest", + description: + "Replace the composition: 0..40 entries, one per nutrient code, `amount` > 0 in one of the code's `inputUnits` (`GET /api/nutrients/catalog`). `source` is `MANUAL` (default) or `LABEL_AI` after a label proposal was reviewed.", +}); + +export const supplementLabelExtractResult = z + .object({ + ingredients: z.array( + z.object({ + nutrient: z.string(), + amount: z.number(), + unit: z.string(), + confidence: z.enum(["high", "low"]), + printedText: z.string(), + }), + ), + unmatched: z.array(z.object({ printedText: z.string() })), + servingNote: z + .string() + .nullable() + .describe('e.g. "per 2 capsules"; shown, never stored.'), + }) + .meta({ + id: "SupplementLabelExtractResult", + description: + "The `result` of an AI run of kind `SUPPLEMENT_LABEL_EXTRACT`: a proposal only. Nothing is stored until the composition is saved through `PUT /api/medications/{id}/ingredients`.", + }); + +export const medicationIngredientPaths: NonNullable = + { + "/api/medications/{id}/ingredients": { + put: { + tags: ["Medications"], + summary: "Replace a supplement's composition (v1.43)", + description: + 'Stores the composition per unit in canonical units and re-counts every taken dose of this medication into the day\'s micronutrients (`recompute.queued`). Needs the `nutrients` module (403 `module.disabled`, `meta.module: "nutrients"`). 404 `medications.notFound`; 422 `medications.ingredients.invalid` (body, duplicate or unknown code); 422 `medications.ingredients.unitMismatch` with `meta.index` and `meta.allowedUnits`.', + parameters: [idempotencyKeyParameter], + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: medicationIngredientsPutRequest }, + }, + }, + responses: { + ...idempotentWrite(), + "200": { + description: "The stored composition.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + ingredients: z.array(medicationIngredient), + recompute: z.object({ queued: z.boolean() }), + }), + "PutMedicationIngredientsResponse", + ), + }, + }, + }, + ...moduleDisabledResponse, + "404": { + description: "Medication not found.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + ...notImplementedResponse, + }, + }, + }, + "/api/medications/{id}/ingredients/extract": { + post: { + tags: ["Medications"], + summary: "Read a supplement label (v1.43)", + description: + "Multipart `file` (JPEG, PNG, HEIC, WebP or PDF, at most 10 MB). 202 with an AI run; poll `GET /api/ai-runs/{id}` (kind `SUPPLEMENT_LABEL_EXTRACT`, result `SupplementLabelExtractResult`). Needs `ai.capabilities.supplementLabel.available` (refused like every AI capability, reason `usage_level` included) and the `nutrients` module. 422 `medications.ingredients.extract.fileType`, 413 `medications.ingredients.extract.fileTooLarge`, 429 `medications.ingredients.extract.rateLimited` (six an hour).", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: z.object({ + file: z.string().meta({ format: "binary" }), + }), + }, + }, + }, + responses: { + ...aiRunAccepted, + ...moduleDisabledResponse, + "413": { + description: "`medications.ingredients.extract.fileTooLarge`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + ...notImplementedResponse, + }, + }, + }, + }; diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 99e8a6d36..abedc3702 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -5,6 +5,8 @@ * Schemas come from `src/lib/validations/*` where shared with the * runtime request parsing, so the wire contract stays single-source. */ +import { relatedList } from "../relation-links"; +import { medicationIngredient } from "./ingredients"; import { z } from "zod/v4"; import { baseUpdatedAtField } from "../shared"; import { @@ -420,6 +422,12 @@ export const medicationListEntry = medicationResource .describe( "v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate (the same math the low-stock notification engine runs, so the wire and the push can never disagree). NULL = inventory tracking off or no consuming cadence derivable; 0 = tracking on, supply ran out. Read-only — computed, not stored.", ), + hasIngredients: z + .boolean() + .optional() + .describe( + "v1.43 — whether a composition per unit is stored (`GET /api/medications/{id}` carries it as `ingredients`). Absent on older servers.", + ), }) .meta({ id: "MedicationListEntry", @@ -468,6 +476,13 @@ export const medicationDetailEntry = medicationResource .describe( "v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate; NULL = tracking off or no consuming cadence.", ), + ingredients: z + .array(medicationIngredient) + .optional() + .describe( + "v1.43 — the composition per unit, empty when none is stored. Absent on older servers.", + ), + related: relatedList, }) .meta({ id: "MedicationDetail", diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index 87410935d..bcae9fb38 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -19,6 +19,7 @@ * twin was in the YAML, which left grant-scope reviewers unable to see the * cross-section read at all. */ +import { moodPattern } from "./trend-verdict"; import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; @@ -1197,6 +1198,12 @@ const moodAggregatesResponse = z .describe( "Mood against five health channels, keyed `sleep`, `steps`, `pulse`, `weight`, `bloodPressureSystolic`. Every key is always present; an empty channel carries `result: null` with no points.", ), + patterns: z + .array(moodPattern) + .optional() + .describe( + "v1.43 — the patterns that passed the gate over the `?days` window, on the account's mood day. Empty means none was found.", + ), }) .meta({ id: "MoodAggregates", diff --git a/src/lib/openapi/routes/nutrients.ts b/src/lib/openapi/routes/nutrients.ts index a1256ad3e..fb2016342 100644 --- a/src/lib/openapi/routes/nutrients.ts +++ b/src/lib/openapi/routes/nutrients.ts @@ -9,6 +9,7 @@ * per type from here (this is the documented home of that table). */ import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; import { NUTRIENT_DEFINITIONS } from "@/lib/nutrients/catalog"; import { @@ -18,6 +19,7 @@ import { nutrientDailySeriesSchema, nutrientOverviewQuerySchema, nutrientOverviewSchema, + nutrientUpperLimitSchema, nutrientWaterWriteSchema, nutrientWaterWriteResponseSchema, } from "@/lib/validations/nutrients"; @@ -26,6 +28,7 @@ import { errorEnvelope, idempotencyKeyParameter, idempotentWrite, + notImplementedResponse, recordRefusal, stdResponses, } from "./shared"; @@ -49,7 +52,55 @@ const catalogTable = NUTRIENT_DEFINITIONS.map( (d) => `| ${d.code} | ${d.hkIdentifier} | ${d.unit} |`, ).join("\n"); +const nutrientCatalogEntry = z + .object({ + code: z.string(), + unit: z.string().describe("The canonical unit amounts are stored in."), + labelKey: z.string(), + label: z.string().describe("The nutrient's name in the account language."), + syncable: z + .boolean() + .describe( + "Whether HealthKit may send this code in the nutrient batch. `dha` and `epa` are not syncable: they appear in responses only.", + ), + inputUnits: z + .array(z.object({ unit: z.string(), factorToCanonical: z.number() })) + .describe( + "Units an amount may be entered in, with the factor to the canonical unit (vitamin D: `IU` = 0.025 µg).", + ), + upperLimit: nutrientUpperLimitSchema.nullable(), + }) + .meta({ + id: "NutrientCatalogEntry", + description: + "One nutrient of the catalogue as the composition form uses it. Clients build the form from this and never convert units themselves.", + }); + export const nutrientPaths: NonNullable = { + "/api/nutrients/catalog": { + get: { + tags: ["Nutrients"], + summary: "The nutrient catalogue (v1.43)", + description: + "Every nutrient code with its canonical unit, label in the account language, input units and upper intake level. Behind the opt-in `nutrients` module (403 `module.disabled` when off).", + responses: { + ...recordRefusal(NUTRIENTS_DISABLED_DESCRIPTION), + "200": { + description: "The catalogue.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ entries: z.array(nutrientCatalogEntry) }), + "NutrientCatalogEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...notImplementedResponse, + }, + }, + }, "/api/nutrients/batch": { post: { parameters: [idempotencyKeyParameter], diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 493078b4f..8b9834d26 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -82,6 +82,32 @@ const extractedRow = z "The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit.", }), duplicateOf: z.string().nullable(), + biomarkerId: z + .string() + .nullable() + .optional() + .describe( + "v1.43 — the marker this row was matched to automatically (by name, by an alias of the account, or by a catalogue abbreviation); null without a match.", + ), + matchKind: z + .enum(["name", "alias", "catalog"]) + .nullable() + .optional() + .describe("v1.43 — how `biomarkerId` was found."), + suggestions: z + .array( + z.object({ + biomarkerId: z.string(), + name: z.string(), + unit: z.string(), + matchKind: z.enum(["catalog", "similar"]), + }), + ) + .max(3) + .optional() + .describe( + "v1.43 — up to three markers to choose from when nothing matched; empty when `biomarkerId` is set.", + ), }) .meta({ id: "OcrExtractedRow", diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index e249366d4..79e693cec 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -5,6 +5,7 @@ * Schemas come from `src/lib/validations/*` where shared with the * runtime request parsing, so the wire contract stays single-source. */ +import { aiUsageState } from "./ai-usage"; import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { notificationPrefsSchema } from "@/lib/validations/notification-prefs"; @@ -198,7 +199,15 @@ export const aiProviderState = z }); export const aiAccountBlock = z - .object({ capabilities: aiCapabilities, provider: aiProviderState }) + .object({ + capabilities: aiCapabilities, + provider: aiProviderState, + usage: aiUsageState + .optional() + .describe( + "v1.43 — the account's AI usage level. Absent on older servers; hide the setting then.", + ), + }) .meta({ id: "AiAccountBlock", description: diff --git a/src/lib/openapi/routes/relation-links.ts b/src/lib/openapi/routes/relation-links.ts new file mode 100644 index 000000000..25fde948b --- /dev/null +++ b/src/lib/openapi/routes/relation-links.ts @@ -0,0 +1,142 @@ +/** + * OpenAPI route table — relation links (v1.43). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The link + * bodies reuse the runtime schemas from `@/lib/validations/relation-links`. + * `RelatedLink` is the entry of the `related` list six detail responses carry + * (lab result, visit, document, condition episode, medication, vaccination). + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + RELATED_KINDS, + RELATED_VIA, + labResultLinkSchema, + medicationLinkSchema, +} from "@/lib/validations/relation-links"; + +import { + dataEnvelope, + errorEnvelope, + notImplementedResponse, + stdResponses, +} from "./shared"; + +export const relatedLink = z + .object({ + kind: z.enum(RELATED_KINDS), + id: z.string(), + label: z.string(), + date: z + .string() + .nullable() + .describe("`YYYY-MM-DD` or an ISO instant, as the record carries it."), + href: z + .string() + .describe( + "The web path of the related record, the same form as a push `url`.", + ), + via: z + .enum(RELATED_VIA) + .describe( + "`direct` for a stored link; `encounter` when the relation runs through a visit both are filed against; `efficacyTarget` for a medication whose efficacy target is this lab marker.", + ), + }) + .meta({ + id: "RelatedLink", + description: + "One record related to the one shown. Only kinds whose module is on and that the caller's sharing grant covers are listed. Skip an unknown `kind`.", + }); + +/** The optional `related` list a detail response carries since v1.43. */ +export const relatedList = z + .array(relatedLink) + .optional() + .describe( + "v1.43 — records related to this one, resolved on the server. Absent on older servers.", + ); + +/** + * A detail response with its `related` list: the published component of the + * record (by `$ref`) and the list beside it, so list endpoints that share the + * component do not claim the field. + */ +export function withRelated(detail: T) { + return z.intersection(detail, z.object({ related: relatedList })); +} + +const linkResult = z + .object({ + changed: z.number().int(), + unknown: z + .array(z.string()) + .describe("Ids that name nothing this account owns; nothing failed."), + }) + .meta({ id: "RelationLinkResult" }); + +const medicationLinkRequest = medicationLinkSchema.meta({ + id: "MedicationLinkRequest", +}); +const labResultLinkRequest = labResultLinkSchema.meta({ + id: "LabResultLinkRequest", +}); + +function linkOperations( + path: string, + noun: string, + request: z.ZodType, + invalidCode: string, + idPrefix: string, +) { + const op = (verb: "post" | "delete") => ({ + tags: ["Links"], + summary: + verb === "post" + ? `File records against a ${noun} (v1.43)` + : `Unfile records from a ${noun} (v1.43)`, + description: `Idempotent; at most 100 ids. An id that names nothing this account owns comes back in \`unknown\` instead of failing. 404 when the ${noun} is not found; 422 \`${invalidCode}\` for a malformed body. Own account only, as the other link routes.`, + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: request } }, + }, + responses: { + "200": { + description: "How many links changed, and which ids were unknown.", + content: { + "application/json": { + schema: dataEnvelope( + linkResult, + `${idPrefix}${verb === "post" ? "Link" : "Unlink"}Response`, + ), + }, + }, + }, + "404": { + description: `The ${noun} was not found.`, + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + ...notImplementedResponse, + }, + }); + return { [path]: { post: op("post"), delete: op("delete") } }; +} + +export const relationLinkPaths: NonNullable = { + ...linkOperations( + "/api/medications/{id}/links", + "medication", + medicationLinkRequest, + "medication.link.invalid", + "Medication", + ), + ...linkOperations( + "/api/labs/{id}/links", + "lab result", + labResultLinkRequest, + "labs.link.invalid", + "LabResult", + ), +}; diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 79e399cca..f2d059c20 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -551,6 +551,19 @@ export function recordRefusal(...alsoRefusesFor: string[]): RefusalResponse { export const MODULE_DISABLED_DESCRIPTION = 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.'; +/** + * The 501 a published operation answers while its behaviour has not landed on + * this server (`src/lib/http/not-implemented.ts`). Spread into an operation's + * responses with the stub, and removed with it. + */ +export const notImplementedResponse = { + "501": { + description: + "Not implemented on this server yet: the contract is published, the effect is not. `meta.errorCode` = `not_implemented`, `meta.operation` names the operation. Authentication, module gates and request validation already answer as documented. A client reaches this only by ignoring the server feature flag that guards the operation.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + export const moduleDisabledResponse = { "403": { description: MODULE_DISABLED_DESCRIPTION, diff --git a/src/lib/openapi/routes/trend-verdict.ts b/src/lib/openapi/routes/trend-verdict.ts new file mode 100644 index 000000000..c8772683e --- /dev/null +++ b/src/lib/openapi/routes/trend-verdict.ts @@ -0,0 +1,140 @@ +/** + * OpenAPI schemas — server-resolved verdicts for the native client (v1.43, + * ios #116 items 3, 4, 5, 6 and 9). + * + * Shared by the dashboard, insights, measurement, record, mood and cycle + * route tables. Every value here is resolved on the server; clients render + * it and never apply thresholds of their own. + */ +import { z } from "zod/v4"; + +export const trendVerdict = z + .object({ + direction: z + .enum(["up-good", "up-bad", "neutral", "hold"]) + .describe( + "How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` the value is inside its band so staying is progress, `neutral` no judgement.", + ), + targetPosition: z + .enum(["below", "inside", "above"]) + .nullable() + .describe("Where the value sits against the band; null without one."), + basis: z + .enum(["personal_target", "profile_default", "metric_default"]) + .describe( + "What the verdict was judged against: the person's own target, a profile-derived default (e.g. the age-based blood pressure target), or the metric's default direction.", + ), + }) + .meta({ + id: "TrendVerdict", + description: + "Which way a change of a metric counts as progress for this person. The generalisation of `tiles.weightTrend`.", + }); + +export const trendSentiment = z.enum(["positive", "negative", "neutral"]).meta({ + id: "TrendSentiment", + description: + "The verdict applied to one delivered change, with the noise floor applied. Colour the change from this.", +}); + +export const seriesChange = z + .object({ + from: z.number(), + to: z.number(), + delta: z.number(), + sentiment: trendSentiment, + }) + .meta({ + id: "SeriesChange", + description: + "First and last day value in the window (by the metric's day statistic) and how the change counts. Null under two days.", + }); + +export const seriesReferenceLine = z + .object({ + value: z.number().describe("In the unit of the response."), + kind: z.enum(["lower", "upper"]), + source: z.enum(["personal", "clinical"]), + appliesTo: z + .enum(["value", "secondary"]) + .describe("`secondary` is the diastolic value of a blood pressure."), + labelKey: z.string(), + label: z.string().describe("In the account language."), + }) + .meta({ + id: "SeriesReferenceLine", + description: + "A horizontal line to draw on the chart. A personal line replaces the clinical one for the same edge. Empty list when there is nothing to draw.", + }); + +export const recordBenchmark = z + .object({ + low: z.number(), + high: z.number(), + unit: z.string(), + source: z.string(), + sourceLabel: z.string(), + }) + .meta({ + id: "RecordBenchmark", + description: + "An age- and sex-aware comparison band from a cited norm, in the record's unit.", + }); + +export const moodPattern = z + .object({ + id: z + .string() + .describe( + "Stable: `previous_day`, `weekend`, `best_weekday`, `notes`, `multiple_entries` or `delayed_tag:`.", + ), + kind: z.enum([ + "previous_day", + "weekend", + "best_weekday", + "notes", + "delayed_tag", + "multiple_entries", + ]), + strength: z.enum(["weak", "moderate", "strong"]), + direction: z.enum(["higher", "lower", "none"]), + values: z.object({ + subject: z.number(), + comparison: z.number(), + delta: z.number(), + weekday: z.number().int().min(0).max(6).optional(), + tagKey: z.string().optional(), + tagLabel: z.string().optional(), + }), + sampleSize: z.object({ + subject: z.number().int(), + comparison: z.number().int(), + }), + }) + .meta({ + id: "MoodPattern", + description: + "A mood pattern that passed the server's gate (at least 8 days on each side, Welch test, false discovery control). Skip an unknown `kind`.", + }); + +export const cycleRestoredStart = z + .object({ + cycleId: z.string(), + startDate: z.string(), + }) + .meta({ + id: "CycleRestoredStart", + description: + "A cycle start that was folded into another start, or given back after the start it was folded into went away.", + }); + +export const cycleDeleteOutcome = z + .object({ + removedCycleId: z.string().nullable(), + restored: z.array(cycleRestoredStart), + }) + .meta({ + id: "CycleDeleteOutcome", + description: + "What a delete changed, answered with 200 when the request sent `Prefer: return=representation` (RFC 7240; the response carries `Preference-Applied`). Without the header the delete answers 204 as before.", + }); diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index d38b6fec9..35ccdeafd 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -18,6 +18,7 @@ * two-member `origin` enum. The match key that connects them is a server-side * column and reaches no response. */ +import { withRelated } from "./relation-links"; import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; @@ -470,7 +471,10 @@ export const vaccinationPaths: NonNullable = { description: "The dose.", content: { "application/json": { - schema: dataEnvelope(vaccination, "GetVaccinationEnvelope"), + schema: dataEnvelope( + withRelated(vaccination), + "GetVaccinationEnvelope", + ), }, }, }, diff --git a/src/lib/validations/ai-usage.ts b/src/lib/validations/ai-usage.ts new file mode 100644 index 000000000..92e92275e --- /dev/null +++ b/src/lib/validations/ai-usage.ts @@ -0,0 +1,27 @@ +/** + * v1.43 (#1217) — AI usage in three levels. + * + * The wire spells a level in lower case; the database enum (`AiUsageLevel`) + * in upper case. The order of {@link AI_USAGE_LEVELS} is the ceiling order: + * a level is allowed when it is at or below the operator's `maxLevel`. + */ +import { z } from "zod/v4"; + +export const AI_USAGE_LEVELS = ["frugal", "standard", "full"] as const; +export type AiUsageLevelWire = (typeof AI_USAGE_LEVELS)[number]; + +export const aiUsageLevelSchema = z.enum(AI_USAGE_LEVELS); + +/** `PUT /api/auth/me/ai-usage`: the account's own choice; `null` follows the instance default. */ +export const aiUsagePutSchema = z.strictObject({ + level: aiUsageLevelSchema.nullable(), +}); +export type AiUsagePutInput = z.infer; + +/** The `aiUsage` block of `PUT /api/admin/settings/assistant-flags`. */ +export const adminAiUsageSchema = z.strictObject({ + maxLevel: aiUsageLevelSchema, + defaultLevel: aiUsageLevelSchema, + syncRegenGapHours: z.number().int().min(1).max(48).nullable(), +}); +export type AdminAiUsageInput = z.infer; diff --git a/src/lib/validations/biomarker-merge.ts b/src/lib/validations/biomarker-merge.ts new file mode 100644 index 000000000..dbaec99ee --- /dev/null +++ b/src/lib/validations/biomarker-merge.ts @@ -0,0 +1,45 @@ +/** + * v1.43 (D1223) — merging biomarkers. `{id}` in the path is the marker that + * stays; `sourceIds` are folded into it. That the target is not among the + * sources is checked by the route, which knows the path id. + */ +import { z } from "zod/v4"; + +export const BIOMARKER_MERGE_MAX_SOURCES = 10; + +export const biomarkerMergeSchema = z + .strictObject({ + sourceIds: z + .array(z.string().trim().min(1).max(64)) + .min(1) + .max(BIOMARKER_MERGE_MAX_SOURCES), + dryRun: z.boolean().optional().default(false), + }) + .superRefine((value, ctx) => { + if (new Set(value.sourceIds).size !== value.sourceIds.length) { + ctx.addIssue({ + code: "custom", + path: ["sourceIds"], + message: "sourceIds must be unique", + }); + } + }); +export type BiomarkerMergeInput = z.infer; + +export const BIOMARKER_MERGE_CONVERSIONS = [ + "none", + "converted", + "refused", +] as const; +export const BIOMARKER_MERGE_REFUSALS = [ + "unknown_analyte", + "unknown_unit", + "not_finite", +] as const; + +/** Where an alias came from (`biomarker_aliases.source`). */ +export const BIOMARKER_ALIAS_SOURCES = [ + "MERGE", + "IMPORT_CHOICE", + "MANUAL", +] as const; diff --git a/src/lib/validations/claim-sso.ts b/src/lib/validations/claim-sso.ts new file mode 100644 index 000000000..e53466a1b --- /dev/null +++ b/src/lib/validations/claim-sso.ts @@ -0,0 +1,17 @@ +/** + * v1.43 (#959) — claiming a managed profile through single sign-on. The + * handover token and the chosen username ride a sealed cookie into the OIDC + * round trip; the token never rides a URL. + */ +import { z } from "zod/v4"; + +import { accountUsernameSchema } from "@/lib/validations/auth"; + +export const claimSsoSchema = z.strictObject({ + token: z.string().min(1).max(80), + username: accountUsernameSchema, +}); +export type ClaimSsoInput = z.infer; + +/** How a handover link can be redeemed on this server. */ +export const HANDOVER_METHODS = ["password", "sso"] as const; diff --git a/src/lib/validations/dawarich.ts b/src/lib/validations/dawarich.ts new file mode 100644 index 000000000..c7ed4aba6 --- /dev/null +++ b/src/lib/validations/dawarich.ts @@ -0,0 +1,48 @@ +/** + * v1.43 — Dawarich as movement context (daily values only). Part of the + * `environment` module; every route is the caller's own record. + */ +import { z } from "zod/v4"; + +const dayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD"); + +export const dawarichConnectSchema = z.strictObject({ + url: z + .string() + .trim() + .max(2048) + .url() + .refine((value) => /^https?:\/\//i.test(value), "Use http or https"), + apiKey: z.string().trim().min(1).max(512), +}); +export type DawarichConnectInput = z.infer; + +export const dawarichPlacesQuerySchema = z.strictObject({ + q: z.string().trim().max(80).optional(), +}); + +export const dawarichSettingsSchema = z + .strictObject({ + homePlaceId: z.number().int().positive().nullable().optional(), + homeCountry: z + .string() + .regex(/^[A-Z]{2}$/, "ISO 3166-1 alpha-2") + .nullable() + .optional(), + }) + .refine( + (value) => + value.homePlaceId !== undefined || value.homeCountry !== undefined, + "Send homePlaceId, homeCountry or both", + ); +export type DawarichSettingsInput = z.infer; + +/** At most 365 days back, checked by the route against the account's today. */ +export const DAWARICH_SYNC_MAX_DAYS = 365; +export const dawarichSyncSchema = z.strictObject({ + fromDate: dayKey.optional(), +}); + +export const dawarichDisconnectSchema = z.strictObject({ + deleteData: z.boolean().optional().default(false), +}); diff --git a/src/lib/validations/life-circumstances.ts b/src/lib/validations/life-circumstances.ts new file mode 100644 index 000000000..942c26743 --- /dev/null +++ b/src/lib/validations/life-circumstances.ts @@ -0,0 +1,221 @@ +/** + * v1.43 (#614) — life circumstances: six sections of dated, sealed, + * self-reported context, and AUDIT-C for the alcohol section. + * + * Each new fact kind stores a JSON object with `schema: 1`; the kinds are + * deliberately NOT in `HEALTH_PROFILE_FACT_KINDS` (health-profile-facts.ts), + * which is what `GET /api/anamnesis/facts` lists: older app builds decode that + * enum closed. They are read through `GET /api/anamnesis/life-circumstances`. + * Every question allows "no answer" (`null`, or an empty list). + */ +import { z } from "zod/v4"; + +import { assertStableExternalId } from "@/lib/validations/external-id"; + +// ── Fact kinds and their values ────────────────────────────────────────────── + +export const LIFE_FACT_KINDS = [ + "TOBACCO_DETAIL", + "ACTIVITY_HABIT", + "WORK_CONTEXT", + "FREE_WEEKDAYS", + "HABITS", + "HEALTH_GOALS", +] as const; +export type LifeFactKind = (typeof LIFE_FACT_KINDS)[number]; + +const schemaVersion = z.literal(1); +const currentYear = () => new Date().getUTCFullYear(); + +export const TOBACCO_PRODUCTS = [ + "cigarettes", + "cigars_pipe", + "e_cigarette", + "heated_tobacco", + "snus_pouches", +] as const; + +export const tobaccoDetailValueSchema = z + .strictObject({ + schema: schemaVersion, + products: z.array(z.enum(TOBACCO_PRODUCTS)).max(TOBACCO_PRODUCTS.length), + cigarettesPerDay: z.number().int().min(1).max(100).nullable(), + startYear: z + .number() + .int() + .min(1900) + .refine((year) => year <= currentYear(), "Not in the future") + .nullable(), + quitMonth: z + .string() + .regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Use YYYY-MM") + .nullable(), + secondhandSmoke: z.boolean().nullable(), + }) + .refine( + (value) => + value.cigarettesPerDay === null || value.products.includes("cigarettes"), + { + path: ["cigarettesPerDay"], + message: "Only with cigarettes", + }, + ); + +export const activityHabitValueSchema = z.strictObject({ + schema: schemaVersion, + moderateDaysPerWeek: z.number().int().min(0).max(7).nullable(), + moderateMinutesPerDay: z.number().int().min(0).max(600).nullable(), + sittingHoursWorkday: z.number().min(0).max(24).multipleOf(0.5).nullable(), +}); + +export const WORK_SITUATIONS = ["none", "indoor", "outdoor", "mixed"] as const; +export const WORK_PHYSICAL_NATURE = [ + "mostly_sitting", + "standing_walking", + "heavy_physical", +] as const; +export const WORK_EXPOSURES = [ + "heat", + "cold", + "dust", + "fumes_solvents", + "noise", +] as const; + +export const workContextValueSchema = z.strictObject({ + schema: schemaVersion, + situation: z.enum(WORK_SITUATIONS).nullable(), + physicalNature: z.enum(WORK_PHYSICAL_NATURE).nullable(), + nightWork: z.boolean().nullable(), + exposures: z.array(z.enum(WORK_EXPOSURES)).max(WORK_EXPOSURES.length), +}); + +export const freeWeekdaysValueSchema = z.strictObject({ + schema: schemaVersion, + /** ISO weekdays, 1 = Monday. */ + days: z.array(z.number().int().min(1).max(7)).max(7), +}); + +export const DIET_PATTERNS = [ + "mixed", + "vegetarian", + "vegan", + "pescetarian", + "other", +] as const; +export const DIET_SPECIFICS = [ + "intermittent_fasting", + "low_carb", + "prescribed_diet", +] as const; +export const LATEST_CAFFEINE = [ + "before_12", + "12_15", + "15_18", + "after_18", +] as const; + +export const habitsValueSchema = z.strictObject({ + schema: schemaVersion, + napRegularly: z.boolean().nullable(), + snoringReported: z.boolean().nullable(), + dietPattern: z.enum(DIET_PATTERNS).nullable(), + dietSpecifics: z.array(z.enum(DIET_SPECIFICS)).max(DIET_SPECIFICS.length), + lateMealsPerWeek: z.number().int().min(0).max(7).nullable(), + fruitVegPortionsPerDay: z.number().int().min(0).max(15).nullable(), + caffeineDrinksPerDay: z.number().int().min(0).max(20).nullable(), + latestCaffeine: z.enum(LATEST_CAFFEINE).nullable(), +}); + +export const HEALTH_GOALS = [ + "weight", + "blood_pressure", + "blood_sugar", + "sleep", + "fitness", + "stress_recovery", + "medication_routine", + "quit_smoking", + "less_alcohol", +] as const; + +export const healthGoalsValueSchema = z.strictObject({ + schema: schemaVersion, + goals: z.array(z.enum(HEALTH_GOALS)).max(3), +}); + +/** The value schema per kind, the one map every writer and reader uses. */ +export const LIFE_FACT_VALUE_SCHEMAS = { + TOBACCO_DETAIL: tobaccoDetailValueSchema, + ACTIVITY_HABIT: activityHabitValueSchema, + WORK_CONTEXT: workContextValueSchema, + FREE_WEEKDAYS: freeWeekdaysValueSchema, + HABITS: habitsValueSchema, + HEALTH_GOALS: healthGoalsValueSchema, +} as const satisfies Record; + +// ── Sections ───────────────────────────────────────────────────────────────── + +export const LIFE_SECTION_KEYS = [ + "tobacco", + "alcohol", + "activity", + "work", + "habits", + "goals", +] as const; +export type LifeSectionKey = (typeof LIFE_SECTION_KEYS)[number]; + +/** The AI sections the life-circumstances surface switches. */ +export const LIFE_AI_SECTIONS = [ + "SMOKING_STATUS", + "ALCOHOL_PATTERN", + "SHIFT_SCHEDULE", + "ACTIVITY", + "WORK", + "HABITS", + "GOALS", +] as const; +export type LifeAiSection = (typeof LIFE_AI_SECTIONS)[number]; + +/** `PATCH /api/anamnesis/life-circumstances/ai-sections`. */ +export const lifeAiSectionsPatchSchema = z + .strictObject({ + sections: z.partialRecord(z.enum(LIFE_AI_SECTIONS), z.boolean()), + baseUpdatedAt: z.iso.datetime({ offset: true }).optional(), + }) + .refine((value) => Object.keys(value.sections).length > 0, { + path: ["sections"], + message: "At least one section", + }); +export type LifeAiSectionsPatchInput = z.infer< + typeof lifeAiSectionsPatchSchema +>; + +/** `POST /api/anamnesis/life-circumstances/confirm`. */ +export const lifeConfirmSchema = z.strictObject({ + section: z.enum(LIFE_SECTION_KEYS), +}); + +// ── AUDIT-C ────────────────────────────────────────────────────────────────── + +const auditCItem = z.number().int().min(0).max(4); + +/** `POST /api/anamnesis/audit-c`. */ +export const auditCRequestSchema = z + .strictObject({ + items: z.tuple([auditCItem, auditCItem, auditCItem]), + takenAt: z.iso.datetime({ offset: true }).optional(), + tz: z.string().min(1).max(64).optional(), + locale: z.string().min(2).max(16).optional(), + source: z.enum(["WEB", "IOS"]), + externalId: z.string().min(1).max(128).optional(), + }) + .superRefine(assertStableExternalId); +export type AuditCRequestInput = z.infer; + +export const AUDIT_C_CUTOFF_SOURCES = [ + "S3_ALCOHOL_DE", + "KNHANES_KR", + "BRADLEY_2007", +] as const; diff --git a/src/lib/validations/medication-ingredients.ts b/src/lib/validations/medication-ingredients.ts new file mode 100644 index 000000000..e1069d590 --- /dev/null +++ b/src/lib/validations/medication-ingredients.ts @@ -0,0 +1,52 @@ +/** + * v1.43 — a supplement's composition per unit (tablet, capsule). + * + * The shape is checked here; whether `nutrient` is a catalogue code and + * `unit` one of its input units is checked against the nutrient catalogue by + * the route (422 `medications.ingredients.unitMismatch` carries the index and + * the allowed units), so the catalogue stays the one place units live. + */ +import { z } from "zod/v4"; + +export const MEDICATION_INGREDIENTS_MAX = 40; +export const MEDICATION_INGREDIENT_SOURCES = ["MANUAL", "LABEL_AI"] as const; + +export const medicationIngredientInputSchema = z.strictObject({ + nutrient: z.string().trim().min(1).max(40), + amount: z.number().finite().positive(), + unit: z.string().trim().min(1).max(8), +}); + +export const medicationIngredientsPutSchema = z + .strictObject({ + ingredients: z + .array(medicationIngredientInputSchema) + .max(MEDICATION_INGREDIENTS_MAX), + source: z.enum(MEDICATION_INGREDIENT_SOURCES).optional().default("MANUAL"), + }) + .superRefine((value, ctx) => { + const seen = new Set(); + value.ingredients.forEach((ingredient, index) => { + if (seen.has(ingredient.nutrient)) { + ctx.addIssue({ + code: "custom", + path: ["ingredients", index, "nutrient"], + message: "Each nutrient may appear once", + }); + } + seen.add(ingredient.nutrient); + }); + }); +export type MedicationIngredientsPutInput = z.infer< + typeof medicationIngredientsPutSchema +>; + +/** Label photo upload limits for `POST /api/medications/{id}/ingredients/extract`. */ +export const SUPPLEMENT_LABEL_MAX_BYTES = 10 * 1024 * 1024; +export const SUPPLEMENT_LABEL_MIME_TYPES = [ + "image/jpeg", + "image/png", + "image/heic", + "image/webp", + "application/pdf", +] as const; diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts index e65014ed6..f7c462eb8 100644 --- a/src/lib/validations/nutrients.ts +++ b/src/lib/validations/nutrients.ts @@ -88,6 +88,36 @@ export const nutrientOverviewQuerySchema = z.object({ days: z.coerce.number().int().min(1).max(365).default(14), }); +/** + * v1.43 — the tolerable upper intake level of a nutrient (EFSA), applied by + * the server to the day's total or to the supplemental share as `scope` says. + */ +export const nutrientUpperLimitSchema = z + .object({ + value: z.number(), + unit: z.string(), + scope: z.enum(["total", "supplemental"]), + source: z.string(), + }) + .meta({ + id: "NutrientUpperLimit", + description: + "The tolerable upper intake level, with the unit, whether it applies to the total intake or to the supplemental share only, and the cited source. `null` where no level is established.", + }); + +/** v1.43 — a day total split by where it came from. */ +export const nutrientBySourceSchema = z + .object({ + APPLE_HEALTH: z.number().optional(), + MANUAL: z.number().optional(), + SUPPLEMENT: z.number().optional(), + }) + .meta({ + id: "NutrientAmountBySource", + description: + "The amount split by source: synced from Apple Health, entered by hand, and counted from taken supplement doses. A source with nothing is absent.", + }); + export const nutrientOverviewSchema = z .object({ windowDays: z.number().int().min(1), @@ -98,6 +128,20 @@ export const nutrientOverviewSchema = z latestDay: z.string(), latestAmount: z.number(), daysWithData: z.number().int().min(1), + latestBySource: nutrientBySourceSchema + .optional() + .describe("v1.43 — `latestAmount` split by source."), + upperLimit: nutrientUpperLimitSchema + .nullable() + .optional() + .describe("v1.43 — the nutrient's upper intake level, or null."), + latestAboveUpperLimit: z + .boolean() + .nullable() + .optional() + .describe( + "v1.43 — whether the latest day is above the upper limit; null without one.", + ), }), ), lastAttempt: z @@ -177,8 +221,27 @@ export const nutrientDailySeriesSchema = z nutrient: nutrientCodeEnum, unit: z.string(), windowDays: z.number().int().min(1), - days: z.array(z.object({ day: z.string(), amount: z.number() })), + days: z.array( + z.object({ + day: z.string(), + amount: z.number(), + bySource: nutrientBySourceSchema + .optional() + .describe("v1.43 — the day's amount split by source."), + aboveUpperLimit: z + .boolean() + .nullable() + .optional() + .describe( + "v1.43 — whether the day is above the upper limit; null without one.", + ), + }), + ), reference: resolvedNutrientReferenceSchema.nullable(), + upperLimit: nutrientUpperLimitSchema + .nullable() + .optional() + .describe("v1.43 — the nutrient's upper intake level, or null."), }) .meta({ id: "NutrientDailySeries", diff --git a/src/lib/validations/pulse-day-mean.ts b/src/lib/validations/pulse-day-mean.ts new file mode 100644 index 000000000..39a837534 --- /dev/null +++ b/src/lib/validations/pulse-day-mean.ts @@ -0,0 +1,14 @@ +/** + * v1.43 (#1107 follow-up) — the admin pulse day-mean dry run and apply step. + */ +import { z } from "zod/v4"; + +export const PULSE_DAY_MEAN_METHODS = [ + "hour_mean_v1", + "weighted_hour_mean_v2", +] as const; + +export const pulseDayMeanActionSchema = z.strictObject({ + action: z.enum(["dry_run", "apply"]), +}); +export type PulseDayMeanActionInput = z.infer; diff --git a/src/lib/validations/relation-links.ts b/src/lib/validations/relation-links.ts new file mode 100644 index 000000000..3290ab06a --- /dev/null +++ b/src/lib/validations/relation-links.ts @@ -0,0 +1,39 @@ +/** + * v1.43 — the relation links that close the relation matrix. Same body and + * answer as the existing link routes (`{ targetKind, targetIds }` in, + * `{ changed, unknown }` out). + */ +import { z } from "zod/v4"; + +const targetIds = z.array(z.string().trim().min(1).max(64)).min(1).max(100); + +/** `POST/DELETE /api/medications/{id}/links`. */ +export const MEDICATION_LINK_TARGETS = [ + "conditionEpisode", + "document", + "encounter", +] as const; +export const medicationLinkSchema = z.strictObject({ + targetKind: z.enum(MEDICATION_LINK_TARGETS), + targetIds, +}); +export type MedicationLinkInput = z.infer; + +/** `POST/DELETE /api/labs/{id}/links`. */ +export const LAB_RESULT_LINK_TARGETS = ["document", "encounter"] as const; +export const labResultLinkSchema = z.strictObject({ + targetKind: z.enum(LAB_RESULT_LINK_TARGETS), + targetIds, +}); +export type LabResultLinkInput = z.infer; + +/** `related[]` on the six detail responses. */ +export const RELATED_KINDS = [ + "labResult", + "encounter", + "document", + "conditionEpisode", + "medication", + "vaccination", +] as const; +export const RELATED_VIA = ["direct", "encounter", "efficacyTarget"] as const; From 2fd62cc115065777f380b2273260839cfb5994a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:05:06 +0200 Subject: [PATCH 010/127] feat(api): carry the request id in meta.requestId on every error envelope --- src/__tests__/helpers/error-envelope.ts | 35 +++++ .../login/__tests__/constant-work.test.ts | 3 +- .../inbound/[id]/chat/__tests__/route.test.ts | 1 + .../[id]/extract/__tests__/route.test.ts | 1 + .../inbound/reindex/__tests__/route.test.ts | 1 + .../intake/import/__tests__/route.test.ts | 1 + .../__tests__/route.test.ts | 1 + .../__tests__/email-test-route.test.ts | 8 +- .../__tests__/private-origin-routes.test.ts | 46 ++++-- .../__tests__/acting-account-resolver.test.ts | 17 ++- src/lib/__tests__/ai-refusal-envelope.test.ts | 1 + .../api-handler-error-request-id.test.ts | 140 ++++++++++++++++++ .../api-handler-key-mismatch.test.ts | 5 +- src/lib/__tests__/auth-error-codes.test.ts | 6 +- src/lib/api-handler.ts | 9 +- src/lib/api-response.ts | 63 ++++++++ .../acting-account-resolver.test.ts | 5 +- .../ai-extraction-capability.test.ts | 6 + .../integration/encryption-key-check.test.ts | 6 +- .../login-insecure-transport.test.ts | 1 + tests/integration/sharing-lifecycle.test.ts | 5 +- tests/integration/sharing-scope-fence.test.ts | 5 +- 22 files changed, 336 insertions(+), 30 deletions(-) create mode 100644 src/__tests__/helpers/error-envelope.ts create mode 100644 src/lib/__tests__/api-handler-error-request-id.test.ts diff --git a/src/__tests__/helpers/error-envelope.ts b/src/__tests__/helpers/error-envelope.ts new file mode 100644 index 000000000..960c78856 --- /dev/null +++ b/src/__tests__/helpers/error-envelope.ts @@ -0,0 +1,35 @@ +/** + * Error-envelope helpers for tests that compare whole response bodies. + * + * `apiHandler` stamps every error envelope with `meta.requestId`, a fresh id + * per request. A test that proves two refusals are byte-identical (an + * enumeration oracle check) compares everything else: the id is random, the + * same for no two requests, and says nothing about the account. + */ + +/** The body text with `meta.requestId` removed, `meta` dropped when empty. */ +export function withoutRequestId(text: string): string { + let body: unknown; + try { + body = JSON.parse(text); + } catch { + return text; + } + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return text; + } + const record = { ...(body as Record) }; + const meta = record.meta; + if (typeof meta === "object" && meta !== null && !Array.isArray(meta)) { + const rest = { ...(meta as Record) }; + delete rest.requestId; + if (Object.keys(rest).length === 0) delete record.meta; + else record.meta = rest; + } + return JSON.stringify(record); +} + +/** A response's body text with `meta.requestId` removed. */ +export async function bodyWithoutRequestId(res: Response): Promise { + return withoutRequestId(await res.text()); +} diff --git a/src/app/api/auth/login/__tests__/constant-work.test.ts b/src/app/api/auth/login/__tests__/constant-work.test.ts index 6490b756a..a41edac7b 100644 --- a/src/app/api/auth/login/__tests__/constant-work.test.ts +++ b/src/app/api/auth/login/__tests__/constant-work.test.ts @@ -80,6 +80,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { verifyPasswordOrDummy } from "@/lib/auth/password"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; function makeRequest(): NextRequest { return new NextRequest("http://localhost/api/auth/login", { @@ -159,7 +160,7 @@ describe("POST /api/auth/login — constant work across the refusals", () => { vi.mocked(prisma.user.findFirst).mockResolvedValue(account as never); const res = await POST(makeRequest()); statuses.push(res.status); - bodies.push(await res.text()); + bodies.push(await bodyWithoutRequestId(res)); } expect(new Set(statuses)).toEqual(new Set([401])); expect(new Set(bodies).size).toBe(1); diff --git a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts index c04152cc4..1cee38bac 100644 --- a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts @@ -421,6 +421,7 @@ describe("POST — consent / budget gating", () => { expect(res.status).toBe(403); const body = await res.json(); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "consent.ai.required", capability: "documentAi", reason: "consent_required", diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts index a03e75fe6..1f143900a 100644 --- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -371,6 +371,7 @@ describe("POST /api/documents/inbound/[id]/extract — the documentAi capability expect(res.status).toBe(403); const body = await res.json(); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "assistant.disabled.documentAi", capability: "documentAi", reason: "operator_disabled", diff --git a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts index bcb3dc018..dcdaad6ec 100644 --- a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts @@ -137,6 +137,7 @@ describe("POST /api/documents/inbound/reindex — the documentAi capability", () expect(res.status).toBe(403); const body = await res.json(); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "assistant.disabled.documentAi", capability: "documentAi", reason: "operator_disabled", diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index a66164c05..cd905a742 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -344,6 +344,7 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => { await expect(res.json()).resolves.toEqual({ data: null, error: "Medication intake import already in progress", + meta: { requestId: expect.any(String) }, }); expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled(); expect(getGlobalBoss).not.toHaveBeenCalled(); diff --git a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts index ce9e2e813..491f52dd5 100644 --- a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts +++ b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts @@ -232,6 +232,7 @@ describe("POST /api/medications/intake/dose-history-import", () => { expect(res.status).toBe(422); const body = await res.json(); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "missing_required_columns", detail: "Medication, Status", }); diff --git a/src/app/api/settings/__tests__/email-test-route.test.ts b/src/app/api/settings/__tests__/email-test-route.test.ts index 2bf5e0684..c54eec97a 100644 --- a/src/app/api/settings/__tests__/email-test-route.test.ts +++ b/src/app/api/settings/__tests__/email-test-route.test.ts @@ -86,7 +86,11 @@ describe("POST /api/settings/email/test", () => { expect(response.status).toBe(502); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: failureCode, smtpCode }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: failureCode, + smtpCode, + }); expect(json.error).toContain(sentence); }, ); @@ -118,7 +122,7 @@ describe("POST /api/settings/email/test", () => { expect(response.status).toBe(500); const json = await response.json(); - expect(json.meta).toBeUndefined(); + expect(json.meta).toEqual({ requestId: expect.any(String) }); }); it("answers a delivered test with 200", async () => { diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts index edcc96bd4..a7c46caf3 100644 --- a/src/app/api/settings/__tests__/private-origin-routes.test.ts +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -140,7 +140,10 @@ describe("PUT /api/settings/webhook — private targets", () => { expect(response.status).toBe(422); const json = await response.json(); expect(json.data).toBeNull(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); }); @@ -155,7 +158,10 @@ describe("PUT /api/settings/webhook — private targets", () => { expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); }); @@ -181,7 +187,7 @@ describe("PUT /api/settings/webhook — private targets", () => { expect(response.status).toBe(422); const json = await response.json(); expect(json.error).toBe("Invalid data"); - expect(json.meta).toBeUndefined(); + expect(json.meta).toEqual({ requestId: expect.any(String) }); }); it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => { @@ -223,7 +229,10 @@ describe("PUT /api/settings/webhook — private targets", () => { const response = await put("webhook", { url, enabled: true }); expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_grantable", + }); expect(json.error).toMatch(/no operator grant can open/); expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); expect(json.details.issues.length).toBeGreaterThan(0); @@ -262,7 +271,10 @@ describe("PUT /api/settings/ntfy — private servers", () => { expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); }); @@ -291,7 +303,10 @@ describe("PUT /api/settings/ntfy — private servers", () => { expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); }); }); @@ -325,7 +340,10 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); }, ); @@ -347,7 +365,10 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_grantable", + }); expect(json.error).toMatch(/no operator grant can open/); expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); }, @@ -372,6 +393,7 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(502); const json = await response.json(); expect(json.meta).toEqual({ + requestId: expect.any(String), errorCode: "upstream_rejected", upstreamStatus: 400, upstreamBody: '{"error":"Bad Request","errorCode":400}', @@ -408,7 +430,11 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(502); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: code, upstreamStatus: status }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: code, + upstreamStatus: status, + }); }, ); @@ -450,7 +476,7 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(500); const json = await response.json(); - expect(json.meta).toBeUndefined(); + expect(json.meta).toEqual({ requestId: expect.any(String) }); expect(emitIfSampledMock).toHaveBeenCalledWith( expect.objectContaining({ level: "error" }), ); diff --git a/src/lib/__tests__/acting-account-resolver.test.ts b/src/lib/__tests__/acting-account-resolver.test.ts index 6ed62b275..458243588 100644 --- a/src/lib/__tests__/acting-account-resolver.test.ts +++ b/src/lib/__tests__/acting-account-resolver.test.ts @@ -130,6 +130,7 @@ import { auditLog } from "@/lib/auth/audit"; import { emitIfSampled } from "@/lib/logging/transports"; import { prisma } from "@/lib/db"; import type { WideEvent } from "@/lib/logging/types"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; const OWNER = "owner-1"; const DELEGATE = "delegate-1"; @@ -604,7 +605,7 @@ describe("refusals do not enumerate", () => { const missing = await route(() => requireRecordAuth("read", "measurements"), )(); - const missingBody = await missing.text(); + const missingBody = await bodyWithoutRequestId(missing); vi.clearAllMocks(); headerJar.clear(); @@ -616,7 +617,7 @@ describe("refusals do not enumerate", () => { const present = await route(() => requireRecordAuth("read", "measurements"), )(); - const presentBody = await present.text(); + const presentBody = await bodyWithoutRequestId(present); expect(missing.status).toBe(present.status); // Bytes, not statuses. A difference of one word here is an account @@ -631,7 +632,7 @@ describe("refusals do not enumerate", () => { const revoked = await route(() => requireRecordAuth("read", "measurements"), )(); - const revokedBody = await revoked.text(); + const revokedBody = await bodyWithoutRequestId(revoked); vi.clearAllMocks(); headerJar.clear(); @@ -642,7 +643,7 @@ describe("refusals do not enumerate", () => { requireRecordAuth("read", "measurements"), )(); - expect(revokedBody).toBe(await never.text()); + expect(revokedBody).toBe(await bodyWithoutRequestId(never)); }); it("keeps the reason off the wire and on the audit row", async () => { @@ -790,7 +791,7 @@ describe("a scoped grant reaches only the sections it names", () => { const outOfScope = await route(() => requireRecordAuth("read", "measurements"), )(); - const outOfScopeBody = await outOfScope.text(); + const outOfScopeBody = await bodyWithoutRequestId(outOfScope); grantRows.length = 0; liveGrant({ access: "READ" }); @@ -799,7 +800,7 @@ describe("a scoped grant reaches only the sections it names", () => { )(); expect(outOfScope.status).toBe(insufficient.status); - expect(outOfScopeBody).toBe(await insufficient.text()); + expect(outOfScopeBody).toBe(await bodyWithoutRequestId(insufficient)); expect(outOfScopeBody).not.toContain("scope"); expect(outOfScopeBody).not.toContain("section"); }); @@ -954,7 +955,7 @@ describe("the guardian resolver gates on the marker, not the grant", () => { userRows.set(OWNER, user(OWNER, "USER", null)); liveGrant({ access: "MANAGE" }); const fenced = await route(() => requireGuardianAuth())(); - const fencedBody = await fenced.text(); + const fencedBody = await bodyWithoutRequestId(fenced); // Compared against the oldest refusal in the feature: no grant at all. The // two are as far apart in meaning as this feature gets — "you were never @@ -966,7 +967,7 @@ describe("the guardian resolver gates on the marker, not the grant", () => { )(); expect(fenced.status).toBe(insufficient.status); - expect(fencedBody).toBe(await insufficient.text()); + expect(fencedBody).toBe(await bodyWithoutRequestId(insufficient)); expect(fencedBody).not.toContain("guardian"); expect(fencedBody).not.toContain("managed"); }); diff --git a/src/lib/__tests__/ai-refusal-envelope.test.ts b/src/lib/__tests__/ai-refusal-envelope.test.ts index a21e8f390..5b72cb8c1 100644 --- a/src/lib/__tests__/ai-refusal-envelope.test.ts +++ b/src/lib/__tests__/ai-refusal-envelope.test.ts @@ -137,6 +137,7 @@ describe("apiHandler renders it", () => { expect(body.data).toBeNull(); expect(typeof body.error).toBe("string"); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "consent.ai.required", capability: "coach", reason: "consent_required", diff --git a/src/lib/__tests__/api-handler-error-request-id.test.ts b/src/lib/__tests__/api-handler-error-request-id.test.ts new file mode 100644 index 000000000..a76c73493 --- /dev/null +++ b/src/lib/__tests__/api-handler-error-request-id.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/monitoring/glitchtip", () => ({ + sendGlitchtipEvent: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler, HttpError } from "@/lib/api-handler"; +import { apiError, apiSuccess, withErrorRequestId } from "@/lib/api-response"; + +const INCOMING = "3f9a2c1b-0000-4000-8000-000000000001"; + +function call( + handler: () => Promise, + headers: Record = {}, +) { + return apiHandler(handler)( + new NextRequest("http://localhost/api/probe", { headers }), + ); +} + +describe("meta.requestId on every error envelope", () => { + it("adds the request id to an apiError envelope, equal to the header", async () => { + const res = await call(async () => + apiError("Nope", 409, { errorCode: "probe.conflict" }), + ); + const body = await res.json(); + expect(res.status).toBe(409); + expect(body.meta.errorCode).toBe("probe.conflict"); + expect(body.meta.requestId).toBe(res.headers.get("x-request-id")); + expect(body.meta.requestId).toMatch(/^[A-Za-z0-9-]{8,64}$/); + }); + + it("adds it to an envelope without meta and to a hand-built one", async () => { + const plain = await call(async () => apiError("Not found", 404)); + expect((await plain.json()).meta).toEqual({ + requestId: plain.headers.get("x-request-id"), + }); + const hand = await call(async () => + NextResponse.json({ data: null, error: "Gone" }, { status: 410 }), + ); + expect((await hand.json()).meta.requestId).toBe( + hand.headers.get("x-request-id"), + ); + }); + + it("adds it to a thrown HttpError and to an unexpected throw (500)", async () => { + const thrown = await call(async () => { + throw new HttpError(403, "Forbidden"); + }); + expect((await thrown.json()).meta.requestId).toBe( + thrown.headers.get("x-request-id"), + ); + const crash = await call(async () => { + throw new Error("boom"); + }); + expect(crash.status).toBe(500); + expect((await crash.json()).meta.requestId).toBe( + crash.headers.get("x-request-id"), + ); + }); + + it("adopts a well-formed incoming x-request-id and replaces a malformed one", async () => { + const adopted = await call(async () => apiError("Nope", 400), { + "x-request-id": INCOMING, + }); + expect((await adopted.json()).meta.requestId).toBe(INCOMING); + expect(adopted.headers.get("x-request-id")).toBe(INCOMING); + + const forged = ''; + const replaced = await call(async () => apiError("Nope", 400), { + "x-request-id": forged, + }); + const id = (await replaced.json()).meta.requestId; + expect(id).not.toBe(forged); + expect(id).toMatch(/^[A-Za-z0-9-]{8,64}$/); + expect(replaced.headers.get("x-request-id")).toBe(id); + }); + + it("keeps status, headers and set-cookie on the rewritten response", async () => { + const res = await call(async () => { + const r = apiError("Slow down", 429, { + headers: { "Retry-After": "30" }, + }); + r.cookies.set("probe", "1"); + return r; + }); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("30"); + expect(res.headers.get("set-cookie")).toContain("probe=1"); + }); + + it("leaves a success, a non-envelope error and a non-JSON error untouched", async () => { + const ok = await call(async () => apiSuccess({ fine: true })); + expect(await ok.json()).toEqual({ data: { fine: true }, error: null }); + + const rpc = await call(async () => + NextResponse.json( + { jsonrpc: "2.0", error: { code: -32600, message: "bad" }, id: 1 }, + { status: 400 }, + ), + ); + expect(await rpc.json()).toEqual({ + jsonrpc: "2.0", + error: { code: -32600, message: "bad" }, + id: 1, + }); + + const text = await call( + async () => new Response("plain failure", { status: 502 }), + ); + expect(await text.text()).toBe("plain failure"); + }); + + it("never overwrites an id the handler set itself", async () => { + const res = await withErrorRequestId( + NextResponse.json( + { data: null, error: "x", meta: { requestId: "kept-by-handler" } }, + { status: 500 }, + ), + "ignored-0000", + ); + expect((await res.json()).meta.requestId).toBe("kept-by-handler"); + }); +}); diff --git a/src/lib/__tests__/api-handler-key-mismatch.test.ts b/src/lib/__tests__/api-handler-key-mismatch.test.ts index b4bbf5a63..906a33526 100644 --- a/src/lib/__tests__/api-handler-key-mismatch.test.ts +++ b/src/lib/__tests__/api-handler-key-mismatch.test.ts @@ -41,7 +41,10 @@ describe("apiHandler while the encryption key does not match", () => { expect(response.status).toBe(503); const body = await response.json(); expect(body.data).toBeNull(); - expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" }); + expect(body.meta).toEqual({ + requestId: expect.any(String), + errorCode: "encryption.key_mismatch", + }); expect(response.headers.get("Retry-After")).toBe("300"); expect(handler).not.toHaveBeenCalled(); }); diff --git a/src/lib/__tests__/auth-error-codes.test.ts b/src/lib/__tests__/auth-error-codes.test.ts index eb608b7e9..76cf3ee13 100644 --- a/src/lib/__tests__/auth-error-codes.test.ts +++ b/src/lib/__tests__/auth-error-codes.test.ts @@ -236,7 +236,11 @@ describe("auth failures carry a machine-readable errorCode", () => { )) as Response; expect(res.status).toBe(404); - expect(await res.json()).toEqual({ data: null, error: "Not found" }); + expect(await res.json()).toEqual({ + data: null, + error: "Not found", + meta: { requestId: expect.any(String) }, + }); }); it("keeps every code distinct and dot.case", () => { diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index c837aec0f..57a0f5c96 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -55,6 +55,7 @@ import { } from "./sharing/record-session-fence"; import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal"; import { acceptIncomingRequestId } from "./http/request-id"; +import { withErrorRequestId } from "./api-response"; import { capturedRateLimit, rateLimitResponseHeaders, @@ -363,7 +364,13 @@ export function apiHandler Promise>( /* logging must never crash the handler */ } } - const nr = response as NextResponse; + // Every error envelope names its request id in `meta.requestId`, so + // the id survives a proxy that strips the header (see + // `withErrorRequestId`). + const nr = (await withErrorRequestId( + response as Response, + evt.getRequestId(), + )) as NextResponse; nr.headers.set("x-request-id", evt.getRequestId()); if (isBypassRoute) applyBypassRouteHeaders(nr.headers); // v1.37.0 — echo the record context this response was actually served diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 0f22c09d6..82762766f 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -301,6 +301,69 @@ export function apiError(message: string, status = 400, meta?: ErrorMeta) { return buildJsonErrorResponse({ data: null, error: message }, status, meta); } +/** An error body larger than this is not an envelope worth re-reading. */ +const ERROR_ENVELOPE_MAX_BYTES = 64 * 1024; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Put the request id into `meta.requestId` of an error envelope. + * + * Every 4xx and 5xx a route answers with the `{ data: null, error, meta? }` + * envelope carries the full request id beside the `x-request-id` header, so a + * client behind a proxy that strips headers can still show the id a person + * reads out to the operator, who finds the event in the admin log with it. + * + * Applied once, by `apiHandler`, to whatever the handler answered, so a route + * that builds its error with `apiError`, with `NextResponse.json` by hand or by + * throwing cannot be the one that forgets it. Anything that is not a JSON + * envelope (a redirect, a stream, a FHIR `OperationOutcome`, a body that does + * not parse) is returned untouched, and an id the handler already set wins. + */ +export async function withErrorRequestId( + response: Response, + requestId: string, +): Promise { + if (response.status < 400) return response; + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) return response; + const declared = Number(response.headers.get("content-length") ?? "0"); + if (declared > ERROR_ENVELOPE_MAX_BYTES) return response; + + let body: unknown; + try { + const text = await response.clone().text(); + if (text.length === 0 || text.length > ERROR_ENVELOPE_MAX_BYTES) { + return response; + } + body = JSON.parse(text); + } catch { + return response; + } + // The envelope and nothing else: a JSON-RPC error (`/mcp`) or an RFC-shaped + // OAuth body has no `data: null` beside its `error`, and a member added to + // either would be one its own clients do not expect. + if ( + !isPlainObject(body) || + body.data !== null || + typeof body.error !== "string" + ) { + return response; + } + const meta = body.meta; + if (meta !== undefined && !isPlainObject(meta)) return response; + if (meta && typeof meta.requestId === "string") return response; + + const headers = new Headers(response.headers); + headers.delete("content-length"); + return new NextResponse( + JSON.stringify({ ...body, meta: { ...(meta ?? {}), requestId } }), + { status: response.status, statusText: response.statusText, headers }, + ); +} + /** * Read a request body as text, counting BYTES as they arrive and stopping at * `maxBytes`. A body that declares a larger `Content-Length` is refused before diff --git a/tests/integration/acting-account-resolver.test.ts b/tests/integration/acting-account-resolver.test.ts index 13ace25fb..0c61815a4 100644 --- a/tests/integration/acting-account-resolver.test.ts +++ b/tests/integration/acting-account-resolver.test.ts @@ -75,6 +75,7 @@ import { import { hashToken } from "@/lib/auth/hmac"; import { prisma } from "@/lib/db"; import { acceptGrant, inviteGrant, revokeGrant } from "@/lib/sharing/grants"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; let counter = 0; @@ -379,11 +380,11 @@ describe("acting-account resolver — refusals do not enumerate", () => { headerJar.set(ACCOUNT_SELECTOR_HEADER, "cmnosuchaccount000000000x"); const missing = await call(); - const missingBody = await missing.text(); + const missingBody = await bodyWithoutRequestId(missing); headerJar.set(ACCOUNT_SELECTOR_HEADER, stranger.id); const present = await call(); - const presentBody = await present.text(); + const presentBody = await bodyWithoutRequestId(present); expect(missing.status).toBe(403); expect(present.status).toBe(403); diff --git a/tests/integration/ai-extraction-capability.test.ts b/tests/integration/ai-extraction-capability.test.ts index a94cc4008..4a326b1cc 100644 --- a/tests/integration/ai-extraction-capability.test.ts +++ b/tests/integration/ai-extraction-capability.test.ts @@ -273,6 +273,7 @@ describe("reading a stored document — POST /api/documents/inbound/[id]/suggest expect(status).toBe(403); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "assistant.disabled.documentAi", capability: "documentAi", reason: "operator_disabled", @@ -303,6 +304,7 @@ describe("reading a stored document — POST /api/documents/inbound/[id]/suggest expect(status).toBe(403); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "consent.ai.required", capability: "documentAi", reason: "consent_required", @@ -336,6 +338,7 @@ describe("reading a stored document — POST /api/documents/inbound/[id]/suggest expect(status).toBe(422); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "documents.inbound.providerUnsupported", capability: "documentAi", reason: "no_provider", @@ -390,6 +393,7 @@ describe("scanning a lab report — POST /api/labs/ocr/extract", () => { expect(status).toBe(403); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "module.disabled", capability: "labsOcr", reason: "module_disabled", @@ -438,6 +442,7 @@ describe("turning medication text into a schedule — POST /api/medications/extr expect(status).toBe(403); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "assistant.disabled.documentAi", capability: "medicationExtract", reason: "operator_disabled", @@ -457,6 +462,7 @@ describe("turning medication text into a schedule — POST /api/medications/extr expect(status).toBe(503); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "ai.provider.none", capability: "medicationExtract", reason: "no_provider", diff --git a/tests/integration/encryption-key-check.test.ts b/tests/integration/encryption-key-check.test.ts index 965509974..51daa67cb 100644 --- a/tests/integration/encryption-key-check.test.ts +++ b/tests/integration/encryption-key-check.test.ts @@ -157,7 +157,10 @@ describe("boot encryption key check (real Postgres)", () => { ); expect(refused.status).toBe(503); const body = await refused.json(); - expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" }); + expect(body.meta).toEqual({ + requestId: expect.any(String), + errorCode: "encryption.key_mismatch", + }); const { GET: healthGet } = await import("@/app/api/health/route"); const health = await asRoute(healthGet)( @@ -625,6 +628,7 @@ describe("routes outside apiHandler refuse while the key does not match", () => ); expect(res.status).toBe(503); expect((await res.json()).meta).toEqual({ + requestId: expect.any(String), errorCode: "encryption.key_mismatch", }); }); diff --git a/tests/integration/login-insecure-transport.test.ts b/tests/integration/login-insecure-transport.test.ts index 58f495d0c..c23df31ba 100644 --- a/tests/integration/login-insecure-transport.test.ts +++ b/tests/integration/login-insecure-transport.test.ts @@ -139,6 +139,7 @@ describe("sign-in over plain http with Secure-only cookies (real Postgres)", () expect(res.status).toBe(409); const body = await res.json(); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "auth.session.insecure_transport", sessionCookieSecure: true, }); diff --git a/tests/integration/sharing-lifecycle.test.ts b/tests/integration/sharing-lifecycle.test.ts index a948ddc80..1aa9574a0 100644 --- a/tests/integration/sharing-lifecycle.test.ts +++ b/tests/integration/sharing-lifecycle.test.ts @@ -51,6 +51,7 @@ vi.mock("@/lib/db-compat", () => ({ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { hashToken } from "@/lib/auth/hmac"; import { prisma } from "@/lib/db"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; let counter = 0; @@ -771,7 +772,9 @@ describe("the switch endpoint", () => { const ungranting = await switchTo(stranger.id); expect(unknown.status).toBe(ungranting.status); - expect(await unknown.text()).toBe(await ungranting.text()); + expect(await bodyWithoutRequestId(unknown)).toBe( + await bodyWithoutRequestId(ungranting), + ); }); it("refuses over Bearer, where the carrier is the header", async () => { diff --git a/tests/integration/sharing-scope-fence.test.ts b/tests/integration/sharing-scope-fence.test.ts index 67b3beafe..f8d88f3e8 100644 --- a/tests/integration/sharing-scope-fence.test.ts +++ b/tests/integration/sharing-scope-fence.test.ts @@ -34,6 +34,7 @@ import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; import { SHARE_DOMAINS } from "@/lib/sharing/scope"; import type { ShareDomain } from "@/lib/sharing/scope"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -417,7 +418,7 @@ describe("the refusal does not distinguish itself", () => { const delegate = await makeUser("delegate"); await switchInto(owner.id, delegate.id, ["medications"]); const outOfScope = await PROBES.labs.call(); - const outOfScopeBody = await outOfScope.text(); + const outOfScopeBody = await bodyWithoutRequestId(outOfScope); // A stranger naming the same record. Different story, same answer. const stranger = await makeUser("stranger"); @@ -427,7 +428,7 @@ describe("the refusal does not distinguish itself", () => { const noGrant = await PROBES.labs.call(); expect(outOfScope.status).toBe(noGrant.status); - expect(outOfScopeBody).toBe(await noGrant.text()); + expect(outOfScopeBody).toBe(await bodyWithoutRequestId(noGrant)); expect(outOfScopeBody).not.toContain("scope"); // …and the operator's trail tells them apart, which is the half that has From 1af0de5e877b27ba6003fc3925f76bae0785debb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:06:39 +0200 Subject: [PATCH 011/127] feat(logging): keep warnings and errors in their own ring and find them by error id --- .../admin/app-logs/__tests__/route.test.ts | 34 +++++++++++ src/app/api/admin/app-logs/route.ts | 28 ++++++++- .../admin/app-log-preview-section.tsx | 36 +++++++++++- .../__tests__/in-memory-buffer.test.ts | 37 ++++++++++++ src/lib/logging/in-memory-buffer.ts | 57 ++++++++++++++++--- src/lib/logging/index.ts | 2 + src/lib/query-keys/admin.ts | 5 +- 7 files changed, 186 insertions(+), 13 deletions(-) diff --git a/src/app/api/admin/app-logs/__tests__/route.test.ts b/src/app/api/admin/app-logs/__tests__/route.test.ts index adfff8b0f..b20edbc84 100644 --- a/src/app/api/admin/app-logs/__tests__/route.test.ts +++ b/src/app/api/admin/app-logs/__tests__/route.test.ts @@ -136,4 +136,38 @@ describe("GET /api/admin/app-logs", () => { expect(json.error).toBe("Validation failed"); expect(json.details.issues.length).toBeGreaterThan(0); }); + + it("finds an error by the first 8 characters of its id in the problems ring", async () => { + appendLogEvent( + makeEvent({ level: "error", request_id: "3f9a2c1b-1111-4000-8000-0000" }), + ); + for (let i = 0; i < 600; i++) { + appendLogEvent(makeEvent({ level: "info" })); + } + const gone = await GET(req("requestId=3f9a2c1b")); + const goneJson = (await gone.json()) as { data: { events: WideEvent[] } }; + expect(goneJson.data.events).toHaveLength(0); + + const res = await GET(req("requestId=3f9a2c1b&ring=problems")); + const json = (await res.json()) as { + data: { + events: WideEvent[]; + meta: { problemsBufferMax: number; ring: string }; + }; + }; + expect(json.data.events.map((e) => e.request_id)).toEqual([ + "3f9a2c1b-1111-4000-8000-0000", + ]); + expect(json.data.meta.problemsBufferMax).toBe(1000); + expect(json.data.meta.ring).toBe("problems"); + }); + + it("refuses a request id shorter than 8 characters or outside the id alphabet", async () => { + for (const bad of ["3f9a2c1", "3f9a2c1b%20OR%201", "a".repeat(65)]) { + const res = await GET(req(`requestId=${bad}`)); + expect(res.status).toBe(422); + } + const ring = await GET(req("ring=everything")); + expect(ring.status).toBe(422); + }); }); diff --git a/src/app/api/admin/app-logs/route.ts b/src/app/api/admin/app-logs/route.ts index c5e3e3478..2482a1600 100644 --- a/src/app/api/admin/app-logs/route.ts +++ b/src/app/api/admin/app-logs/route.ts @@ -1,7 +1,11 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer"; +import { + readLogBuffer, + LOG_BUFFER_MAX, + PROBLEMS_BUFFER_MAX, +} from "@/lib/logging/in-memory-buffer"; import { redactSecrets } from "@/lib/logging/redact"; import type { WideEvent } from "@/lib/logging/types"; import { NextRequest } from "next/server"; @@ -10,17 +14,31 @@ import { z } from "zod/v4"; export const dynamic = "force-dynamic"; const querySchema = z.object({ + // The error id a person reads out is the first 8 characters of the request + // id; the full id (up to 64) matches as well. Same alphabet as an accepted + // incoming `x-request-id`. + requestId: z + .string() + .trim() + .regex(/^[A-Za-z0-9-]{8,64}$/) + .optional(), + ring: z.enum(["all", "problems"]).default("all"), traceId: z.string().trim().min(1).max(200).optional(), level: z.enum(["debug", "info", "warn", "error"]).optional(), action: z.string().trim().min(1).max(200).optional(), since: z.string().datetime().optional(), until: z.string().datetime().optional(), - limit: z.coerce.number().int().min(1).max(500).default(200), + limit: z.coerce.number().int().min(1).max(PROBLEMS_BUFFER_MAX).default(200), }); /** * Admin endpoint: tail of the per-process in-memory wide-event ring buffer. * + * `ring=problems` reads the second ring, which keeps the last 1,000 `warn` and + * `error` events only, so a failure a person reported an hour ago is still + * there after the main ring has turned over. `requestId` finds the event + * behind an error id by prefix. + * * The buffer is populated by `transports.emitEvent()` so the result mirrors * what would be shipped to Loki (sampler-gated). Per-process and volatile — * see `src/lib/logging/in-memory-buffer.ts` for the limitations. @@ -36,6 +54,8 @@ export const GET = apiHandler(async (request: NextRequest) => { const { searchParams } = new URL(request.url); const result = querySchema.safeParse({ + requestId: searchParams.get("requestId") ?? undefined, + ring: searchParams.get("ring") ?? undefined, traceId: searchParams.get("traceId") ?? undefined, level: searchParams.get("level") ?? undefined, action: searchParams.get("action") ?? undefined, @@ -47,6 +67,8 @@ export const GET = apiHandler(async (request: NextRequest) => { const parsed = result.data; const events = readLogBuffer({ + ring: parsed.ring, + requestId: parsed.requestId, traceId: parsed.traceId, level: parsed.level, action: parsed.action, @@ -62,6 +84,8 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { total: redacted.length, bufferMax: LOG_BUFFER_MAX, + problemsBufferMax: PROBLEMS_BUFFER_MAX, + ring: parsed.ring, }, }); }); diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index b043500c3..92453a839 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -67,6 +67,7 @@ interface AppLogsResponse { meta: { total: number; bufferMax: number; + problemsBufferMax?: number; }; } @@ -94,6 +95,7 @@ function levelIcon(level: LogLevel) { export function AppLogPreviewSection() { const { t } = useTranslations(); + const [requestId, setRequestId] = useState(""); const [traceId, setTraceId] = useState(""); const [actionFilter, setActionFilter] = useState(""); const [level, setLevel] = useState("__all__"); @@ -103,16 +105,32 @@ export function AppLogPreviewSection() { const params = useMemo(() => { const p = new URLSearchParams(); + // An error id is the first characters of a request id. The ids people + // read out come from 5xx answers, which are logged at `error`, so a + // search by id, like a filter on warn or error, reads the problems ring: + // it keeps the last 1,000 warnings and errors, where the main ring keeps + // 500 events of every kind and turns over in minutes on a busy host. + const id = requestId.trim(); + if (id.length >= 8) p.set("requestId", id); + if (id.length >= 8 || level === "warn" || level === "error") { + p.set("ring", "problems"); + } if (traceId.trim()) p.set("traceId", traceId.trim()); if (actionFilter.trim()) p.set("action", actionFilter.trim()); if (level !== "__all__") p.set("level", level); const since = rangeToSince(range); if (since) p.set("since", since); return p; - }, [traceId, actionFilter, level, range]); + }, [requestId, traceId, actionFilter, level, range]); const { data, isLoading, refetch, isFetching } = useQuery({ - queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range), + queryKey: queryKeys.adminAppLogs( + traceId, + actionFilter, + level, + range, + requestId.trim(), + ), queryFn: async () => { return apiGet( `/api/admin/app-logs?${params.toString()}`, @@ -144,7 +162,19 @@ export function AppLogPreviewSection() { {/* Filters and the refresh share one toolbar row above the table; the header status slot carries status, not actions (design standards §12). */} -
+
+ { + setRequestId(e.target.value); + setPage(1); + }} + aria-label={t("admin.section.app-logs.filterErrorId")} + data-slot="app-logs-error-id-filter" + /> { clearLogBuffer(); expect(readLogBuffer({})).toHaveLength(0); }); + + it("keeps warn and error in the problems ring while info traffic turns the main ring over", () => { + expect(PROBLEMS_BUFFER_MAX).toBe(1000); + appendLogEvent( + makeEvent({ level: "error", request_id: "3f9a2c1b-dead-beef" }), + ); + appendLogEvent(makeEvent({ level: "warn", request_id: "warn-0001" })); + for (let i = 0; i < LOG_BUFFER_MAX + 50; i++) { + appendLogEvent(makeEvent({ level: "info" })); + } + // The main ring lost both; the problems ring kept them. + expect(readLogBuffer({ requestId: "3f9a2c1b" })).toHaveLength(0); + const found = readLogBuffer({ ring: "problems", requestId: "3f9a2c1b" }); + expect(found.map((e) => e.request_id)).toEqual(["3f9a2c1b-dead-beef"]); + expect(getLogBufferSize("problems")).toBe(2); + }); + + it("FIFO-evicts the problems ring at its own cap and never admits info or debug", () => { + appendLogEvent(makeEvent({ level: "debug" })); + for (let i = 0; i < PROBLEMS_BUFFER_MAX + 5; i++) { + appendLogEvent(makeEvent({ level: "error", request_id: `e-${i}` })); + } + const out = readLogBuffer({ ring: "problems" }); + expect(out).toHaveLength(PROBLEMS_BUFFER_MAX); + expect(out[0].request_id).toBe(`e-${PROBLEMS_BUFFER_MAX + 4}`); + expect(out.every((e) => e.level === "error")).toBe(true); + }); + + it("matches a request id by case-insensitive prefix", () => { + appendLogEvent(makeEvent({ request_id: "ABCDEF12-0000" })); + appendLogEvent(makeEvent({ request_id: "abcdef99-0000" })); + expect(readLogBuffer({ requestId: "abcdef12" })).toHaveLength(1); + expect(readLogBuffer({ requestId: "abcdef" })).toHaveLength(2); + expect(readLogBuffer({ requestId: "ABCDEF12-0000" })).toHaveLength(1); + }); }); diff --git a/src/lib/logging/in-memory-buffer.ts b/src/lib/logging/in-memory-buffer.ts index 74f052aa8..5e5120b27 100644 --- a/src/lib/logging/in-memory-buffer.ts +++ b/src/lib/logging/in-memory-buffer.ts @@ -12,6 +12,11 @@ * web buffer doesn't see worker events and vice-versa. The UI surfaces * this caveat in the section header. * - 500-entry cap (~500KB max @ ~1KB/event). FIFO eviction on overflow. + * - A second ring, `problems`, keeps the last 1,000 `warn` and `error` + * events only. On a busy host the 500 entries of the main ring turn over + * in minutes of ordinary traffic, so the one failure a person reports + * was gone by the time the operator looked for it. Info traffic never + * enters the problems ring and cannot evict from it. * - Volatile — restart drops the buffer. For durable storage configure * `LOKI_ENDPOINT` and tail Loki instead. * @@ -23,20 +28,49 @@ import type { WideEvent, LogLevel } from "./types"; export const LOG_BUFFER_MAX = 500; -// Plain array with FIFO eviction. A linked list would be technically tighter -// but for 500 entries the array shift overhead (~microseconds) is invisible +/** Capacity of the warn/error-only ring. */ +export const PROBLEMS_BUFFER_MAX = 1000; + +/** Which ring a read walks: every event, or only `warn` and `error`. */ +export type LogRing = "all" | "problems"; + +// Plain arrays with FIFO eviction. A linked list would be technically tighter +// but for 1,000 entries the array shift overhead (~microseconds) is invisible // next to the rest of the request lifecycle. let buffer: WideEvent[] = []; +let problems: WideEvent[] = []; -/** Append an event to the buffer; evict the oldest entry once cap is hit. */ +/** Whether an event belongs in the problems ring. */ +function isProblem(event: WideEvent): boolean { + return event.level === "warn" || event.level === "error"; +} + +/** + * Append an event to the main ring and, for `warn` and `error`, to the + * problems ring; each evicts its oldest entry once its cap is hit. The same + * object sits in both, so an event costs its memory once. + */ export function appendLogEvent(event: WideEvent): void { buffer.push(event); if (buffer.length > LOG_BUFFER_MAX) { buffer.shift(); } + if (isProblem(event)) { + problems.push(event); + if (problems.length > PROBLEMS_BUFFER_MAX) { + problems.shift(); + } + } } export interface ReadLogBufferOptions { + /** The ring to read; `all` when absent. */ + ring?: LogRing; + /** + * Prefix match on `event.request_id`, case-insensitive. A person reads out + * the first 8 characters of an error id; the full id matches too. + */ + requestId?: string; /** Exact match on `event.trace_id`. */ traceId?: string; /** Exact match on `event.level`. */ @@ -60,11 +94,19 @@ export function readLogBuffer(opts: ReadLogBufferOptions): WideEvent[] { const sinceMs = opts.since?.getTime(); const untilMs = opts.until?.getTime(); const actionLower = opts.action?.toLowerCase(); + const requestIdLower = opts.requestId?.toLowerCase(); + const source = opts.ring === "problems" ? problems : buffer; // Walk back-to-front so the result is newest-first without an extra reverse. const out: WideEvent[] = []; - for (let i = buffer.length - 1; i >= 0; i--) { - const event = buffer[i]; + for (let i = source.length - 1; i >= 0; i--) { + const event = source[i]; + if ( + requestIdLower && + !(event.request_id ?? "").toLowerCase().startsWith(requestIdLower) + ) { + continue; + } if (opts.traceId && event.trace_id !== opts.traceId) continue; if (opts.level && event.level !== opts.level) continue; if (actionLower) { @@ -85,9 +127,10 @@ export function readLogBuffer(opts: ReadLogBufferOptions): WideEvent[] { /** Reset the buffer (test-only — not exported from the package index). */ export function clearLogBuffer(): void { buffer = []; + problems = []; } /** Current buffer size (test-only). */ -export function getLogBufferSize(): number { - return buffer.length; +export function getLogBufferSize(ring: LogRing = "all"): number { + return ring === "problems" ? problems.length : buffer.length; } diff --git a/src/lib/logging/index.ts b/src/lib/logging/index.ts index 88b6df6ba..f7ebb66ba 100644 --- a/src/lib/logging/index.ts +++ b/src/lib/logging/index.ts @@ -17,5 +17,7 @@ export { clearLogBuffer, getLogBufferSize, LOG_BUFFER_MAX, + PROBLEMS_BUFFER_MAX, + type LogRing, type ReadLogBufferOptions, } from "./in-memory-buffer"; diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 59b402bb4..f3eeda9b7 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -32,7 +32,10 @@ export const adminKeys = { action: string | undefined, level: string | undefined, range: string | undefined, - ) => ["admin", "app-logs", traceId, action, level, range] as const, + requestId?: string, + ) => ["admin", "app-logs", traceId, action, level, range, requestId] as const, + /** v1.43 — the redacted support report on the admin status page. */ + adminSupportReport: () => ["admin", "support-report"] as const, adminAssistantFlags: () => ["admin", "settings", "assistant-flags"] as const, /** v1.18.0 — operator-level server-wide module availability matrix. */ adminModuleAvailability: () => From e16c9fb71793bb811059b6666dbf3e6811c6462e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:11:49 +0200 Subject: [PATCH 012/127] feat(links): search a year or a month in the link pickers by its date window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A picker term that names a period ("2019", "03.2019", "2019-03", "März 2019", "2019년 3월") is sent as the list route's from/to window instead of q, newest first, on every family including the visit list, which has no text search. Month names come from Intl for the UI locale, folded for case, accents and umlaut spellings. Instant routes get the period's bounds in the person's timezone; the document route gets the period's calendar days. The sheet names the period it shows. --- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/pl.json | 5 +- src/components/links/entity-link-picker.tsx | 24 +- src/components/links/period-query.ts | 207 ++++++++++++++++++ src/components/links/picker-requests.ts | 80 ++++++- src/components/links/use-link-picker-pages.ts | 73 +++++- 11 files changed, 394 insertions(+), 25 deletions(-) create mode 100644 src/components/links/period-query.ts diff --git a/messages/de.json b/messages/de.json index 5e17d1bf6..2abf8465c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12227,7 +12227,10 @@ "removed": "Verknüpfung mit {name} gelöst", "linked": "Verknüpft", "loadNewer": "Neuere laden", - "loadOlder": "Ältere laden" + "loadOlder": "Ältere laden", + "period": { + "showing": "Zeitraum: {period}" + } } }, "bodySites": { diff --git a/messages/en.json b/messages/en.json index b08ee23b9..46880af6d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12227,7 +12227,10 @@ "removed": "Unlinked {name}", "linked": "Linked", "loadNewer": "Load newer", - "loadOlder": "Load older" + "loadOlder": "Load older", + "period": { + "showing": "Showing {period}" + } } }, "bodySites": { diff --git a/messages/es.json b/messages/es.json index 9bf9001d7..112c33f1d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12227,7 +12227,10 @@ "removed": "Se desvinculó {name}", "linked": "Vinculados", "loadNewer": "Cargar más recientes", - "loadOlder": "Cargar anteriores" + "loadOlder": "Cargar anteriores", + "period": { + "showing": "Mostrando {period}" + } } }, "bodySites": { diff --git a/messages/fr.json b/messages/fr.json index 878a0384b..6177b0427 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12227,7 +12227,10 @@ "removed": "{name} dissocié", "linked": "Liés", "loadNewer": "Charger les plus récents", - "loadOlder": "Charger les plus anciens" + "loadOlder": "Charger les plus anciens", + "period": { + "showing": "Période : {period}" + } } }, "bodySites": { diff --git a/messages/it.json b/messages/it.json index 2c3c7df29..3749f474f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12227,7 +12227,10 @@ "removed": "Collegamento a {name} rimosso", "linked": "Collegati", "loadNewer": "Carica più recenti", - "loadOlder": "Carica meno recenti" + "loadOlder": "Carica meno recenti", + "period": { + "showing": "Periodo: {period}" + } } }, "bodySites": { diff --git a/messages/ko.json b/messages/ko.json index 1d65cae91..4d17c9756 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12227,7 +12227,10 @@ "removed": "{name} 연결 해제됨", "linked": "연결됨", "loadNewer": "최근 항목 더 보기", - "loadOlder": "이전 항목 더 보기" + "loadOlder": "이전 항목 더 보기", + "period": { + "showing": "{period} 표시 중" + } } }, "bodySites": { diff --git a/messages/pl.json b/messages/pl.json index 6093866f9..87fa0206b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12227,7 +12227,10 @@ "removed": "Odłączono: {name}", "linked": "Połączone", "loadNewer": "Wczytaj nowsze", - "loadOlder": "Wczytaj starsze" + "loadOlder": "Wczytaj starsze", + "period": { + "showing": "Okres: {period}" + } } }, "bodySites": { diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx index 919dfd9e7..00d26c098 100644 --- a/src/components/links/entity-link-picker.tsx +++ b/src/components/links/entity-link-picker.tsx @@ -53,6 +53,8 @@ import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; +import { formatPeriod, type PeriodQuery } from "./period-query"; + export interface EntityLinkOption { id: string; label: string; @@ -110,6 +112,8 @@ export interface EntityLinkServerPaging { * here instead of being sent. */ filterLocally?: boolean; + /** The term names a year or a month, and the list is that period. */ + period?: PeriodQuery | null; } /** @@ -127,6 +131,7 @@ export function serverPagingFrom(pages: { loadOlder: () => void; loadNewer: () => void; filterLocally: boolean; + period: PeriodQuery | null; }): EntityLinkServerPaging { return { term: pages.term, @@ -139,6 +144,7 @@ export function serverPagingFrom(pages: { onOlder: pages.loadOlder, onNewer: pages.loadNewer, filterLocally: pages.filterLocally, + period: pages.period, }; } @@ -278,7 +284,7 @@ export function EntityLinkPicker({ */ knownOptions?: EntityLinkOption[]; }) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const [open, setOpen] = useState(false); const [localTerm, setLocalTerm] = useState(""); const term = server ? server.term : localTerm; @@ -308,7 +314,10 @@ export function EntityLinkPicker({ // again here would drop a match on a field the option does not render. const groups = useMemo(() => { const visible = - server && !server.filterLocally ? options : filterOptions(options, term); + // A period is answered by the route's date window, never by the digits. + server && (!server.filterLocally || server.period) + ? options + : filterOptions(options, term); // Linked records the loaded pages do not hold stay reachable in the // sheet, on top, so they can be unticked where they are listed. const loaded = new Set(options.map((option) => option.id)); @@ -486,6 +495,17 @@ export function EntityLinkPicker({ />
+ {server?.period ? ( +

+ {t("links.picker.period.showing", { + period: formatPeriod(server.period, locale), + })} +

+ ) : null} + {server?.searchPending && groups.length === 0 ? (
= { + es: ["de", "del"], + it: ["del"], +}; + +const nameCache = new Map>(); + +/** Spell German umlauts the way a keyboard without them does. */ +function umlautSpelling(lower: string): string { + return lower.replace(/ä/gu, "ae").replace(/ö/gu, "oe").replace(/ü/gu, "ue"); +} + +/** + * Every folded spelling of every month in `locale`, paired with its month + * number: long, short and (where it differs) genitive, each also with its + * umlauts spelled out. + */ +function monthNames(locale: string): ReadonlyArray<[string, number]> { + const cached = nameCache.get(locale); + if (cached) return cached; + const names: Array<[string, number]> = []; + const add = (raw: string, month: number) => { + const lower = raw.toLowerCase().replace(/\./gu, ""); + for (const spelling of [lower, umlautSpelling(lower)]) { + const folded = foldForMatch(spelling); + if (folded && !names.some(([n, m]) => n === folded && m === month)) { + names.push([folded, month]); + } + } + }; + let long: Intl.DateTimeFormat; + let short: Intl.DateTimeFormat; + let genitive: Intl.DateTimeFormat; + try { + long = new Intl.DateTimeFormat(locale, { month: "long", timeZone: "UTC" }); + short = new Intl.DateTimeFormat(locale, { + month: "short", + timeZone: "UTC", + }); + genitive = new Intl.DateTimeFormat(locale, { + month: "long", + day: "numeric", + timeZone: "UTC", + }); + } catch { + nameCache.set(locale, names); + return names; + } + for (let month = 1; month <= 12; month += 1) { + const day = new Date(Date.UTC(2019, month - 1, 15)); + add(long.format(day), month); + add(short.format(day), month); + const part = genitive + .formatToParts(day) + .find((p) => p.type === "month")?.value; + if (part) add(part, month); + } + nameCache.set(locale, names); + return names; +} + +/** The month a folded word names in `locale`, or null. */ +function monthFromName(word: string, locale: string): number | null { + const names = monthNames(locale); + const exact = names.find(([name]) => name === word); + if (exact) return exact[1]; + if (word.length < 3 || !/^\p{L}+$/u.test(word)) return null; + const months = new Set( + names.filter(([name]) => name.startsWith(word)).map(([, month]) => month), + ); + return months.size === 1 ? [...months][0] : null; +} + +function plausibleYear(word: string, now: Date): number | null { + if (!/^\d{4}$/u.test(word)) return null; + const year = Number(word); + if (year < MIN_PERIOD_YEAR || year > now.getUTCFullYear() + 1) return null; + return year; +} + +function numericMonth(word: string): number | null { + if (!/^\d{1,2}$/u.test(word)) return null; + const month = Number(word); + return month >= 1 && month <= 12 ? month : null; +} + +function pad(value: number): string { + return String(value).padStart(2, "0"); +} + +function period(year: number, month: number | null): PeriodQuery { + if (month === null) { + return { year, month, fromDay: `${year}-01-01`, toDay: `${year}-12-31` }; + } + const fromDay = `${year}-${pad(month)}-01`; + const nextMonth = + month === 12 ? `${year + 1}-01-01` : `${year}-${pad(month + 1)}-01`; + return { year, month, fromDay, toDay: shiftDateKey(nextMonth, -1) }; +} + +/** The base language of a locale tag: "de-AT" → "de". */ +function language(locale: string): string { + return locale.toLowerCase().split(/[-_]/u)[0] ?? ""; +} + +/** + * The period `term` names in `locale`, or null when it is not one (and the + * picker keeps its text search). + */ +export function parsePeriodQuery( + term: string, + locale: string, + now: Date = new Date(), +): PeriodQuery | null { + const lang = language(locale); + let text = term.toLowerCase(); + // Korean writes the units after the numbers: "2019년 3월". + if (lang === "ko") { + text = text.replace(/(\d)\s*년/gu, "$1 ").replace(/(\d)\s*월/gu, "$1 "); + } + const connectors = CONNECTORS[lang] ?? []; + const words = foldForMatch(text.replace(/[.,]/gu, " ")) + .split(" ") + .filter((word) => word.length > 0 && !connectors.includes(word)); + + if (words.length === 1) { + const year = plausibleYear(words[0], now); + return year === null ? null : period(year, null); + } + if (words.length !== 2) return null; + + for (const [first, second] of [ + [words[0], words[1]], + [words[1], words[0]], + ]) { + const year = plausibleYear(second, now); + if (year === null) continue; + const month = numericMonth(first) ?? monthFromName(first, locale); + if (month !== null) return period(year, month); + } + return null; +} + +/** The period as the app names it in `locale`: "2019", "März 2019". */ +export function formatPeriod(value: PeriodQuery, locale: string): string { + if (value.month === null) return String(value.year); + try { + return new Intl.DateTimeFormat(locale, { + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(new Date(Date.UTC(value.year, value.month - 1, 15))); + } catch { + return `${pad(value.month)}.${value.year}`; + } +} diff --git a/src/components/links/picker-requests.ts b/src/components/links/picker-requests.ts index 2d4be8381..0e93009a7 100644 --- a/src/components/links/picker-requests.ts +++ b/src/components/links/picker-requests.ts @@ -11,7 +11,28 @@ * `search` the term across the whole history. The cursor is the family's own: * an offset for the routes that page by offset, the last row's id for the * document route's keyset. + * + * A search term that names a year or a month ("2019", "03.2019", "März 2019", + * see {@link parsePeriodQuery}) is not sent as `q`: it becomes the route's + * own `from` / `to` window over that period, newest first, so the answer is + * every record dated in it rather than the records whose name contains the + * digits. The visit route, which has no text search at all, gets the same + * window. + * + * The period is a calendar period as the person reads it. The routes over an + * instant (lab draw, condition onset, visit) get the first instant of the + * period's first day and the last instant of its last day in the person's + * timezone, the zone every date in the picker is rendered in, so a visit at + * 00:30 on 1 March is in March for the person who sees it dated 1 March. A + * date-only lab reading is stored at noon UTC, which reads as its own date in + * every zone from UTC-11 to UTC+11. The document route's window is already + * day-granular and inclusive over a stored calendar date, so it gets the two + * day keys as they are. */ +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { shiftDateKey } from "@/lib/tz/format"; + +import { parsePeriodQuery, type PeriodQuery } from "./period-query"; import { PICKER_PAGE_SIZE, afterPivot, @@ -22,6 +43,44 @@ function withParams(path: string, params: URLSearchParams): string { return `${path}?${params.toString()}`; } +/** The period a search request names, or null outside a period search. */ +export function requestPeriod(request: PickerPageRequest): PeriodQuery | null { + if (request.direction !== "search") return null; + return parsePeriodQuery(request.term, request.locale); +} + +/** + * The instant window of a period in `timeZone`: from the first instant of its + * first day to the last millisecond before the day after its last, both + * inclusive as the instant routes read them. + */ +export function periodInstants( + period: PeriodQuery, + timeZone: string, +): { from: string; to: string } { + const from = startOfLocalDayKey(period.fromDay, timeZone); + const next = startOfLocalDayKey(shiftDateKey(period.toDay, 1), timeZone); + return { + from: from.toISOString(), + to: new Date(next.getTime() - 1).toISOString(), + }; +} + +/** `q`, or the period's instant window, for a search on an instant route. */ +function setInstantSearch( + params: URLSearchParams, + request: PickerPageRequest, +): void { + const period = requestPeriod(request); + if (period) { + const window = periodInstants(period, request.timeZone); + params.set("from", window.from); + params.set("to", window.to); + } else { + params.set("q", request.term); + } +} + /** `GET /api/labs` — offset paging, `from` / `to` on the draw instant. */ export function labPickerUrl(request: PickerPageRequest): string { const params = new URLSearchParams({ @@ -29,7 +88,7 @@ export function labPickerUrl(request: PickerPageRequest): string { offset: request.cursor ?? "0", sortDir: request.direction === "newer" ? "asc" : "desc", }); - if (request.direction === "search") params.set("q", request.term); + if (request.direction === "search") setInstantSearch(params, request); else if (request.pivot !== null) { if (request.direction === "older") params.set("to", request.pivot); else params.set("from", afterPivot(request.pivot)); @@ -45,7 +104,7 @@ export function episodePickerUrl(request: PickerPageRequest): string { offset: request.cursor ?? "0", sortDir: request.direction === "newer" ? "asc" : "desc", }); - if (request.direction === "search") params.set("q", request.term); + if (request.direction === "search") setInstantSearch(params, request); else if (request.pivot !== null) { if (request.direction === "older") params.set("to", request.pivot); else params.set("from", afterPivot(request.pivot)); @@ -65,7 +124,11 @@ export function documentPickerUrl(request: PickerPageRequest): string { limit: String(PICKER_PAGE_SIZE), }); if (request.cursor) params.set("cursor", request.cursor); - if (request.direction === "search") params.set("q", request.term); + const period = requestPeriod(request); + if (period) { + params.set("from", period.fromDay); + params.set("to", period.toDay); + } else if (request.direction === "search") params.set("q", request.term); else if (request.pivot !== null) { if (request.direction === "older") { params.set("to", request.pivot.slice(0, 10)); @@ -80,15 +143,20 @@ export function documentPickerUrl(request: PickerPageRequest): string { * `GET /api/encounters` — offset paging inside a window on the visit instant. * The route answers in two arms (upcoming soonest first, past newest first), * each paged by the same offset; the picker merges and orders them itself. - * The visit route has no text search, so `search` reads the whole history - * and the picker filters what it has loaded. + * The visit route has no text search, so a plain term never reaches it (the + * picker filters what it has loaded); a period search reads that period. */ export function encounterPickerUrl(request: PickerPageRequest): string { const params = new URLSearchParams({ limit: String(PICKER_PAGE_SIZE), offset: request.cursor ?? "0", }); - if (request.direction !== "search" && request.pivot !== null) { + const period = requestPeriod(request); + if (period) { + const window = periodInstants(period, request.timeZone); + params.set("from", window.from); + params.set("to", window.to); + } else if (request.direction !== "search" && request.pivot !== null) { if (request.direction === "older") params.set("to", request.pivot); else params.set("from", afterPivot(request.pivot)); } diff --git a/src/components/links/use-link-picker-pages.ts b/src/components/links/use-link-picker-pages.ts index 4a0b13f7e..cb5807b57 100644 --- a/src/components/links/use-link-picker-pages.ts +++ b/src/components/links/use-link-picker-pages.ts @@ -22,8 +22,11 @@ * paged further forward from there; * * and a typed term switches to `search`, which asks the list route itself - * (`q`) so a match is found wherever it sits in the history. With no date - * yet the picker opens on the newest records and pages back. + * (`q`) so a match is found wherever it sits in the history. A term that names + * a year or a month ("2019", "03.2019", "März 2019") searches that period + * instead, through the route's date window, on every family including the + * visit list that has no text search (`parsePeriodQuery`). With no date yet + * the picker opens on the newest records and pages back. * * Every page comes from the family's own list route through `fetchPage`, so * paging, the filter and the ownership check stay server-side. @@ -33,6 +36,9 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; + +import { parsePeriodQuery, type PeriodQuery } from "./period-query"; export type PickerDirection = "older" | "newer" | "search"; @@ -50,6 +56,10 @@ export interface PickerPageRequest { term: string; /** The family's own cursor (an offset or a row id), null for page one. */ cursor: string | null; + /** The UI locale, which names the months a period search understands. */ + locale: string; + /** The person's timezone, where a period's days begin and end. */ + timeZone: string; } /** Records per page, the same for every family. */ @@ -106,17 +116,41 @@ export function useLinkPickerPages({ */ serverSearch?: boolean; }) { + const { locale } = useTranslations(); + const timeZone = useDisplayTimezone(); const [rawTerm, setTerm] = useState(""); const debounced = useDebouncedValue(rawTerm.trim(), SEARCH_DEBOUNCE_MS); - const term = serverSearch ? debounced : ""; + // A period goes to the route as a date window, which every family's route + // has, so it is searched on the server even where text is not. + const period: PeriodQuery | null = useMemo( + () => parsePeriodQuery(debounced, locale), + [debounced, locale], + ); + const typedPeriod = useMemo( + () => parsePeriodQuery(rawTerm.trim(), locale) !== null, + [rawTerm, locale], + ); + const term = serverSearch || period !== null ? debounced : ""; const searching = term.length > 0; + // "März 2019" and "03.2019" are one request; the key says which period, in + // which zone, rather than how it was typed. + const searchKey = period + ? `period:${period.fromDay}..${period.toDay}@${timeZone}` + : term; const older = useInfiniteQuery({ queryKey: queryKey("older", pivot, ""), enabled: enabled && !searching, initialPageParam: null as string | null, queryFn: ({ pageParam }) => - fetchPage({ direction: "older", pivot, term: "", cursor: pageParam }), + fetchPage({ + direction: "older", + pivot, + term: "", + cursor: pageParam, + locale, + timeZone, + }), getNextPageParam: (last) => last.next ?? undefined, }); @@ -131,16 +165,30 @@ export function useLinkPickerPages({ enabled: enabled && !searching && newerRequested, initialPageParam: null as string | null, queryFn: ({ pageParam }) => - fetchPage({ direction: "newer", pivot, term: "", cursor: pageParam }), + fetchPage({ + direction: "newer", + pivot, + term: "", + cursor: pageParam, + locale, + timeZone, + }), getNextPageParam: (last) => last.next ?? undefined, }); const search = useInfiniteQuery({ - queryKey: queryKey("search", null, term), + queryKey: queryKey("search", null, searchKey), enabled: enabled && searching, initialPageParam: null as string | null, queryFn: ({ pageParam }) => - fetchPage({ direction: "search", pivot: null, term, cursor: pageParam }), + fetchPage({ + direction: "search", + pivot: null, + term, + cursor: pageParam, + locale, + timeZone, + }), getNextPageParam: (last) => last.next ?? undefined, }); @@ -171,10 +219,15 @@ export function useLinkPickerPages({ setTerm, /** A search is in flight (typed, or its first page not back yet). */ searchPending: - serverSearch && + (serverSearch || typedPeriod || period !== null) && (rawTerm.trim() !== term || (searching && search.isPending)), - /** The picker filters the loaded options by the term itself. */ - filterLocally: !serverSearch, + /** + * The picker filters the loaded options by the term itself: a route + * without text search, and a term that is not a period. + */ + filterLocally: !serverSearch && !typedPeriod, + /** The period the list is showing, when the term names one. */ + period: searching ? period : null, hasOlder: searching ? search.hasNextPage : older.hasNextPage, // Unknown until asked: offered once for every dated record, then only // while the server reports another page. From 20a1ac4f7db30d21cf591c8dcf3a6cad7e6b5667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:11:50 +0200 Subject: [PATCH 013/127] test(links): prove a period search returns exactly that period Parser cases for every accepted form in de, en, es, fr, it, pl and ko and the near misses that stay text; builder cases for every family; and an integration walk over three years of lab results, conditions, visits and documents, with records either side of each boundary in the person's zone, across every page. --- .../__tests__/document-record-links.test.tsx | 1 + .../entity-link-picker-paging.test.tsx | 35 ++ .../links/__tests__/period-query.test.ts | 155 +++++++ .../links/__tests__/picker-requests.test.ts | 141 ++++++- .../__tests__/vault-document-options.test.ts | 2 + .../visit-link-picker-long-history.test.ts | 23 +- .../visit-link-picker-period-search.test.ts | 399 ++++++++++++++++++ 7 files changed, 743 insertions(+), 13 deletions(-) create mode 100644 src/components/links/__tests__/period-query.test.ts create mode 100644 tests/integration/visit-link-picker-period-search.test.ts diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx index 209e10413..5d980d908 100644 --- a/src/components/documents/__tests__/document-record-links.test.tsx +++ b/src/components/documents/__tests__/document-record-links.test.tsx @@ -18,6 +18,7 @@ vi.mock("@/components/links/use-link-picker-pages", async (importOriginal) => ({ setTerm: vi.fn(), searchPending: false, filterLocally: true, + period: null, hasOlder: false, hasNewer: false, loadingOlder: false, diff --git a/src/components/links/__tests__/entity-link-picker-paging.test.tsx b/src/components/links/__tests__/entity-link-picker-paging.test.tsx index 8c7e67de3..21342b4d9 100644 --- a/src/components/links/__tests__/entity-link-picker-paging.test.tsx +++ b/src/components/links/__tests__/entity-link-picker-paging.test.tsx @@ -125,6 +125,41 @@ describe("paged link picker", () => { expect(html).toContain('data-slot="labs-add"'); }); + it("names the period a date term searches, and does not filter its answer by the digits", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="labs-period"'); + expect(html).toContain("Showing March 2019"); + expect(html).toContain('data-slot="labs-option"'); + }); + + it("shows no period line for a plain term", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="labs-period"'); + }); + it("does not filter a server answer again by the term", () => { // The route matched on the panel name; the option does not render it. const html = render( diff --git a/src/components/links/__tests__/period-query.test.ts b/src/components/links/__tests__/period-query.test.ts new file mode 100644 index 000000000..ee13a4805 --- /dev/null +++ b/src/components/links/__tests__/period-query.test.ts @@ -0,0 +1,155 @@ +/** + * A picker term that names a year or a month becomes a date window; anything + * else stays a text search. Every accepted form is checked in every UI + * locale, and the near misses that must stay text are checked too. + */ +import { describe, expect, it } from "vitest"; + +import { formatPeriod, parsePeriodQuery } from "../period-query"; + +const NOW = new Date("2026-10-15T12:00:00.000Z"); +const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const; + +function window(term: string, locale: string) { + const parsed = parsePeriodQuery(term, locale, NOW); + return parsed ? `${parsed.fromDay}..${parsed.toDay}` : null; +} + +const MARCH = "2019-03-01..2019-03-31"; +const YEAR = "2019-01-01..2019-12-31"; + +describe("parsePeriodQuery", () => { + describe.each(LOCALES)("%s", (locale) => { + it("reads a year", () => { + expect(window("2019", locale)).toBe(YEAR); + expect(window(" 2019 ", locale)).toBe(YEAR); + }); + + it.each([ + "03.2019", + "3.2019", + "03/2019", + "3/2019", + "03-2019", + "2019-03", + "2019/03", + "2019.03", + "2019.3", + " 03 . 2019 ", + ])("reads the numeric month %j", (term) => { + expect(window(term, locale)).toBe(MARCH); + }); + + it.each(["LDL", "2019abc", "13.2019", "00.2019", "1850", "1899", "2028"])( + "leaves %j as text", + (term) => { + expect(window(term, locale)).toBeNull(); + }, + ); + + it.each(["", " ", "2019 2020", "03.2019.04", "LDL 2019", "3 2019 x"])( + "leaves %j as text", + (term) => { + expect(window(term, locale)).toBeNull(); + }, + ); + }); + + it.each([ + ["de", ["März 2019", "märz 2019", "MÄRZ 2019", "Maerz 2019", "Marz 2019"]], + ["de", ["mär 2019", "Mär. 2019", "2019 März", "März, 2019"]], + ["en", ["March 2019", "march 2019", "Mar 2019", "Mar. 2019", "2019 March"]], + ["es", ["marzo 2019", "Marzo de 2019", "mar 2019", "mar. 2019"]], + ["fr", ["mars 2019", "Mars 2019", "mar 2019", "2019 mars"]], + ["it", ["marzo 2019", "Marzo 2019", "mar 2019", "marzo del 2019"]], + ["pl", ["marzec 2019", "Marzec 2019", "marca 2019", "mar 2019"]], + ["ko", ["2019년 3월", "2019년3월", "2019년 03월", "3월 2019", "2019. 3."]], + ] as const)("reads March 2019 in %s", (locale, terms) => { + for (const term of terms) { + expect(window(term, locale), term).toBe(MARCH); + } + }); + + it.each([ + ["de", "Dezember 2019", "2019-12-01..2019-12-31"], + ["de", "Okt 2019", "2019-10-01..2019-10-31"], + ["de", "Februar 2020", "2020-02-01..2020-02-29"], + ["en", "Sept 2019", "2019-09-01..2019-09-30"], + ["en", "Sep 2019", "2019-09-01..2019-09-30"], + ["en", "May 2019", "2019-05-01..2019-05-31"], + ["es", "septiembre 2019", "2019-09-01..2019-09-30"], + ["es", "ene 2019", "2019-01-01..2019-01-31"], + ["es", "dic 2019", "2019-12-01..2019-12-31"], + ["fr", "février 2019", "2019-02-01..2019-02-28"], + ["fr", "fevrier 2019", "2019-02-01..2019-02-28"], + ["fr", "févr. 2019", "2019-02-01..2019-02-28"], + ["fr", "août 2019", "2019-08-01..2019-08-31"], + ["fr", "déc 2019", "2019-12-01..2019-12-31"], + ["fr", "juil. 2019", "2019-07-01..2019-07-31"], + ["it", "gennaio 2019", "2019-01-01..2019-01-31"], + ["it", "giu 2019", "2019-06-01..2019-06-30"], + ["it", "set 2019", "2019-09-01..2019-09-30"], + ["pl", "październik 2019", "2019-10-01..2019-10-31"], + ["pl", "paź 2019", "2019-10-01..2019-10-31"], + ["pl", "października 2019", "2019-10-01..2019-10-31"], + ["pl", "luty 2019", "2019-02-01..2019-02-28"], + ["pl", "styczeń 2019", "2019-01-01..2019-01-31"], + ["ko", "2019년 12월", "2019-12-01..2019-12-31"], + ["ko", "2019년", YEAR], + // Austrian German calls January "Jänner", and Intl knows it. + ["de-AT", "Jänner 2019", "2019-01-01..2019-01-31"], + ["de-AT", "März 2019", MARCH], + ] as const)("%s %j", (locale, term, expected) => { + expect(window(term, locale)).toBe(expected); + }); + + it.each([ + // "jui" is both juin and juillet; "ma" is too short to name one month. + ["fr", "jui 2019"], + ["en", "ma 2019"], + ["en", "Ju 2019"], + // A month name from another language is text in this one. + ["en", "März 2019"], + ["de", "March 2019"], + // A month without a year is not a period. + ["en", "March"], + ["de", "03"], + // Two months, or a month name with digits glued on. + ["en", "March May"], + ["en", "March2019"], + ] as const)("%s %j stays text", (locale, term) => { + expect(window(term, locale)).toBeNull(); + }); + + it("accepts next year and refuses the one after", () => { + expect(window("2027", "en")).toBe("2027-01-01..2027-12-31"); + expect(window("12.2027", "en")).toBe("2027-12-01..2027-12-31"); + expect(window("2028", "en")).toBeNull(); + expect(window("1900", "en")).toBe("1900-01-01..1900-12-31"); + }); + + it("names the year and the month", () => { + expect(parsePeriodQuery("2019", "de", NOW)).toEqual({ + year: 2019, + month: null, + fromDay: "2019-01-01", + toDay: "2019-12-31", + }); + expect(parsePeriodQuery("Maerz 2019", "de", NOW)).toMatchObject({ + year: 2019, + month: 3, + }); + }); +}); + +describe("formatPeriod", () => { + it("names a period the way the locale writes it", () => { + const march = parsePeriodQuery("03.2019", "de", NOW)!; + expect(formatPeriod(march, "de")).toBe("März 2019"); + expect(formatPeriod(march, "en")).toBe("March 2019"); + expect(formatPeriod(march, "ko")).toBe("2019년 3월"); + expect(formatPeriod(parsePeriodQuery("2019", "en", NOW)!, "en")).toBe( + "2019", + ); + }); +}); diff --git a/src/components/links/__tests__/picker-requests.test.ts b/src/components/links/__tests__/picker-requests.test.ts index 125939e4b..377b2a46b 100644 --- a/src/components/links/__tests__/picker-requests.test.ts +++ b/src/components/links/__tests__/picker-requests.test.ts @@ -28,15 +28,24 @@ import { } from "../use-link-picker-pages"; const pivot = pickerPivot("2019-05-10T10:00:00.000Z"); +const ctx = { locale: "en", timeZone: "UTC" } as const; const REQUESTS: PickerPageRequest[] = [ - { direction: "older", pivot, term: "", cursor: null }, - { direction: "older", pivot, term: "", cursor: "200" }, - { direction: "older", pivot: null, term: "", cursor: null }, - { direction: "newer", pivot, term: "", cursor: null }, - { direction: "newer", pivot, term: "", cursor: "100" }, - { direction: "search", pivot: null, term: "ferritin", cursor: null }, - { direction: "search", pivot: null, term: "ferritin", cursor: "100" }, + { direction: "older", pivot, term: "", cursor: null, ...ctx }, + { direction: "older", pivot, term: "", cursor: "200", ...ctx }, + { direction: "older", pivot: null, term: "", cursor: null, ...ctx }, + { direction: "newer", pivot, term: "", cursor: null, ...ctx }, + { direction: "newer", pivot, term: "", cursor: "100", ...ctx }, + { direction: "search", pivot: null, term: "ferritin", cursor: null, ...ctx }, + { direction: "search", pivot: null, term: "ferritin", cursor: "100", ...ctx }, + { direction: "search", pivot: null, term: "2019", cursor: null, ...ctx }, + { + direction: "search", + pivot: null, + term: "March 2019", + cursor: "100", + ...ctx, + }, ]; function params(url: string): Record { @@ -67,10 +76,22 @@ describe("documents picker", () => { it("splits the day-granular window at the pivot's day", () => { const older = params( - documentPickerUrl({ direction: "older", pivot, term: "", cursor: null }), + documentPickerUrl({ + direction: "older", + pivot, + term: "", + cursor: null, + ...ctx, + }), ); const newer = params( - documentPickerUrl({ direction: "newer", pivot, term: "", cursor: null }), + documentPickerUrl({ + direction: "newer", + pivot, + term: "", + cursor: null, + ...ctx, + }), ); expect(older.to).toBe("2019-05-17"); expect(newer.from).toBe("2019-05-18"); @@ -88,10 +109,22 @@ describe("the windows around a record's date", () => { it("older reads up to the split newest first, newer after it oldest first", () => { const older = params( - labPickerUrl({ direction: "older", pivot, term: "", cursor: null }), + labPickerUrl({ + direction: "older", + pivot, + term: "", + cursor: null, + ...ctx, + }), ); const newer = params( - labPickerUrl({ direction: "newer", pivot, term: "", cursor: null }), + labPickerUrl({ + direction: "newer", + pivot, + term: "", + cursor: null, + ...ctx, + }), ); expect(older).toMatchObject({ to: pivot, sortDir: "desc", offset: "0" }); expect(newer).toMatchObject({ @@ -107,6 +140,7 @@ describe("the windows around a record's date", () => { pivot: null, term: "ferritin", cursor: null, + ...ctx, }), ); expect(search.q).toBe("ferritin"); @@ -120,3 +154,88 @@ describe("the windows around a record's date", () => { expect(nextOffset("100", 3)).toBeNull(); }); }); + +describe("a search term that names a period", () => { + const search = ( + term: string, + locale = "en", + timeZone = "UTC", + ): PickerPageRequest => ({ + direction: "search", + pivot: null, + term, + cursor: null, + locale, + timeZone, + }); + + it.each([ + ["labs", labPickerUrl, "sortDir"], + ["episodes", episodePickerUrl, "sortDir"], + ] as const)( + "%s: a year is the year's instant window, newest first, with no q", + (_name, build, sortKey) => { + const sent = params(build(search("2019"))); + expect(sent.q).toBeUndefined(); + expect(sent.from).toBe("2019-01-01T00:00:00.000Z"); + expect(sent.to).toBe("2019-12-31T23:59:59.999Z"); + expect(sent[sortKey]).toBe("desc"); + }, + ); + + it("labs: a month in the person's language is that month", () => { + const sent = params(labPickerUrl(search("März 2019", "de"))); + expect(sent.q).toBeUndefined(); + expect(sent.from).toBe("2019-03-01T00:00:00.000Z"); + expect(sent.to).toBe("2019-03-31T23:59:59.999Z"); + }); + + it("the window follows the person's timezone, DST included", () => { + // Berlin: March 2019 starts at UTC+1 and ends at UTC+2. + const sent = params( + episodePickerUrl(search("03.2019", "de", "Europe/Berlin")), + ); + expect(sent.from).toBe("2019-02-28T23:00:00.000Z"); + expect(sent.to).toBe("2019-03-31T21:59:59.999Z"); + }); + + it("documents: the day-granular window is the period's two calendar days", () => { + const sent = params(documentPickerUrl(search("2019-02"))); + expect(sent.q).toBeUndefined(); + expect(sent.from).toBe("2019-02-01"); + expect(sent.to).toBe("2019-02-28"); + expect(sent.order).toBe("desc"); + }); + + it("encounters: the route without text search still gets the period", () => { + const sent = params(encounterPickerUrl(search("Mar 2019"))); + expect(sent.q).toBeUndefined(); + expect(sent.from).toBe("2019-03-01T00:00:00.000Z"); + expect(sent.to).toBe("2019-03-31T23:59:59.999Z"); + }); + + it.each([labPickerUrl, episodePickerUrl, documentPickerUrl])( + "a plain term stays a text search (%#)", + (build) => { + for (const term of ["LDL", "2019abc", "13.2019", "1850"]) { + const sent = params(build(search(term))); + expect(sent.q).toBe(term); + expect(sent.from).toBeUndefined(); + expect(sent.to).toBeUndefined(); + } + }, + ); + + it("a month name is read in the UI locale only", () => { + // "März" is not an English month, so it stays text. + expect(params(labPickerUrl(search("März 2019", "en"))).q).toBe("März 2019"); + }); + + it("the older and newer windows never read the term", () => { + const older = params( + labPickerUrl({ ...search("2019"), direction: "older", pivot }), + ); + expect(older.to).toBe(pivot); + expect(older.from).toBeUndefined(); + }); +}); diff --git a/src/components/links/__tests__/vault-document-options.test.ts b/src/components/links/__tests__/vault-document-options.test.ts index 049c915d8..ef065b4a4 100644 --- a/src/components/links/__tests__/vault-document-options.test.ts +++ b/src/components/links/__tests__/vault-document-options.test.ts @@ -113,6 +113,8 @@ describe("fetchVaultPage", () => { pivot: "2026-05-17T23:59:59.999Z", term: "", cursor: "z", + locale: "en", + timeZone: "UTC", }); expect(page.items.map((d) => d.id)).toEqual(["a"]); expect(page.next).toBe("a"); diff --git a/tests/integration/visit-link-picker-long-history.test.ts b/tests/integration/visit-link-picker-long-history.test.ts index b9d286a4d..e67f629dd 100644 --- a/tests/integration/visit-link-picker-long-history.test.ts +++ b/tests/integration/visit-link-picker-long-history.test.ts @@ -66,6 +66,8 @@ const DAY_MS = 24 * 60 * 60 * 1000; const VISIT_AT = "2019-04-03T09:30:00.000Z"; const FERRITIN_AT = new Date("2019-04-03T07:00:00.000Z"); const RESULT_COUNT = 1_200; +/** What the hook adds to every request: the UI locale and the person's zone. */ +const CTX = { locale: "en", timeZone: "Europe/Berlin" } as const; async function seedOwner() { const prisma = getPrismaClient(); @@ -161,6 +163,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => { pivot, term: "", cursor: null, + ...CTX, }); expect(first.results.some((row) => row.id === ferritinId)).toBe(true); // Nothing after the window's split is on it. @@ -182,6 +185,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => { pivot, term: "", cursor, + ...CTX, }); for (const row of page.results) { expect(seen.has(row.id)).toBe(false); @@ -207,6 +211,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => { pivot: null, term, cursor: null, + ...CTX, }); expect(page.results.map((row) => row.id)).toEqual([ferritinId]); } @@ -240,6 +245,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => { pivot: pickerPivot(VISIT_AT), term: "", cursor: null, + ...CTX, }), ); expect(list.past).toHaveLength(1); @@ -276,7 +282,13 @@ describe("the condition picker's window and search", () => { const pivot = pickerPivot(VISIT_AT); const around = await get>( listEpisodes as never, - episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }), + episodePickerUrl({ + direction: "older", + pivot, + term: "", + cursor: null, + ...CTX, + }), ); expect(around[0]!.id).toBe(old.id); @@ -287,6 +299,7 @@ describe("the condition picker's window and search", () => { pivot: null, term: "tendin", cursor: null, + ...CTX, }), ); expect(found.map((row) => row.id)).toEqual([old.id]); @@ -297,7 +310,13 @@ describe("the condition picker's window and search", () => { for (let guard = 0; guard < 5; guard++) { const page: Array<{ id: string }> = await get( listEpisodes as never, - episodePickerUrl({ direction: "newer", pivot, term: "", cursor }), + episodePickerUrl({ + direction: "newer", + pivot, + term: "", + cursor, + ...CTX, + }), ); for (const row of page) { expect(ids.has(row.id)).toBe(false); diff --git a/tests/integration/visit-link-picker-period-search.test.ts b/tests/integration/visit-link-picker-period-search.test.ts new file mode 100644 index 000000000..8c11213fa --- /dev/null +++ b/tests/integration/visit-link-picker-period-search.test.ts @@ -0,0 +1,399 @@ +/** + * A picker search for a year or a month returns exactly that period. + * + * Typing "2019" into a visit's link picker used to send `q=2019`, which the + * list routes match against names and titles, so filtering by a year found + * nothing. A term that names a period is now sent as the route's own date + * window. This drives every family's list route with the requests the + * pickers build (`picker-requests.ts`), against three years of records, and + * walks every page: the answer must be every record in the period and + * nothing else, including the records far beyond the first page. + * + * The expected sets are derived independently of the builders: an instant + * belongs to the period its day in the person's zone falls in + * (`userDayKey`), a document to the period its stored calendar date is in. + * The seeds put records just either side of each boundary in that zone. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import { GET as listEncounters } from "@/app/api/encounters/route"; +import { GET as listDocuments } from "@/app/api/documents/inbound/route"; +import { GET as listEpisodes } from "@/app/api/illness/episodes/route"; +import { GET as listLabs } from "@/app/api/labs/route"; +import { + documentPickerUrl, + encounterPickerUrl, + episodePickerUrl, + labPickerUrl, +} from "@/components/links/picker-requests"; +import { + PICKER_PAGE_SIZE, + nextOffset, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; +import { userDayKey } from "@/lib/tz/format"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "period-search-owner"; +const DAY_MS = 24 * 60 * 60 * 1000; +const TZ = "Europe/Berlin"; + +/** Every day from 2018-01-01 to 2020-12-31, as UTC-midnight instants. */ +const DAYS = Array.from( + { length: 1096 }, + (_, i) => new Date(Date.UTC(2018, 0, 1) + i * DAY_MS), +); + +/** + * Instants just either side of the period boundaries in Berlin: 23:30 and + * 00:30 local around New Year 2019 (UTC+1), the start of March 2019 (UTC+1) + * and the end of March 2019 (UTC+2, after the DST change), and New Year 2020. + */ +const EDGES = [ + "2018-12-31T22:30:00.000Z", + "2018-12-31T23:30:00.000Z", + "2019-02-28T22:30:00.000Z", + "2019-02-28T23:30:00.000Z", + "2019-03-31T21:30:00.000Z", + "2019-03-31T22:30:00.000Z", + "2019-12-31T22:30:00.000Z", + "2019-12-31T23:30:00.000Z", +].map((iso) => new Date(iso)); + +const search = (term: string, locale: string): PickerPageRequest => ({ + direction: "search", + pivot: null, + term, + cursor: null, + locale, + timeZone: TZ, +}); + +async function seedOwner() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "period-search", + email: "period-search@example.test", + timezone: TZ, + modulePreferencesJson: { + labs: true, + illness: true, + inboundDocuments: true, + }, + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: new Date(Date.now() + 7 * DAY_MS), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +async function get( + handler: (request: NextRequest) => Promise, + url: string, +): Promise { + const response = await handler(new NextRequest(`http://localhost${url}`)); + expect(response.status, url).toBe(200); + return ((await response.json()) as { data: T }).data; +} + +/** Ids of the instants whose day in the person's zone starts with `prefix`. */ +function expectedIds( + rows: ReadonlyArray<{ id: string; at: Date }>, + prefix: string, +): Set { + return new Set( + rows + .filter((row) => userDayKey(row.at, TZ).startsWith(prefix)) + .map((r) => r.id), + ); +} + +/** Walk an offset-paged search to its end, newest first, without repeats. */ +async function walkOffsets( + fetchPage: (cursor: string | null) => Promise, + at: (row: T) => string, +): Promise<{ ids: Set; pages: number }> { + const ids = new Set(); + let cursor: string | null = null; + let pages = 0; + let previous = Number.POSITIVE_INFINITY; + for (let guard = 0; guard < 30; guard++) { + const rows = await fetchPage(cursor); + pages += 1; + for (const row of rows) { + expect(ids.has(row.id), row.id).toBe(false); + ids.add(row.id); + const when = new Date(at(row)).getTime(); + expect(when).toBeLessThanOrEqual(previous); + previous = when; + } + cursor = nextOffset(cursor, rows.length); + if (cursor === null) break; + } + return { ids, pages }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedOwner(); +}); + +describe("lab results: a period search walks exactly the period", () => { + async function seed() { + const prisma = getPrismaClient(); + // A date-only reading is stored at noon UTC; the edges carry a time. + const instants = [ + ...DAYS.map((day) => new Date(day.getTime() + 12 * 60 * 60 * 1000)), + ...EDGES, + ]; + await prisma.labResult.createMany({ + data: instants.map((takenAt, i) => ({ + userId: OWNER_ID, + panel: "Metabolic", + analyte: `Glucose ${i}`, + value: 90, + unit: "mg/dL", + takenAt, + })), + }); + const rows = await prisma.labResult.findMany({ + where: { userId: OWNER_ID }, + select: { id: true, takenAt: true }, + }); + return rows.map((row) => ({ id: row.id, at: row.takenAt })); + } + + const walk = (request: PickerPageRequest) => + walkOffsets( + async (cursor) => { + const page = await get<{ + results: Array<{ id: string; takenAt: string }>; + }>(listLabs as never, labPickerUrl({ ...request, cursor })); + return page.results; + }, + (row) => row.takenAt, + ); + + it("a year", async () => { + const rows = await seed(); + const expected = expectedIds(rows, "2019-"); + expect(expected.size).toBe(365 + 6); + const { ids, pages } = await walk(search("2019", "en")); + expect(pages).toBeGreaterThan(1); + expect(ids.size).toBeGreaterThan(PICKER_PAGE_SIZE); + expect(ids).toEqual(expected); + }); + + it.each([ + ["de", "März 2019"], + ["de", "03.2019"], + ["en", "Mar 2019"], + ["pl", "marzec 2019"], + ["ko", "2019년 3월"], + ])("a month (%s %j)", async (locale, term) => { + const rows = await seed(); + const expected = expectedIds(rows, "2019-03-"); + expect(expected.size).toBe(31 + 2); + const { ids } = await walk(search(term, locale)); + expect(ids).toEqual(expected); + }); + + it("a plain term is still a text search", async () => { + await seed(); + const { ids } = await walk(search("Glucose 7", "en")); + // "Glucose 7", "Glucose 70" … "Glucose 799", "Glucose 7xx": names only. + expect(ids.size).toBeGreaterThan(0); + expect(ids.size).toBeLessThan(200); + }); +}); + +describe("conditions: a period search walks exactly the period", () => { + it("a year and a month", async () => { + const prisma = getPrismaClient(); + const onsets = [ + ...DAYS.filter((_, i) => i % 2 === 0).map( + (day) => new Date(day.getTime() + 5 * 60 * 60 * 1000), + ), + ...EDGES, + ]; + await prisma.illnessEpisode.createMany({ + data: onsets.map((onsetAt, i) => ({ + userId: OWNER_ID, + label: `Cold ${i}`, + type: "INFECTION" as const, + onsetAt, + resolvedAt: new Date(onsetAt.getTime() + 3 * DAY_MS), + })), + }); + const rows = ( + await prisma.illnessEpisode.findMany({ + where: { userId: OWNER_ID }, + select: { id: true, onsetAt: true }, + }) + ).map((row) => ({ id: row.id, at: row.onsetAt })); + + const walk = (request: PickerPageRequest) => + walkOffsets( + async (cursor) => { + const page = await get>( + listEpisodes as never, + episodePickerUrl({ ...request, cursor }), + ); + return page; + }, + (row) => row.onsetAt, + ); + + const year = await walk(search("2019", "fr")); + expect(year.ids.size).toBeGreaterThan(PICKER_PAGE_SIZE); + expect(year.ids).toEqual(expectedIds(rows, "2019-")); + + const month = await walk(search("mars 2019", "fr")); + expect(month.ids).toEqual(expectedIds(rows, "2019-03-")); + }); +}); + +describe("visits: the route without text search still searches a period", () => { + it("a year and a month", async () => { + const prisma = getPrismaClient(); + const instants = [ + ...DAYS.map((day) => new Date(day.getTime() + 9 * 60 * 60 * 1000)), + ...EDGES, + ]; + await prisma.encounter.createMany({ + data: instants.map((occurredAt) => ({ + userId: OWNER_ID, + occurredAt, + status: "DONE" as const, + kind: "ROUTINE" as const, + })), + }); + const rows = ( + await prisma.encounter.findMany({ + where: { userId: OWNER_ID }, + select: { id: true, occurredAt: true }, + }) + ).map((row) => ({ id: row.id, at: row.occurredAt })); + + const walk = (request: PickerPageRequest) => + walkOffsets( + async (cursor) => { + const list = await get<{ + upcoming: Array<{ id: string; occurredAt: string }>; + past: Array<{ id: string; occurredAt: string }>; + }>( + listEncounters as never, + encounterPickerUrl({ ...request, cursor }), + ); + expect(list.upcoming).toEqual([]); + return list.past; + }, + (row) => row.occurredAt, + ); + + const year = await walk(search("2019", "it")); + expect(year.pages).toBeGreaterThan(1); + expect(year.ids).toEqual(expectedIds(rows, "2019-")); + + const month = await walk(search("marzo 2019", "it")); + expect(month.ids).toEqual(expectedIds(rows, "2019-03-")); + }); +}); + +describe("documents: the day-granular window is the period's calendar days", () => { + it("a year and a month, across keyset pages", async () => { + const prisma = getPrismaClient(); + await prisma.inboundDocument.createMany({ + data: DAYS.map((day, i) => ({ + userId: OWNER_ID, + kind: "OTHER" as const, + filename: `doc-${i}.pdf`, + mimeType: "application/pdf", + byteSize: 4, + status: "STORED" as const, + contentEncrypted: Buffer.from("test"), + contentCodec: "v1", + documentDate: day, + })), + }); + const rows = await prisma.inboundDocument.findMany({ + where: { userId: OWNER_ID }, + select: { id: true, documentDate: true }, + }); + const expected = (prefix: string) => + new Set( + rows + .filter((row) => row.documentDate!.toISOString().startsWith(prefix)) + .map((row) => row.id), + ); + + async function walk(request: PickerPageRequest) { + const ids = new Set(); + let cursor: string | null = null; + let pages = 0; + for (let guard = 0; guard < 30; guard++) { + const page: { + documents: Array<{ id: string }>; + nextCursor: string | null; + } = await get( + listDocuments as never, + documentPickerUrl({ ...request, cursor }), + ); + pages += 1; + for (const doc of page.documents) { + expect(ids.has(doc.id)).toBe(false); + ids.add(doc.id); + } + cursor = page.nextCursor; + if (cursor === null) break; + } + return { ids, pages }; + } + + const year = await walk(search("2019", "es")); + expect(year.pages).toBeGreaterThan(1); + expect(year.ids).toEqual(expected("2019-")); + expect(year.ids.size).toBe(365); + + const month = await walk(search("marzo de 2019", "es")); + expect(month.ids).toEqual(expected("2019-03-")); + expect(month.ids.size).toBe(31); + }); +}); From b873ce8bc8be23563a6fc1727f43dd36cc8a7e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:15:50 +0200 Subject: [PATCH 014/127] feat(admin): fill the redacted support report with its redaction guard --- src/__tests__/contract-stub-inventory.test.ts | 3 +- .../support-report/__tests__/route.test.ts | 277 ++++++ src/app/api/admin/support-report/route.ts | 23 +- .../api-handler-error-request-id.test.ts | 3 +- .../support-report-redaction.test.ts | 309 +++++++ src/lib/support/redact.ts | 164 ++++ src/lib/support/report.ts | 875 ++++++++++++++++++ .../integration/admin-support-report.test.ts | 152 +++ 8 files changed, 1801 insertions(+), 5 deletions(-) create mode 100644 src/app/api/admin/support-report/__tests__/route.test.ts create mode 100644 src/lib/support/__tests__/support-report-redaction.test.ts create mode 100644 src/lib/support/redact.ts create mode 100644 src/lib/support/report.ts create mode 100644 tests/integration/admin-support-report.test.ts diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index 102729345..0d71bcd34 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -28,7 +28,6 @@ const CONTRACT_STUBS: Readonly> = { "admin.pulse_day_mean.get", "admin.pulse_day_mean.post", ], - "admin/support-report/route.ts": ["admin.support_report.get"], "anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"], "anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"], "anamnesis/life-circumstances/ai-sections/route.ts": [ @@ -64,7 +63,7 @@ const CONTRACT_STUBS: Readonly> = { }; /** The size of the register when it was written; see the header. */ -const REGISTER_SIZE = 24; +const REGISTER_SIZE = 23; function routeFiles(): string[] { return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] }) diff --git a/src/app/api/admin/support-report/__tests__/route.test.ts b/src/app/api/admin/support-report/__tests__/route.test.ts new file mode 100644 index 000000000..e74b24c18 --- /dev/null +++ b/src/app/api/admin/support-report/__tests__/route.test.ts @@ -0,0 +1,277 @@ +/** + * `GET /api/admin/support-report` end to end over a mocked database: the + * collector, the renderer and the redaction together. Accounts are seeded + * with distinctive emails, ids, usernames and names, and the failures carry + * a token, a URL and a host in their messages; none of it may reach the text. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const USERS = [ + { + id: "cmownerabcdefghijklmnopq", + username: "ownerperson", + email: "owner.person@example.org", + fullName: "Olive Owner", + aiUsageLevel: null, + }, + { + id: "cmsecondabcdefghijklmnop", + username: "secondperson", + email: "second.person@mailbox.org", + fullName: "Sam Second", + aiUsageLevel: "FULL", + }, +]; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findMany: vi.fn(async () => USERS), + groupBy: vi.fn(async () => [ + { aiProvider: "LOCAL", _count: { _all: 1 } }, + { aiProvider: null, _count: { _all: 1 } }, + ]), + }, + appSettings: { + findUnique: vi.fn(async () => ({ + glitchtipEnabled: true, + glitchtipDsn: "https://key@glitchtip.example.net/1", + webPushVapidPublicKey: null, + webPushVapidPrivateKeyEncrypted: null, + adminAiKeyEncrypted: "v1:ciphertext", + adminCodexConnectionStatus: "disconnected", + aiUsageDefaultLevel: "STANDARD", + aiUsageMaxLevel: "FULL", + })), + }, + $queryRaw: vi.fn(async (strings: TemplateStringsArray) => { + const sql = strings.join("?"); + if (sql.includes("server_version")) { + return [ + { version: "16.4", timezone: "Etc/UTC", size: BigInt(52428800) }, + ]; + } + if (sql.includes("migration_name")) { + return [ + { + migration_name: "0410_relation_links", + finished_at: new Date("2026-10-15T08:00:00Z"), + }, + ]; + } + if (sql.includes("finished_at IS NULL")) return [{ count: 0 }]; + if (sql.includes("pgboss.job")) { + return [{ at: new Date("2026-10-15T09:59:00Z") }]; + } + return []; + }), + integrationWatch: { + findMany: vi.fn(async () => [ + { + userId: "cmsecondabcdefghijklmnop", + integration: "nightscout", + incidentReason: "unexpected_silence", + incidentOpenedAt: new Date("2026-10-14T00:00:00Z"), + alertedAt: null, + }, + ]), + }, + providerHealth: { + findMany: vi.fn(async () => [ + { + providerType: "LOCAL", + lastResult: "hard_failed", + lastStatus: 502, + lastFailureAt: new Date("2026-10-15T07:00:00Z"), + nextRetryAt: new Date(Date.now() + 60_000), + }, + ]), + }, + pushAttempt: { + groupBy: vi.fn(async (args: { by: string[] }) => + args.by.includes("result") + ? [ + { channel: "NTFY", result: "ok", _count: { _all: 9 } }, + { channel: "NTFY", result: "error", _count: { _all: 2 } }, + ] + : [{ channel: "NTFY", reason: "http_500", _count: { _all: 2 } }], + ), + }, + dataBackup: { + aggregate: vi.fn(async () => ({ + _max: { createdAt: new Date("2026-10-12T03:00:00Z") }, + })), + count: vi.fn(async () => 2), + }, + offhostBackupState: { + findMany: vi.fn(async () => [ + { lastSuccessAt: new Date("2026-10-15T02:00:00Z") }, + { lastSuccessAt: null }, + ]), + }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +vi.mock("@/lib/jobs/job-failures", () => ({ + JOB_FAILURE_WINDOW_HOURS: 72, + readFailingQueues: vi.fn(async () => [ + { + queue: "withings-sync", + failures: 4, + lastFailedAt: "2026-10-15T09:00:00.000Z", + lastError: + "TypeError: fetch https://wbsapi.withings.net/v2?access_token=abc failed for owner.person@example.org", + }, + ]), + readLastQueueRun: vi.fn(async (queue: string) => + queue === "data-restore-drill" + ? { + at: "2026-10-01T04:11:00.000Z", + state: "failed", + error: "connect ECONNREFUSED 10.0.0.12:9000 (s3.nas.lan)", + } + : null, + ), +})); + +vi.mock("@/lib/integrations/connection-health-load", () => ({ + loadConnectionHealth: vi.fn(async (userId: string) => { + const empty = { + withings: null, + whoop: null, + fitbit: null, + "google-health": null, + polar: null, + oura: null, + strava: null, + nightscout: null, + "apple-health": null, + }; + if (userId === "cmownerabcdefghijklmnopq") { + return { + ...empty, + withings: { state: "ok", reason: "data_arriving" }, + }; + } + return { + ...empty, + withings: { state: "action_required", reason: "reauth_required" }, + nightscout: { state: "degraded", reason: "unexpected_silence" }, + }; + }), +})); + +vi.mock("@/lib/geo", () => ({ offlineGeoReady: () => false })); + +import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer"; +import { GET } from "../route"; + +const FORBIDDEN = [ + ...USERS.flatMap((u) => [u.id, u.username, u.email, u.fullName]), + "wbsapi.withings.net", + "access_token", + "glitchtip.example.net", + "10.0.0.12", + "s3.nas.lan", + "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc", + "hlk_leaked_token", +]; + +beforeEach(() => { + vi.mocked(requireAdmin).mockReset(); + clearLogBuffer(); +}); + +async function report(): Promise<{ generatedAt: string; text: string }> { + const res = await GET(); + expect(res.status).toBe(200); + return (await res.json()).data; +} + +describe("GET /api/admin/support-report", () => { + it("refuses a non-admin before reading anything", async () => { + vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden")); + await expect(GET()).rejects.toThrow("Forbidden"); + }); + + it("answers the redacted report with every section", async () => { + appendLogEvent({ + timestamp: "2026-10-15T09:40:00.000Z", + duration_ms: 20, + request_id: "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc", + trace_id: "t", + level: "error", + kind: "http", + service: "healthlog", + environment: "test", + action: { name: "measurement.batch.ingest" }, + auth: { user_id: "cmownerabcdefghijklmnopq" }, + error: { + type: "TypeError", + message: "Bearer hlk_leaked_token for owner.person@example.org", + }, + }); + + const { generatedAt, text } = await report(); + expect(Date.parse(generatedAt)).not.toBeNaN(); + for (const heading of [ + "## Build", + "## Database", + "## Configuration", + "## Jobs", + "## Connections", + "## AI", + "## Notifications (7 days)", + "## Backups", + "## Recent warnings and errors (this process)", + ]) { + expect(text).toContain(heading); + } + expect(text).toContain("- PostgreSQL: 16.4"); + expect(text).toContain("0410_relation_links"); + expect(text).toContain("- GlitchTip: yes"); + expect(text).toContain("withings-sync: 4 failed"); + expect(text).toContain("(TypeError)"); + expect(text).toContain( + "- withings: action_required / reauth_required: 1 (Account 2)", + ); + expect(text).toContain("- withings: ok / data_arriving: 1"); + expect(text).toContain("nightscout: unexpected_silence since"); + expect(text).toContain("Account 2"); + expect(text).toContain( + "- Accounts by usage level: FULL 1, instance default 1", + ); + expect(text).toContain( + "LOCAL: ok 0, failing 1, credential refused 0, paused 1", + ); + expect(text).toContain("- NTFY: ok 9, failed 2, skipped 0; http_500 2"); + expect(text).toContain( + "Last restore drill: failed at 2026-10-01T04:11:00.000Z (ECONNREFUSED)", + ); + expect(text).toContain("- error measurement.batch.ingest: 1x"); + + for (const value of FORBIDDEN) { + expect(text.toLowerCase(), value).not.toContain(value.toLowerCase()); + } + }); +}); diff --git a/src/app/api/admin/support-report/route.ts b/src/app/api/admin/support-report/route.ts index 08fec25cb..472f8e886 100644 --- a/src/app/api/admin/support-report/route.ts +++ b/src/app/api/admin/support-report/route.ts @@ -5,13 +5,32 @@ * backups and the most frequent recent error actions. Never an email address, * a user id, a value, a token or a foreign host name; accounts appear as * "Account 1..n". Admin only, cookie session only. + * + * What goes in and how it is redacted lives in `src/lib/support/report.ts`; + * the redaction guard is `src/lib/support/__tests__/support-report-redaction.test.ts`. */ import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + buildSupportReportText, + collectSupportReportFacts, +} from "@/lib/support/report"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { await requireAdmin(); - return notImplemented("admin.support_report.get"); + annotate({ action: { name: "admin.support_report.get" } }); + + const { facts, context } = await collectSupportReportFacts(); + const text = buildSupportReportText(facts, context); + + annotate({ + meta: { + support_report_accounts: facts.accounts.total, + support_report_bytes: text.length, + }, + }); + return apiSuccess({ generatedAt: facts.generatedAt, text }); }); diff --git a/src/lib/__tests__/api-handler-error-request-id.test.ts b/src/lib/__tests__/api-handler-error-request-id.test.ts index a76c73493..ca32c21b0 100644 --- a/src/lib/__tests__/api-handler-error-request-id.test.ts +++ b/src/lib/__tests__/api-handler-error-request-id.test.ts @@ -29,7 +29,8 @@ function call( handler: () => Promise, headers: Record = {}, ) { - return apiHandler(handler)( + const route = apiHandler(async (_request: NextRequest) => handler()); + return route( new NextRequest("http://localhost/api/probe", { headers }), ); } diff --git a/src/lib/support/__tests__/support-report-redaction.test.ts b/src/lib/support/__tests__/support-report-redaction.test.ts new file mode 100644 index 000000000..d21046b33 --- /dev/null +++ b/src/lib/support/__tests__/support-report-redaction.test.ts @@ -0,0 +1,309 @@ +/** + * Redaction guard for the operator's support report. + * + * The report is pasted into public issues. This seeds every free-text slot it + * carries with something it must never print (an account's email, id and + * username, a token, a URL, a foreign host, an address, a request id) and + * proves two things: + * + * 1. the finished text contains none of them; + * 2. every redaction step is load-bearing: with any single step dropped, + * the seed of its class reaches the output. A step that can be removed + * without a test going red would be a step nobody notices is gone. + */ +import { describe, expect, it } from "vitest"; + +import { + buildSupportReportText, + errorClassOf, + renderSupportReport, + summariseProblemEvents, + type SupportReportFacts, +} from "../report"; +import { + redactSupportText, + SUPPORT_REDACTION_STEPS, + type SupportRedactionContext, +} from "../redact"; +import type { WideEvent } from "@/lib/logging/types"; + +const ACCOUNT_EMAIL = "anna.schmidt@example.org"; +const ACCOUNT_ID = "cmq7x0abcdefghijklmnopqrs"; +const ACCOUNT_USERNAME = "annaschmidt"; +const ACCOUNT_NAME = "Anna Schmidt"; + +/** One seed per step; each is caught by its own step and by no other. */ +const SEEDS = { + "known-values": ACCOUNT_USERNAME, + secrets: "sk-ant-api03-Zq9Xw8Vu7Ts6", + urls: "smb://nas/backups", + emails: "someone@", + ids: "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc", + "ip-addresses": "192.168.17.42", + "host-names": "gotify.home.arpa", +} as const; + +const CONTEXT: SupportRedactionContext = { + knownValues: [ACCOUNT_ID, ACCOUNT_USERNAME, ACCOUNT_EMAIL, ACCOUNT_NAME], +}; + +function seededFacts(): SupportReportFacts { + return { + generatedAt: "2026-10-15T10:00:00.000Z", + build: { + version: "1.43.0", + buildSha: "abc1234", + builtAt: "2026-10-15T08:00:00.000Z", + node: "v22.11.0", + processType: "web,worker", + heapLimitMb: 792, + uptimeSeconds: 7260, + }, + database: { + version: "16.4", + timezone: "Etc/UTC", + sizeBytes: 104857600, + lastMigration: "0410_relation_links", + lastMigrationAt: "2026-10-15T08:05:00.000Z", + unfinishedMigrations: 0, + }, + config: [ + ["Secure cookie flag", "yes"], + // A value that should only ever be yes/no, carrying a URL anyway. + ["AI provider types chosen by accounts", `OPENAI 1, ${SEEDS.urls}`], + ], + jobs: { + workerInThisProcess: true, + workerRunning: true, + workerLastHeartbeat: "2026-10-15T09:59:00.000Z", + newestFinishedJobAt: "2026-10-15T09:59:30.000Z", + failing: [ + { + queue: "environment-fetch", + failures: 3, + lastFailedAt: "2026-10-15T09:00:00.000Z", + errorClass: `Error for ${ACCOUNT_EMAIL}`, + }, + ], + }, + accounts: { total: 2, read: 2 }, + connections: [ + { + provider: "withings", + state: "action_required", + reason: "reauth_required", + accounts: [2], + }, + { + provider: "oura", + state: "degraded", + reason: `provider_errors ${SEEDS["ip-addresses"]}`, + accounts: [1], + }, + ], + incidents: [ + { + provider: "nightscout", + reason: `unexpected_silence ${SEEDS["host-names"]}`, + openedAt: "2026-10-14T00:00:00.000Z", + announced: true, + account: 1, + }, + ], + ai: { + defaultLevel: "STANDARD", + maxLevel: "FULL", + levels: [["instance default", 2]], + providers: [ + { + provider: "LOCAL", + ok: 1, + failing: 1, + authFailed: 0, + paused: 0, + lastFailureAt: "2026-10-15T07:00:00.000Z", + lastStatus: 502, + }, + ], + }, + notifications: [ + { + channel: "NTFY", + ok: 10, + error: 2, + skipped: 0, + topReasons: [[`http_500 ${SEEDS.ids}`, 2]], + }, + ], + backups: { + newestLocalAt: "2026-10-12T03:00:00.000Z", + localCopies: 2, + lastLocalRun: { + at: "2026-10-12T03:10:00.000Z", + state: "completed", + errorClass: null, + }, + offhostConfigured: true, + offhostNewestAt: "2026-10-15T02:00:00.000Z", + offhostOldestAt: "2026-10-15T01:00:00.000Z", + offhostAccounts: 2, + offhostNeverStored: 0, + lastOffhostRun: null, + lastRestoreDrill: { + at: "2026-10-01T04:11:00.000Z", + state: "failed", + errorClass: `TypeError Bearer ${SEEDS.secrets}`, + }, + }, + errors: { + ringSize: 3, + ringMax: 1000, + top: [ + { + action: `integration.sync.${ACCOUNT_ID}`, + level: "error", + count: 2, + lastSeen: "2026-10-15T09:30:00.000Z", + errorClass: `${ACCOUNT_USERNAME} Error`, + }, + { + action: "notification.send", + level: "warn", + count: 1, + lastSeen: "2026-10-15T09:20:00.000Z", + errorClass: `someone@mailbox.org ${ACCOUNT_NAME}`, + }, + ], + }, + }; +} + +const FORBIDDEN = [ + ACCOUNT_EMAIL, + ACCOUNT_ID, + ACCOUNT_USERNAME, + ACCOUNT_NAME, + ...Object.values(SEEDS), +]; + +describe("support report redaction", () => { + it("prints none of the seeded values", () => { + const text = buildSupportReportText(seededFacts(), CONTEXT); + for (const value of FORBIDDEN) { + expect(text.toLowerCase(), value).not.toContain(value.toLowerCase()); + } + // The report itself survived: sections, accounts as numbers, dates. + expect(text).toContain("# HealthLog support report"); + expect(text).toContain("Account 2"); + expect(text).toContain("2026-10-15T09:30:00.000Z"); + expect(text).toContain("0410_relation_links"); + expect(text).toContain("environment-fetch"); + }); + + it("the seeds really are in the unredacted text", () => { + const raw = renderSupportReport(seededFacts()); + for (const value of FORBIDDEN) expect(raw).toContain(value); + }); + + it.each(SUPPORT_REDACTION_STEPS.map((s) => s.name))( + "goes red without the %s step", + (name) => { + const seed = SEEDS[name as keyof typeof SEEDS]; + expect(seed, `no seed for step ${name}`).toBeDefined(); + const without = SUPPORT_REDACTION_STEPS.filter((s) => s.name !== name); + const text = redactSupportText( + renderSupportReport(seededFacts()), + CONTEXT, + without, + ); + expect(text.toLowerCase()).toContain(seed.toLowerCase()); + }, + ); + + it("has a seed for every step, so a new step needs one", () => { + expect(Object.keys(SEEDS).sort()).toEqual( + SUPPORT_REDACTION_STEPS.map((s) => s.name).sort(), + ); + }); + + it("leaves dates, versions and clock times alone", () => { + const text = redactSupportText( + "- Node: v22.11.0\n- Built at: 2026-10-15T08:00:00.000Z\n- Version: 1.43.0", + CONTEXT, + ); + expect(text).toContain("v22.11.0"); + expect(text).toContain("2026-10-15T08:00:00.000Z"); + expect(text).toContain("1.43.0"); + }); +}); + +describe("error classes never carry the message", () => { + it("names the class or code and drops the rest", () => { + expect( + errorClassOf( + `TypeError: fetch failed for https://gotify.home.arpa/?token=abc (${ACCOUNT_EMAIL})`, + ), + ).toBe("TypeError"); + expect(errorClassOf("connect ECONNREFUSED 192.168.17.42:5432")).toBe( + "ECONNREFUSED", + ); + expect( + errorClassOf("Invalid `prisma.user.update()` invocation: P2002 unique"), + ).toBe("P2002"); + expect(errorClassOf("Withings answered HTTP 503 for annaschmidt")).toBe( + "503", + ); + expect(errorClassOf("job timed out: did not finish within 1 h")).toBe( + "timeout", + ); + expect(errorClassOf("anything at all, an email a@b.co")).toBe("Error"); + expect(errorClassOf(null)).toBe("unknown"); + }); + + it("groups problem events by action without reading their messages", () => { + const event = (over: Partial): WideEvent => ({ + timestamp: "2026-10-15T09:00:00.000Z", + duration_ms: 5, + request_id: SEEDS.ids, + trace_id: "t", + level: "error", + kind: "http", + service: "healthlog", + environment: "test", + ...over, + }); + const top = summariseProblemEvents([ + event({ + action: { name: "measurement.batch.ingest" }, + error: { + type: "PrismaClientKnownRequestError", + code: "P2002", + message: `duplicate for ${ACCOUNT_EMAIL}`, + }, + }), + event({ + timestamp: "2026-10-15T09:05:00.000Z", + action: { name: "measurement.batch.ingest" }, + error: { type: "Error", message: "Bearer hlk_secret_value_here" }, + }), + event({ + level: "warn", + http: { method: "GET", path: "/x", route: "/x", status: 404 }, + }), + ]); + expect(top[0]).toEqual({ + action: "measurement.batch.ingest", + level: "error", + count: 2, + lastSeen: "2026-10-15T09:05:00.000Z", + errorClass: "Error", + }); + expect(top[1]).toMatchObject({ + action: "http GET 404", + errorClass: "HTTP 404", + }); + expect(JSON.stringify(top)).not.toContain(ACCOUNT_EMAIL); + expect(JSON.stringify(top)).not.toContain("hlk_secret"); + expect(JSON.stringify(top)).not.toContain(SEEDS.ids); + }); +}); diff --git a/src/lib/support/redact.ts b/src/lib/support/redact.ts new file mode 100644 index 000000000..28eb95bd5 --- /dev/null +++ b/src/lib/support/redact.ts @@ -0,0 +1,164 @@ +/** + * Redaction for the support report. + * + * The report is meant to be pasted into a public issue. It is built from + * counts, states and closed codes, so in the ordinary case there is nothing + * here to remove. These steps are the second layer: the free-text fields the + * report does carry (an error class, a delivery reason, an action name) come + * from places a future change could fill with something it should not, and + * the whole rendered text runs through every step before it leaves. + * + * The steps are an ordered list rather than one function so the guard test + * can drop each one in turn and prove it is load-bearing: with any single + * step removed, a seeded value of its class reaches the output. + * + * Never an email address, an account id, a token, a host name, an address or + * a value an account wrote. + */ +import { redactSecrets } from "@/lib/logging/redact"; + +/** Values that belong to this instance's accounts and must never appear. */ +export interface SupportRedactionContext { + /** Email addresses, usernames, display names, account ids. */ + knownValues: readonly string[]; +} + +export interface SupportRedactionStep { + name: string; + apply: (text: string, context: SupportRedactionContext) => string; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Shortest known value replaced. Shorter usernames (`ann`, `max`) are left to + * the structural rules: the report prints no username anywhere, and a + * three-letter match would rewrite ordinary words of the report itself. + */ +const KNOWN_VALUE_MIN_LENGTH = 5; + +/** Top-level domains a host name in a self-hosted setup ends in. */ +const HOST_TLDS = [ + "com", + "net", + "org", + "io", + "dev", + "app", + "cloud", + "info", + "me", + "de", + "at", + "ch", + "eu", + "uk", + "fr", + "es", + "it", + "pl", + "nl", + "kr", + "us", + "local", + "lan", + "home", + "internal", + "arpa", + "localdomain", + "ts", +].join("|"); + +export const SUPPORT_REDACTION_STEPS: readonly SupportRedactionStep[] = [ + { + // Exact values of this instance's accounts, wherever they turn up. + name: "known-values", + apply: (text, context) => { + let out = text; + const values = [...new Set(context.knownValues)] + .map((v) => v.trim()) + .filter((v) => v.length >= KNOWN_VALUE_MIN_LENGTH) + // Longest first, so an email is replaced before the username in it. + .sort((a, b) => b.length - a.length); + for (const value of values) { + out = out.replace(new RegExp(escapeRegExp(value), "gi"), "[account]"); + } + return out; + }, + }, + { + // Bearer tokens, API keys, path and query secrets: the shared scrubber. + name: "secrets", + apply: (text) => redactSecrets(text), + }, + { + name: "urls", + apply: (text) => + text.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s)>\]]+/gi, "[url]"), + }, + { + name: "emails", + apply: (text) => + text.replace( + /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, + "[email]", + ), + }, + { + // UUIDs and cuid-shaped ids: account, record and request ids. + name: "ids", + apply: (text) => + text + .replace( + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, + "[id]", + ) + .replace(/\bc[a-z0-9]{20,32}\b/g, "[id]"), + }, + { + name: "ip-addresses", + apply: (text) => + text + .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]") + // Four groups or more, so a clock time (`10:00:00`) is not one. + .replace(/\b(?:[0-9a-f]{1,4}:){3,7}[0-9a-f]{1,4}\b/gi, "[ip]") + .replace(/(?:\b[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:?)+/gi, "[ip]"), + }, + { + name: "host-names", + apply: (text) => + text.replace( + new RegExp( + `\\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+(?:${HOST_TLDS})\\b`, + "gi", + ), + "[host]", + ), + }, +]; + +/** Run every step (or the given list) over the text, in order. */ +export function redactSupportText( + text: string, + context: SupportRedactionContext, + steps: readonly SupportRedactionStep[] = SUPPORT_REDACTION_STEPS, +): string { + return steps.reduce((acc, step) => step.apply(acc, context), text); +} + +/** + * One line of free text, made safe to stand in the report: control + * characters out, whitespace folded, cut to `max`. The content itself is + * left to the redaction steps, which see the whole report. + */ +export function supportLine(value: unknown, max = 80): string { + if (typeof value !== "string" && typeof value !== "number") return ""; + const folded = String(value) + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001f\u007f]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return folded.length > max ? `${folded.slice(0, max - 1)}…` : folded; +} diff --git a/src/lib/support/report.ts b/src/lib/support/report.ts new file mode 100644 index 000000000..8dae07ce3 --- /dev/null +++ b/src/lib/support/report.ts @@ -0,0 +1,875 @@ +/** + * The operator's support report: a redacted plain-text summary of what this + * instance looks like, for pasting into a bug report. + * + * Three stages, kept apart so each can be tested on its own: + * + * 1. `collectSupportReportFacts()` reads the database, the queue and this + * process. Accounts leave it as numbers ("Account 1..n", oldest first); + * no account id, username or email is part of the facts. + * 2. `renderSupportReport()` turns the facts into text. Pure. + * 3. `redactSupportText()` runs every redaction step over that text, with + * the instance's own emails, usernames, names and ids as known values. + * + * What it never contains: an email address, an account id, a value anyone + * recorded, a token, the host name of a foreign service. Configuration is yes + * or no, never the setting itself. Errors appear as an action name and an + * error class, never as their message. + * + * The report is English on purpose: it is read by whoever answers the issue. + */ +import { getHeapStatistics } from "node:v8"; + +import { prisma } from "@/lib/db"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc"; +import { getLoggingConfig } from "@/lib/logging/config"; +import { + readLogBuffer, + PROBLEMS_BUFFER_MAX, +} from "@/lib/logging/in-memory-buffer"; +import type { WideEvent } from "@/lib/logging/types"; +import { + offhostBackupConfigured, + OFFHOST_BACKUP_QUEUE, +} from "@/lib/jobs/offhost-backup"; +import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy"; +import { RESTORE_DRILL_QUEUE } from "@/lib/jobs/restore-drill"; +import { + JOB_FAILURE_WINDOW_HOURS, + readFailingQueues, + readLastQueueRun, + type LastQueueRun, +} from "@/lib/jobs/job-failures"; +import { getWorkerStatus } from "@/lib/jobs/worker-status"; +import { getProcessType, shouldRunWorker } from "@/lib/process-type"; +import { offlineGeoReady } from "@/lib/geo"; +import { configuredNotificationPrivateOrigins } from "@/lib/notifications/egress-policy"; +import { isEmailConfigured } from "@/lib/notifications/senders/email-config"; +import { loadConnectionHealth } from "@/lib/integrations/connection-health-load"; +import { CONNECTION_KEYS } from "@/lib/integrations/connection-health"; +import { + redactSupportText, + supportLine, + type SupportRedactionContext, +} from "./redact"; +import packageJson from "../../../package.json"; + +/** Accounts whose connections the report reads; the rest are counted only. */ +export const SUPPORT_REPORT_ACCOUNT_CAP = 200; +/** How far back the notification tallies reach. */ +export const SUPPORT_REPORT_NOTIFICATION_DAYS = 7; +/** How many error actions the report lists. */ +export const SUPPORT_REPORT_TOP_ERRORS = 20; + +// ─── Facts ─────────────────────────────────────────────────────────────── + +export interface QueueRunFact { + at: string; + state: LastQueueRun["state"]; + errorClass: string | null; +} + +export interface SupportReportFacts { + generatedAt: string; + build: { + version: string; + buildSha: string | null; + builtAt: string | null; + node: string; + processType: string; + heapLimitMb: number; + uptimeSeconds: number; + }; + database: { + version: string | null; + timezone: string | null; + sizeBytes: number | null; + lastMigration: string | null; + lastMigrationAt: string | null; + unfinishedMigrations: number | null; + }; + /** Label and a yes / no / count answer. Never the setting itself. */ + config: Array<[string, string]>; + jobs: { + workerInThisProcess: boolean; + workerRunning: boolean; + workerLastHeartbeat: string | null; + newestFinishedJobAt: string | null; + /** `null` when the queue tables could not be read. */ + failing: Array<{ + queue: string; + failures: number; + lastFailedAt: string; + errorClass: string; + }> | null; + }; + accounts: { total: number; read: number }; + connections: Array<{ + provider: string; + state: string; + reason: string; + accounts: number[]; + }>; + incidents: Array<{ + provider: string; + reason: string; + openedAt: string | null; + announced: boolean; + account: number | null; + }>; + ai: { + defaultLevel: string | null; + maxLevel: string | null; + /** Level (or `instance default`) to number of accounts. */ + levels: Array<[string, number]>; + providers: Array<{ + provider: string; + ok: number; + failing: number; + authFailed: number; + paused: number; + lastFailureAt: string | null; + lastStatus: number | null; + }>; + }; + notifications: Array<{ + channel: string; + ok: number; + error: number; + skipped: number; + topReasons: Array<[string, number]>; + }>; + backups: { + newestLocalAt: string | null; + localCopies: number; + lastLocalRun: QueueRunFact | null; + offhostConfigured: boolean; + offhostNewestAt: string | null; + offhostOldestAt: string | null; + offhostAccounts: number; + offhostNeverStored: number; + lastOffhostRun: QueueRunFact | null; + lastRestoreDrill: QueueRunFact | null; + }; + errors: { + ringSize: number; + ringMax: number; + top: Array<{ + action: string; + level: string; + count: number; + lastSeen: string; + errorClass: string; + }>; + }; +} + +// ─── Error classes ─────────────────────────────────────────────────────── + +/** + * A short class for a failure text: never the message. Timeouts and lost + * heartbeats by name, otherwise the thrown class (`TypeError`), a Prisma code + * (`P2002`), a system code (`ECONNREFUSED`) or an HTTP status, whichever the + * text names; plain `Error` when it names none. + */ +export function errorClassOf(text: string | null | undefined): string { + if (!text) return "unknown"; + const trimmed = text.trim(); + if (/^(job timed out|handler execution exceeded)/i.test(trimmed)) { + return "timeout"; + } + if (/^job heartbeat timeout/i.test(trimmed)) return "heartbeat_timeout"; + const parts: string[] = []; + const named = /^([A-Z][A-Za-z0-9]{1,60}(?:Error|Exception))\b/.exec(trimmed); + if (named) parts.push(named[1]); + const code = + /\b(P\d{4})\b/.exec(trimmed) ?? + /\b(E[A-Z][A-Z_]{2,30})\b/.exec(trimmed) ?? + /\b(?:HTTP|status)\s*:?\s*([1-5]\d{2})\b/i.exec(trimmed); + if (code) parts.push(code[1]); + return parts.length > 0 ? parts.join(" ") : "Error"; +} + +/** The class of a wide event's failure: thrown class and code, else its level. */ +export function eventErrorClass(event: WideEvent): string { + if (event.error) { + const type = supportLine(event.error.type, 60) || "Error"; + const code = + event.error.code !== undefined && event.error.code !== null + ? supportLine(String(event.error.code), 40) + : ""; + return code ? `${type} ${code}` : type; + } + const status = event.http?.status; + return typeof status === "number" && status >= 400 ? `HTTP ${status}` : "-"; +} + +/** The name an event is grouped under: its action, else its kind and status. */ +function eventActionName(event: WideEvent): string { + const name = supportLine(event.action?.name, 80); + if (name) return name; + if (event.kind === "background") { + return supportLine(event.background?.task_name, 80) || "background"; + } + const status = event.http?.status; + return `http ${event.http?.method ?? ""} ${status ?? ""}` + .replace(/\s+/g, " ") + .trim(); +} + +/** The most frequent warn/error actions in the problems ring. */ +export function summariseProblemEvents( + events: readonly WideEvent[], + limit: number = SUPPORT_REPORT_TOP_ERRORS, +): SupportReportFacts["errors"]["top"] { + const groups = new Map< + string, + { + action: string; + level: string; + count: number; + lastSeen: string; + errorClass: string; + } + >(); + for (const event of events) { + const action = eventActionName(event); + const key = `${event.level}\u0000${action}`; + const existing = groups.get(key); + if (!existing) { + groups.set(key, { + action, + level: event.level, + count: 1, + lastSeen: event.timestamp, + errorClass: eventErrorClass(event), + }); + continue; + } + existing.count += 1; + if (event.timestamp > existing.lastSeen) { + existing.lastSeen = event.timestamp; + existing.errorClass = eventErrorClass(event); + } + } + return [...groups.values()] + .sort((a, b) => b.count - a.count || b.lastSeen.localeCompare(a.lastSeen)) + .slice(0, limit); +} + +// ─── Collect ───────────────────────────────────────────────────────────── + +function iso(value: Date | null | undefined): string | null { + return value ? value.toISOString() : null; +} + +function runFact(run: LastQueueRun | null): QueueRunFact | null { + if (!run) return null; + return { + at: run.at, + state: run.state, + errorClass: run.error ? errorClassOf(run.error) : null, + }; +} + +function yesNo(value: boolean): string { + return value ? "yes" : "no"; +} + +function envSet(name: string): boolean { + return (process.env[name] ?? "").trim().length > 0; +} + +async function readDatabaseFacts(): Promise { + const out: SupportReportFacts["database"] = { + version: null, + timezone: null, + sizeBytes: null, + lastMigration: null, + lastMigrationAt: null, + unfinishedMigrations: null, + }; + try { + const [row] = await prisma.$queryRaw< + Array<{ version: string; timezone: string; size: bigint }> + >` + SELECT current_setting('server_version') AS version, + current_setting('TimeZone') AS timezone, + pg_database_size(current_database()) AS size + `; + if (row) { + out.version = row.version; + out.timezone = row.timezone; + out.sizeBytes = Number(row.size); + } + } catch { + /* reported as unknown */ + } + try { + const [last] = await prisma.$queryRaw< + Array<{ migration_name: string; finished_at: Date | null }> + >` + SELECT migration_name, finished_at + FROM _prisma_migrations + WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL + ORDER BY finished_at DESC, migration_name DESC + LIMIT 1 + `; + const [unfinished] = await prisma.$queryRaw>` + SELECT count(*)::int AS count + FROM _prisma_migrations + WHERE finished_at IS NULL AND rolled_back_at IS NULL + `; + out.lastMigration = last?.migration_name ?? null; + out.lastMigrationAt = iso(last?.finished_at); + out.unfinishedMigrations = unfinished?.count ?? null; + } catch { + /* no migrations table: reported as unknown */ + } + return out; +} + +async function readNewestFinishedJobAt(): Promise { + try { + const [row] = await prisma.$queryRaw>` + SELECT max(completed_on) AS at FROM pgboss.job WHERE state = 'completed' + `; + return iso(row?.at); + } catch { + return null; + } +} + +/** Read everything the report shows. Accounts leave here as numbers only. */ +export async function collectSupportReportFacts( + now: Date = new Date(), +): Promise<{ facts: SupportReportFacts; context: SupportRedactionContext }> { + const users = await prisma.user.findMany({ + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { + id: true, + username: true, + email: true, + fullName: true, + aiUsageLevel: true, + }, + }); + const accountOf = new Map(); + users.forEach((u, i) => accountOf.set(u.id, i + 1)); + const knownValues: string[] = []; + for (const u of users) { + knownValues.push(u.id, u.username); + if (u.email) knownValues.push(u.email); + if (u.fullName) knownValues.push(u.fullName); + } + + const appSettings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { + glitchtipEnabled: true, + glitchtipDsn: true, + webPushVapidPublicKey: true, + webPushVapidPrivateKeyEncrypted: true, + adminAiKeyEncrypted: true, + adminCodexConnectionStatus: true, + aiUsageDefaultLevel: true, + aiUsageMaxLevel: true, + }, + }); + + const providerTypes = await prisma.user.groupBy({ + by: ["aiProvider"], + _count: { _all: true }, + }); + + const config: Array<[string, string]> = [ + ["Secure cookie flag", yesNo(shouldEmitSecureCookie())], + ["OIDC sign-in", yesNo(isOidcConfigured())], + ["OIDC only", yesNo(isOidcOnly())], + ["Loki", yesNo(Boolean(getLoggingConfig().lokiEndpoint))], + [ + "GlitchTip", + yesNo(Boolean(appSettings?.glitchtipEnabled && appSettings.glitchtipDsn)), + ], + ["Off-host backup", yesNo(offhostBackupConfigured())], + [ + "APNs", + yesNo( + envSet("APNS_KEY_ID") && + envSet("APNS_TEAM_ID") && + envSet("APNS_BUNDLE_ID") && + (envSet("APNS_KEY_B64") || + envSet("APNS_KEY") || + envSet("APNS_KEY_FILE")), + ), + ], + [ + "Web Push (VAPID)", + yesNo( + Boolean( + appSettings?.webPushVapidPublicKey && + appSettings.webPushVapidPrivateKeyEncrypted, + ) || + (envSet("VAPID_PUBLIC_KEY") && envSet("VAPID_PRIVATE_KEY")), + ), + ], + ["SMTP", yesNo(isEmailConfigured())], + ["GeoLite2 databases", yesNo(offlineGeoReady())], + [ + "NOTIFICATION_PRIVATE_ORIGINS entries", + String(configuredNotificationPrivateOrigins().size), + ], + ["Operator AI key", yesNo(Boolean(appSettings?.adminAiKeyEncrypted))], + [ + "Central Codex connected", + yesNo(appSettings?.adminCodexConnectionStatus === "connected"), + ], + [ + "AI provider types chosen by accounts", + providerTypes + .filter((row) => row.aiProvider) + .map((row) => `${supportLine(row.aiProvider, 30)} ${row._count._all}`) + .sort() + .join(", ") || "none", + ], + ["Demo mode", yesNo(process.env.DEMO_MODE === "true")], + ]; + + const workerStatus = getWorkerStatus(); + const [database, failing, newestFinishedJobAt] = await Promise.all([ + readDatabaseFacts(), + readFailingQueues(JOB_FAILURE_WINDOW_HOURS), + readNewestFinishedJobAt(), + ]); + + // Connections: one health read per account, capped. + const readUsers = users.slice(0, SUPPORT_REPORT_ACCOUNT_CAP); + const connectionGroups = new Map< + string, + { provider: string; state: string; reason: string; accounts: number[] } + >(); + for (const user of readUsers) { + let health; + try { + health = await loadConnectionHealth(user.id); + } catch { + continue; + } + for (const key of CONNECTION_KEYS) { + const h = health[key]; + if (!h) continue; + const groupKey = `${key}|${h.state}|${h.reason}`; + const group = connectionGroups.get(groupKey) ?? { + provider: key, + state: h.state, + reason: h.reason, + accounts: [], + }; + group.accounts.push(accountOf.get(user.id) ?? 0); + connectionGroups.set(groupKey, group); + } + } + + const incidentRows = await prisma.integrationWatch.findMany({ + where: { incidentReason: { not: null } }, + select: { + userId: true, + integration: true, + incidentReason: true, + incidentOpenedAt: true, + alertedAt: true, + }, + orderBy: { incidentOpenedAt: "desc" }, + take: 100, + }); + + const providerRows = await prisma.providerHealth.findMany({ + select: { + providerType: true, + lastResult: true, + lastStatus: true, + lastFailureAt: true, + nextRetryAt: true, + }, + }); + const providers = new Map< + string, + SupportReportFacts["ai"]["providers"][number] + >(); + for (const row of providerRows) { + const p = providers.get(row.providerType) ?? { + provider: row.providerType, + ok: 0, + failing: 0, + authFailed: 0, + paused: 0, + lastFailureAt: null, + lastStatus: null, + }; + if (row.lastResult === "ok") p.ok += 1; + else if (row.lastResult === "auth_failed") p.authFailed += 1; + else p.failing += 1; + if (row.nextRetryAt && row.nextRetryAt.getTime() > now.getTime()) { + p.paused += 1; + } + const failedAt = iso(row.lastFailureAt); + if (failedAt && (!p.lastFailureAt || failedAt > p.lastFailureAt)) { + p.lastFailureAt = failedAt; + p.lastStatus = row.lastStatus; + } + providers.set(row.providerType, p); + } + + const levelCounts = new Map(); + for (const u of users) { + const level = u.aiUsageLevel ?? "instance default"; + levelCounts.set(level, (levelCounts.get(level) ?? 0) + 1); + } + + const since = new Date( + now.getTime() - SUPPORT_REPORT_NOTIFICATION_DAYS * 24 * 60 * 60 * 1000, + ); + const [attemptCounts, reasonCounts] = await Promise.all([ + prisma.pushAttempt.groupBy({ + by: ["channel", "result"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + prisma.pushAttempt.groupBy({ + by: ["channel", "reason"], + where: { createdAt: { gte: since }, result: { not: "ok" } }, + _count: { _all: true }, + }), + ]); + const channels = new Map< + string, + SupportReportFacts["notifications"][number] + >(); + const channelOf = (channel: string) => { + const c = channels.get(channel) ?? { + channel, + ok: 0, + error: 0, + skipped: 0, + topReasons: [], + }; + channels.set(channel, c); + return c; + }; + for (const row of attemptCounts) { + const c = channelOf(row.channel); + if (row.result === "ok") c.ok += row._count._all; + else if (row.result === "error") c.error += row._count._all; + else c.skipped += row._count._all; + } + for (const row of reasonCounts) { + channelOf(row.channel).topReasons.push([ + supportLine(row.reason ?? "unspecified", 60), + row._count._all, + ]); + } + for (const c of channels.values()) { + c.topReasons = c.topReasons.sort((a, b) => b[1] - a[1]).slice(0, 3); + } + + const [ + localAggregate, + localCopies, + offhostRows, + lastLocalRun, + lastOffhostRun, + lastDrill, + ] = await Promise.all([ + prisma.dataBackup.aggregate({ _max: { createdAt: true } }), + prisma.dataBackup.count(), + prisma.offhostBackupState.findMany({ select: { lastSuccessAt: true } }), + readLastQueueRun(DATA_BACKUP_QUEUE), + readLastQueueRun(OFFHOST_BACKUP_QUEUE), + readLastQueueRun(RESTORE_DRILL_QUEUE), + ]); + const offhostDates = offhostRows + .map((r) => r.lastSuccessAt) + .filter((d): d is Date => d !== null) + .map((d) => d.toISOString()) + .sort(); + + const problemEvents = readLogBuffer({ ring: "problems" }); + + const facts: SupportReportFacts = { + generatedAt: now.toISOString(), + build: { + version: + process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version, + buildSha: process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null, + builtAt: process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null, + node: process.version, + processType: getProcessType(), + heapLimitMb: Math.round( + getHeapStatistics().heap_size_limit / 1024 / 1024, + ), + uptimeSeconds: Math.round(process.uptime()), + }, + database, + config, + jobs: { + workerInThisProcess: shouldRunWorker(), + workerRunning: workerStatus.running, + workerLastHeartbeat: workerStatus.lastHeartbeat, + newestFinishedJobAt, + failing: + failing === null + ? null + : failing.map((q) => ({ + queue: q.queue, + failures: q.failures, + lastFailedAt: q.lastFailedAt, + errorClass: errorClassOf(q.lastError), + })), + }, + accounts: { total: users.length, read: readUsers.length }, + connections: [...connectionGroups.values()].sort( + (a, b) => + a.provider.localeCompare(b.provider) || + a.state.localeCompare(b.state) || + a.reason.localeCompare(b.reason), + ), + incidents: incidentRows.map((row) => ({ + provider: row.integration, + reason: row.incidentReason ?? "", + openedAt: iso(row.incidentOpenedAt), + announced: row.alertedAt !== null, + account: accountOf.get(row.userId) ?? null, + })), + ai: { + defaultLevel: appSettings?.aiUsageDefaultLevel ?? null, + maxLevel: appSettings?.aiUsageMaxLevel ?? null, + levels: [...levelCounts.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + ), + providers: [...providers.values()].sort((a, b) => + a.provider.localeCompare(b.provider), + ), + }, + notifications: [...channels.values()].sort((a, b) => + a.channel.localeCompare(b.channel), + ), + backups: { + newestLocalAt: iso(localAggregate._max.createdAt), + localCopies, + lastLocalRun: runFact(lastLocalRun), + offhostConfigured: offhostBackupConfigured(), + offhostNewestAt: offhostDates.at(-1) ?? null, + offhostOldestAt: offhostDates[0] ?? null, + offhostAccounts: offhostRows.length, + offhostNeverStored: offhostRows.length - offhostDates.length, + lastOffhostRun: runFact(lastOffhostRun), + lastRestoreDrill: runFact(lastDrill), + }, + errors: { + ringSize: problemEvents.length, + ringMax: PROBLEMS_BUFFER_MAX, + top: summariseProblemEvents(problemEvents), + }, + }; + + return { facts, context: { knownValues } }; +} + +// ─── Render ────────────────────────────────────────────────────────────── + +function or(value: string | number | null | undefined, fallback = "unknown") { + return value === null || value === undefined || value === "" + ? fallback + : String(value); +} + +function megabytes(bytes: number | null): string { + return bytes === null ? "unknown" : `${Math.round(bytes / 1024 / 1024)} MB`; +} + +function runLine(run: QueueRunFact | null): string { + if (!run) return "no run on record"; + return run.errorClass + ? `${run.state} at ${run.at} (${run.errorClass})` + : `${run.state} at ${run.at}`; +} + +function accountList(accounts: readonly number[]): string { + const sorted = [...accounts].sort((a, b) => a - b); + const shown = sorted.slice(0, 10).map((n) => `Account ${n}`); + const more = sorted.length - shown.length; + return more > 0 ? `${shown.join(", ")} and ${more} more` : shown.join(", "); +} + +/** The report as plain text, before redaction. */ +export function renderSupportReport(facts: SupportReportFacts): string { + const lines: string[] = []; + const section = (title: string) => { + if (lines.length > 0) lines.push(""); + lines.push(`## ${title}`); + }; + const row = (label: string, value: string | number | null) => + lines.push(`- ${label}: ${or(value)}`); + + lines.push("# HealthLog support report"); + lines.push(`Generated: ${facts.generatedAt}`); + + section("Build"); + row("Version", facts.build.version); + row("Build SHA", facts.build.buildSha); + row("Built at", facts.build.builtAt); + row("Node", facts.build.node); + row("Process type", facts.build.processType); + row("Heap limit", `${facts.build.heapLimitMb} MB`); + row( + "Uptime", + `${Math.floor(facts.build.uptimeSeconds / 3600)} h ${Math.floor((facts.build.uptimeSeconds % 3600) / 60)} min`, + ); + + section("Database"); + row("PostgreSQL", facts.database.version); + row("Server time zone", facts.database.timezone); + row("Size", megabytes(facts.database.sizeBytes)); + row( + "Last migration", + facts.database.lastMigration + ? `${facts.database.lastMigration} (${or(facts.database.lastMigrationAt)})` + : null, + ); + row("Unfinished migrations", facts.database.unfinishedMigrations); + + section("Configuration"); + for (const [label, value] of facts.config) row(label, value); + + section("Jobs"); + row("Worker in this process", yesNo(facts.jobs.workerInThisProcess)); + if (facts.jobs.workerInThisProcess) { + row("Worker running", yesNo(facts.jobs.workerRunning)); + row("Worker last heartbeat", facts.jobs.workerLastHeartbeat); + } + row("Newest finished job", facts.jobs.newestFinishedJobAt); + if (facts.jobs.failing === null) { + row( + `Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h)`, + "queue tables not readable", + ); + } else if (facts.jobs.failing.length === 0) { + row(`Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h)`, "none"); + } else { + lines.push(`- Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h):`); + for (const q of facts.jobs.failing) { + lines.push( + ` - ${supportLine(q.queue, 60)}: ${q.failures} failed, last ${q.lastFailedAt} (${q.errorClass})`, + ); + } + } + + section("Connections"); + row( + "Accounts", + facts.accounts.read < facts.accounts.total + ? `${facts.accounts.total} (connections read for the oldest ${facts.accounts.read})` + : facts.accounts.total, + ); + if (facts.connections.length === 0) { + row("Connected sources", "none"); + } else { + for (const c of facts.connections) { + const who = c.state === "ok" ? "" : ` (${accountList(c.accounts)})`; + lines.push( + `- ${c.provider}: ${c.state} / ${c.reason}: ${c.accounts.length}${who}`, + ); + } + } + if (facts.incidents.length === 0) { + row("Open watchdog incidents", "none"); + } else { + lines.push("- Open watchdog incidents:"); + for (const i of facts.incidents) { + lines.push( + ` - ${supportLine(i.provider, 40)}: ${supportLine(i.reason, 40)} since ${or(i.openedAt)}, ${i.announced ? "announced" : "not announced"}${i.account ? `, Account ${i.account}` : ""}`, + ); + } + } + + section("AI"); + row("Usage level default", facts.ai.defaultLevel); + row("Usage level ceiling", facts.ai.maxLevel); + row( + "Accounts by usage level", + facts.ai.levels.map(([level, n]) => `${level} ${n}`).join(", ") || "none", + ); + if (facts.ai.providers.length === 0) { + row("Provider ledger", "empty"); + } else { + lines.push("- Provider ledger (accounts):"); + for (const p of facts.ai.providers) { + const last = p.lastFailureAt + ? `, last failure ${p.lastFailureAt}${p.lastStatus ? ` (HTTP ${p.lastStatus})` : ""}` + : ""; + lines.push( + ` - ${supportLine(p.provider, 30)}: ok ${p.ok}, failing ${p.failing}, credential refused ${p.authFailed}, paused ${p.paused}${last}`, + ); + } + } + + section(`Notifications (${SUPPORT_REPORT_NOTIFICATION_DAYS} days)`); + if (facts.notifications.length === 0) { + row("Delivery attempts", "none"); + } else { + for (const c of facts.notifications) { + const reasons = + c.topReasons.length > 0 + ? `; ${c.topReasons.map(([r, n]) => `${r} ${n}`).join(", ")}` + : ""; + lines.push( + `- ${supportLine(c.channel, 20)}: ok ${c.ok}, failed ${c.error}, skipped ${c.skipped}${reasons}`, + ); + } + } + + section("Backups"); + row("Newest in-database copy", facts.backups.newestLocalAt ?? "none"); + row("In-database copies", facts.backups.localCopies); + row("Last weekly run", runLine(facts.backups.lastLocalRun)); + row("Off-host configured", yesNo(facts.backups.offhostConfigured)); + if (facts.backups.offhostAccounts > 0) { + row("Off-host newest", facts.backups.offhostNewestAt ?? "none"); + row( + "Off-host oldest account copy", + facts.backups.offhostOldestAt ?? "none", + ); + row( + "Off-host accounts never stored", + `${facts.backups.offhostNeverStored} of ${facts.backups.offhostAccounts}`, + ); + } + row("Last off-host run", runLine(facts.backups.lastOffhostRun)); + row("Last restore drill", runLine(facts.backups.lastRestoreDrill)); + + section("Recent warnings and errors (this process)"); + row( + "Problems ring", + `${facts.errors.ringSize} of ${facts.errors.ringMax} entries`, + ); + if (facts.errors.top.length === 0) { + row("Top actions", "none"); + } else { + for (const e of facts.errors.top) { + lines.push( + `- ${e.level} ${e.action}: ${e.count}x, last ${e.lastSeen}, ${e.errorClass}`, + ); + } + } + + return `${lines.join("\n")}\n`; +} + +/** Facts to finished, redacted text. */ +export function buildSupportReportText( + facts: SupportReportFacts, + context: SupportRedactionContext, +): string { + return redactSupportText(renderSupportReport(facts), context); +} diff --git a/tests/integration/admin-support-report.test.ts b/tests/integration/admin-support-report.test.ts new file mode 100644 index 000000000..8a05ac7a5 --- /dev/null +++ b/tests/integration/admin-support-report.test.ts @@ -0,0 +1,152 @@ +/** + * `GET /api/admin/support-report` against real Postgres. + * + * - admin only, through the real `apiHandler` and `requireAdmin`; + * - the raw reads (server version, time zone, size, the migrations table, + * the queue) and the group-bys run against the real schema; + * - accounts with distinctive usernames, emails and names appear only as + * "Account n", and their ids never. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { GET } from "@/app/api/admin/support-report/route"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function call() { + return GET(new NextRequest("http://localhost/api/admin/support-report")); +} + +describe("GET /api/admin/support-report (integration)", () => { + it("refuses a signed-in non-admin", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username: "plainmember", email: "plain.member@example.test" }, + }); + await signIn(user.id); + const res = await call(); + expect(res.status).toBe(403); + }); + + it("answers a redacted report built from the real schema", async () => { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: "operatorwithaname", + email: "operator.distinct@example.test", + fullName: "Ottilie Operatorson", + role: "ADMIN", + }, + }); + const member = await prisma.user.create({ + data: { + username: "memberwithaname", + email: "member.distinct@example.test", + fullName: "Mathilda Memberson", + }, + }); + await prisma.providerHealth.create({ + data: { + userId: member.id, + providerType: "LOCAL", + lastResult: "auth_failed", + lastStatus: 401, + consecutiveFailures: 3, + lastFailureAt: new Date(), + nextRetryAt: new Date(Date.now() + 3_600_000), + }, + }); + await prisma.integrationWatch.create({ + data: { + userId: member.id, + integration: "nightscout", + incidentReason: "unexpected_silence", + incidentOpenedAt: new Date(), + }, + }); + await prisma.pushAttempt.create({ + data: { + userId: member.id, + channel: "NTFY", + eventType: "MEDICATION_REMINDER", + result: "error", + reason: "http_500", + }, + }); + await signIn(admin.id); + + const res = await call(); + expect(res.status).toBe(200); + const body = await res.json(); + const text: string = body.data.text; + + expect(text).toMatch(/- PostgreSQL: \d+/); + expect(text).toMatch(/- Server time zone: \S+/); + expect(text).toMatch(/- Size: \d+ MB/); + expect(text).toMatch(/- Last migration: \d{4}_\w+ \(/); + expect(text).toContain("- Unfinished migrations: 0"); + expect(text).toContain( + "- LOCAL: ok 0, failing 0, credential refused 1, paused 1", + ); + expect(text).toContain("nightscout: unexpected_silence since"); + expect(text).toContain("Account 2"); + expect(text).toContain("- NTFY: ok 0, failed 1, skipped 0; http_500 1"); + expect(text).toContain("- Accounts: 2"); + + for (const value of [ + admin.id, + member.id, + admin.username, + member.username, + admin.email!, + member.email!, + "Ottilie", + "Mathilda", + ]) { + expect(text.toLowerCase(), value).not.toContain(value.toLowerCase()); + } + }); +}); From 800bebd45cbbe08c6d817b3e4c00322f3b285b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:18:29 +0200 Subject: [PATCH 015/127] chore(support): drop an unused lint directive --- src/lib/support/redact.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/support/redact.ts b/src/lib/support/redact.ts index 28eb95bd5..78b7b7c63 100644 --- a/src/lib/support/redact.ts +++ b/src/lib/support/redact.ts @@ -156,7 +156,6 @@ export function redactSupportText( export function supportLine(value: unknown, max = 80): string { if (typeof value !== "string" && typeof value !== "number") return ""; const folded = String(value) - // eslint-disable-next-line no-control-regex .replace(/[\u0000-\u001f\u007f]+/g, " ") .replace(/\s+/g, " ") .trim(); From a3ce6af9a368117a19e3d116499ff51d2dee99ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:22:29 +0200 Subject: [PATCH 016/127] feat(labs): resolve marker names through aliases and catalog shorthand A printed name now resolves to one of the account's markers by its own name, then by an alias of the account, then by a catalog name for the same analyte when the reading's unit fits. The catalog gains seeds for alkaline phosphatase and total bilirubin and a checked list of common lab shorthand in seven languages; names that mean two analytes stay out. --- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/ko.json | 8 +- messages/pl.json | 8 +- .../labs/__tests__/biomarker-resolve.test.ts | 286 ++++++++++ .../resolve-or-mint-biomarker.test.ts | 131 +++-- src/lib/labs/analyte-match.ts | 130 ++++- src/lib/labs/biomarker-aliases.ts | 172 ++++++ src/lib/labs/biomarker-catalog.ts | 507 +++++++++++++++++- src/lib/labs/biomarker-resolve.ts | 379 +++++++++++++ src/lib/labs/biomarker-store.ts | 88 +-- 14 files changed, 1648 insertions(+), 101 deletions(-) create mode 100644 src/lib/labs/__tests__/biomarker-resolve.test.ts create mode 100644 src/lib/labs/biomarker-aliases.ts create mode 100644 src/lib/labs/biomarker-resolve.ts diff --git a/messages/de.json b/messages/de.json index 2abf8465c..fc3592090 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9604,12 +9604,16 @@ "apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht, besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.", "lp-a": "Lipoprotein(a) ist ein LDL-ähnliches Partikel mit einem zusätzlich angehefteten Protein, dessen Spiegel weitgehend genetisch festgelegt ist und ein Leben lang recht stabil bleibt. Ein erhöhtes Lp(a) ist ein unabhängiger, vererbter Risikofaktor für Herz-Kreislauf-Erkrankungen und eine Verengung der Aortenklappe. Da es sich kaum verändert, wird es meist einmal bestimmt, um das lebenslange Risiko einer Person festzulegen, statt es wiederholt zu verfolgen. Es wird in molaren Einheiten angegeben und als Teil des gesamten kardiovaskulären Risikos gelesen.", "fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins (des Hormons, das Glukose aus dem Blut in die Zellen schleust), gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.", - "omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA (den beiden langkettigen marinen Omega-3-Fettsäuren) an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert." + "omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA (den beiden langkettigen marinen Omega-3-Fettsäuren) an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert.", + "alkaline-phosphatase": "Die alkalische Phosphatase (AP) ist ein Enzym, das vor allem in Leber, Gallenwegen und Knochen vorkommt. Sie steigt, wenn der Gallenfluss gestört ist, und in Phasen aktiven Knochenaufbaus, etwa im Wachstum von Kindern und Jugendlichen oder bei der Heilung eines Bruchs. Weil beide Organe sie abgeben, wird ein erhöhter Wert meist zusammen mit der GGT und den übrigen Leberwerten gelesen, um eine Ursache in der Leber von einer im Knochen zu unterscheiden. Sie gehört zu den üblichen Leberwerten.", + "bilirubin-total": "Das Gesamtbilirubin ist das gelbe Abbauprodukt des Hämoglobins aus gealterten roten Blutkörperchen, gemessen als Summe seiner unkonjugierten und konjugierten Form. Die Leber nimmt es auf, bindet es und scheidet es mit der Galle aus, daher zeigt der Wert, wie gut dieser Weg funktioniert. Er kann bei Lebererkrankungen, verschlossenen Gallenwegen oder vermehrtem Abbau roter Blutkörperchen steigen, harmlos auch beim Gilbert-Syndrom. Er wird zusammen mit den übrigen Leberwerten gelesen." }, "apob": "Apolipoprotein B (ApoB)", "lp-a": "Lipoprotein(a)", "fasting-insulin": "Nüchtern-Insulin", - "omega-3-index": "Omega-3-Index" + "omega-3-index": "Omega-3-Index", + "alkaline-phosphatase": "Alkalische Phosphatase (AP)", + "bilirubin-total": "Bilirubin gesamt" }, "noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.", "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt", diff --git a/messages/en.json b/messages/en.json index 46880af6d..98a57afc3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9604,12 +9604,16 @@ "apob": "Apolipoprotein B is the structural protein carried by each of the atherogenic lipoprotein particles, so measuring it counts those particles directly rather than the cholesterol they carry. Because one ApoB molecule sits on each LDL, VLDL, and related particle, it can capture cardiovascular risk that an LDL-cholesterol value misses, particularly when particle number and cholesterol content diverge. For this reason it is increasingly used to refine lipid-based risk assessment. It is interpreted alongside the standard lipid panel.", "lp-a": "Lipoprotein(a) is an LDL-like particle with an extra attached protein, and its level is largely set by genetics and stays fairly stable through life. Elevated Lp(a) is an independent, inherited risk factor for cardiovascular disease and aortic-valve narrowing. Because it changes little, it is usually measured once to establish a person's lifelong risk rather than tracked repeatedly. It is reported in molar units and read as part of overall cardiovascular risk.", "fasting-insulin": "Fasting insulin is the level of insulin (the hormone that moves glucose from the blood into cells) measured after an overnight fast. Paired with fasting glucose, it helps estimate insulin resistance, the state in which the body needs more insulin to keep glucose in range. A high fasting insulin alongside normal glucose can be an early sign that this compensation is under way. It is read as part of a metabolic-health picture rather than in isolation.", - "omega-3-index": "The omega-3 index is the share of EPA and DHA (the two long-chain marine omega-3 fatty acids) among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months." + "omega-3-index": "The omega-3 index is the share of EPA and DHA (the two long-chain marine omega-3 fatty acids) among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months.", + "alkaline-phosphatase": "Alkaline phosphatase (ALP) is an enzyme found mainly in the liver, the bile ducts and bone. It rises when bile flow is blocked and during phases of active bone building, such as growth in children and adolescents or bone healing. Because both organs release it, a raised value is usually read together with GGT and the other liver markers to tell a liver source from a bone source. It is one of the standard liver-panel markers.", + "bilirubin-total": "Total bilirubin is the yellow breakdown product of haemoglobin from worn-out red blood cells, measured as the sum of its unconjugated and conjugated forms. The liver takes it up, conjugates it and excretes it in bile, so the level reflects how well this pathway is working. It can rise with liver disease, blocked bile ducts or increased breakdown of red cells, and harmlessly in Gilbert's syndrome. It is read alongside the other liver markers." }, "apob": "Apolipoprotein B (ApoB)", "lp-a": "Lipoprotein(a)", "fasting-insulin": "Fasting insulin", - "omega-3-index": "Omega-3 index" + "omega-3-index": "Omega-3 index", + "alkaline-phosphatase": "Alkaline phosphatase (ALP)", + "bilirubin-total": "Total bilirubin" }, "noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.", "showingLatestOf": "Showing the latest {shown} of {total} readings", diff --git a/messages/es.json b/messages/es.json index 112c33f1d..4f8622fc0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9604,12 +9604,16 @@ "apob": "La apolipoproteína B es la proteína estructural que lleva cada una de las partículas de lipoproteína aterogénicas, de modo que medirla cuenta directamente esas partículas en lugar del colesterol que transportan. Como hay una molécula de ApoB en cada partícula de LDL, VLDL y afines, puede captar un riesgo cardiovascular que un valor de colesterol LDL pasa por alto, sobre todo cuando el número de partículas y su contenido de colesterol divergen. Por eso se usa cada vez más para afinar la evaluación del riesgo basada en los lípidos. Se interpreta junto con el perfil lipídico habitual.", "lp-a": "La lipoproteína(a) es una partícula similar al LDL con una proteína adicional unida, cuyo nivel viene determinado en gran medida por la genética y se mantiene bastante estable a lo largo de la vida. Una Lp(a) elevada es un factor de riesgo independiente y hereditario de enfermedad cardiovascular y de estrechamiento de la válvula aórtica. Como cambia poco, suele medirse una sola vez para establecer el riesgo vital de una persona, en lugar de seguirla de forma repetida. Se expresa en unidades molares y se lee como parte del riesgo cardiovascular global.", "fasting-insulin": "La insulina en ayunas es el nivel de insulina —la hormona que traslada la glucosa de la sangre a las células— medido tras un ayuno nocturno. Junto con la glucosa en ayunas, ayuda a estimar la resistencia a la insulina, el estado en el que el cuerpo necesita más insulina para mantener la glucosa dentro de su rango. Una insulina en ayunas alta con una glucosa normal puede ser un signo temprano de que esa compensación está en marcha. Se lee como parte de un cuadro de salud metabólica y no de forma aislada.", - "omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses." + "omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses.", + "alkaline-phosphatase": "La fosfatasa alcalina es una enzima presente sobre todo en el hígado, las vías biliares y el hueso. Sube cuando se obstruye el flujo de la bilis y en fases de formación ósea activa, como el crecimiento en niños y adolescentes o la consolidación de una fractura. Como la liberan ambos órganos, un valor alto suele leerse junto con la GGT y los demás marcadores hepáticos para distinguir un origen hepático de uno óseo. Es uno de los marcadores habituales del perfil hepático.", + "bilirubin-total": "La bilirrubina total es el producto amarillo de la degradación de la hemoglobina de los glóbulos rojos envejecidos, medido como la suma de sus formas no conjugada y conjugada. El hígado la capta, la conjuga y la elimina por la bilis, de modo que su nivel refleja cómo funciona esa vía. Puede subir con enfermedades del hígado, obstrucción de las vías biliares o mayor destrucción de glóbulos rojos, y de forma inofensiva en el síndrome de Gilbert. Se interpreta junto con los demás marcadores hepáticos." }, "apob": "Apolipoproteína B (ApoB)", "lp-a": "Lipoproteína(a)", "fasting-insulin": "Insulina en ayunas", - "omega-3-index": "Índice omega-3" + "omega-3-index": "Índice omega-3", + "alkaline-phosphatase": "Fosfatasa alcalina", + "bilirubin-total": "Bilirrubina total" }, "noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.", "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}", diff --git a/messages/fr.json b/messages/fr.json index 6177b0427..48b16f632 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9604,12 +9604,16 @@ "apob": "L'apolipoprotéine B est la protéine structurale portée par chacune des particules de lipoprotéines athérogènes ; la doser revient donc à compter directement ces particules plutôt que le cholestérol qu'elles transportent. Comme il y a une molécule d'ApoB sur chaque particule de LDL, de VLDL et apparentée, elle peut capter un risque cardiovasculaire qu'une valeur de cholestérol LDL manque, en particulier lorsque le nombre de particules et leur teneur en cholestérol divergent. C'est pourquoi elle est de plus en plus utilisée pour affiner l'évaluation du risque fondée sur les lipides. Elle s'interprète avec le bilan lipidique habituel.", "lp-a": "La lipoprotéine(a) est une particule semblable au LDL avec une protéine supplémentaire attachée, dont le taux est en grande partie déterminé par la génétique et reste assez stable toute la vie. Une Lp(a) élevée est un facteur de risque indépendant et héréditaire de maladie cardiovasculaire et de rétrécissement de la valve aortique. Comme elle varie peu, on la mesure généralement une seule fois pour établir le risque à vie d'une personne, plutôt que de la suivre de façon répétée. Elle s'exprime en unités molaires et se lit dans le cadre du risque cardiovasculaire global.", "fasting-insulin": "L'insuline à jeun est le taux d'insuline (l'hormone qui fait passer le glucose du sang vers les cellules) mesuré après un jeûne nocturne. Associée à la glycémie à jeun, elle aide à estimer la résistance à l'insuline, l'état dans lequel le corps a besoin de plus d'insuline pour maintenir le glucose dans sa plage. Une insuline à jeun élevée avec une glycémie normale peut être un signe précoce que cette compensation est en cours. Elle se lit dans le cadre d'un bilan de santé métabolique et non isolément.", - "omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA (les deux acides gras oméga-3 marins à longue chaîne) parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois." + "omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA (les deux acides gras oméga-3 marins à longue chaîne) parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois.", + "alkaline-phosphatase": "Les phosphatases alcalines (PAL) sont des enzymes présentes surtout dans le foie, les voies biliaires et l'os. Elles augmentent lorsque l'écoulement de la bile est entravé et pendant les phases de formation osseuse active, comme la croissance chez l'enfant et l'adolescent ou la consolidation d'une fracture. Comme les deux organes les libèrent, une valeur élevée se lit en général avec la GGT et les autres marqueurs hépatiques pour distinguer une origine hépatique d'une origine osseuse. Elles font partie du bilan hépatique habituel.", + "bilirubin-total": "La bilirubine totale est le produit jaune de la dégradation de l'hémoglobine des globules rouges usés, mesuré comme la somme de ses formes non conjuguée et conjuguée. Le foie la capte, la conjugue et l'élimine dans la bile, si bien que son taux reflète le bon fonctionnement de cette voie. Elle peut augmenter en cas de maladie du foie, d'obstruction des voies biliaires ou de destruction accrue des globules rouges, et sans gravité dans le syndrome de Gilbert. Elle se lit avec les autres marqueurs hépatiques." }, "apob": "Apolipoprotéine B (ApoB)", "lp-a": "Lipoprotéine(a)", "fasting-insulin": "Insuline à jeun", - "omega-3-index": "Indice oméga-3" + "omega-3-index": "Indice oméga-3", + "alkaline-phosphatase": "Phosphatases alcalines (PAL)", + "bilirubin-total": "Bilirubine totale" }, "noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.", "showingLatestOf": "Affichage des {shown} derniers résultats sur {total}", diff --git a/messages/it.json b/messages/it.json index 3749f474f..a716cd7d7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9604,12 +9604,16 @@ "apob": "L'apolipoproteina B è la proteina strutturale presente su ciascuna delle particelle di lipoproteine aterogene, quindi misurarla significa contare direttamente quelle particelle anziché il colesterolo che trasportano. Poiché c'è una molecola di ApoB su ogni particella di LDL, VLDL e affini, può cogliere un rischio cardiovascolare che un valore di colesterolo LDL non rileva, soprattutto quando il numero di particelle e il loro contenuto di colesterolo divergono. Per questo è sempre più usata per affinare la valutazione del rischio basata sui lipidi. Si interpreta insieme al consueto profilo lipidico.", "lp-a": "La lipoproteina(a) è una particella simile all'LDL con una proteina aggiuntiva attaccata, il cui livello è in gran parte determinato dalla genetica e resta piuttosto stabile per tutta la vita. Una Lp(a) elevata è un fattore di rischio indipendente ed ereditario di malattia cardiovascolare e di restringimento della valvola aortica. Poiché varia poco, di solito si misura una sola volta per stabilire il rischio di una persona lungo l'arco della vita, anziché seguirla ripetutamente. Si esprime in unità molari e si legge nell'ambito del rischio cardiovascolare complessivo.", "fasting-insulin": "L'insulina a digiuno è il livello di insulina (l'ormone che fa passare il glucosio dal sangue alle cellule) misurato dopo un digiuno notturno. Insieme alla glicemia a digiuno, aiuta a stimare la resistenza all'insulina, lo stato in cui il corpo ha bisogno di più insulina per mantenere il glucosio nel suo intervallo. Un'insulina a digiuno alta con una glicemia normale può essere un segno precoce che questa compensazione è in atto. Si legge nell'ambito di un quadro di salute metabolica e non isolatamente.", - "omega-3-index": "L'indice omega-3 è la quota di EPA e DHA (i due acidi grassi omega-3 marini a catena lunga) tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi." + "omega-3-index": "L'indice omega-3 è la quota di EPA e DHA (i due acidi grassi omega-3 marini a catena lunga) tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi.", + "alkaline-phosphatase": "La fosfatasi alcalina (ALP) è un enzima presente soprattutto nel fegato, nelle vie biliari e nell'osso. Sale quando il flusso della bile è ostacolato e nelle fasi di formazione ossea attiva, come la crescita nei bambini e negli adolescenti o la guarigione di una frattura. Poiché la rilasciano entrambi gli organi, un valore alto si legge di solito insieme alla GGT e agli altri indici epatici per distinguere un'origine epatica da una ossea. È uno degli indici abituali del profilo epatico.", + "bilirubin-total": "La bilirubina totale è il prodotto giallo della degradazione dell'emoglobina dei globuli rossi invecchiati, misurato come somma delle sue forme non coniugata e coniugata. Il fegato la capta, la coniuga e la elimina con la bile, quindi il suo livello riflette il funzionamento di questa via. Può salire con malattie del fegato, ostruzione delle vie biliari o aumentata distruzione dei globuli rossi, e in modo innocuo nella sindrome di Gilbert. Si legge insieme agli altri indici epatici." }, "apob": "Apolipoproteina B (ApoB)", "lp-a": "Lipoproteina(a)", "fasting-insulin": "Insulina a digiuno", - "omega-3-index": "Indice omega-3" + "omega-3-index": "Indice omega-3", + "alkaline-phosphatase": "Fosfatasi alcalina (ALP)", + "bilirubin-total": "Bilirubina totale" }, "noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.", "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}", diff --git a/messages/ko.json b/messages/ko.json index 4d17c9756..ebf33b659 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9604,12 +9604,16 @@ "apob": "아포지단백 B는 죽상경화를 일으키는 지단백 입자마다 하나씩 붙어 있는 구조 단백질이라, 이 값을 재면 입자가 나르는 콜레스테롤이 아니라 입자 수를 직접 세는 셈이에요. LDL과 VLDL, 관련 입자 하나에 ApoB 분자가 하나씩 있기 때문에, 입자 수와 콜레스테롤 양이 어긋날 때 LDL 콜레스테롤이 놓치는 심혈관 위험을 잡아낼 수 있어요. 그래서 지질 기반 위험 평가를 다듬는 데 점점 많이 쓰여요. 기본 지질 검사와 함께 해석해요.", "lp-a": "지단백(a)는 LDL과 비슷한 입자에 단백질이 하나 더 붙은 형태로, 수치는 대체로 유전으로 정해지고 평생 꽤 일정하게 유지돼요. Lp(a)가 높으면 심혈관 질환과 대동맥판막 협착의 독립적인 유전 위험 요인이에요. 거의 변하지 않기 때문에 반복해서 추적하기보다 평생 위험을 파악하려고 보통 한 번만 재요. 몰 단위로 보고되고 전체 심혈관 위험의 일부로 읽어요.", "fasting-insulin": "공복 인슐린은 혈액 속 포도당을 세포로 옮기는 호르몬인 인슐린을 밤새 금식한 뒤 잰 값이에요. 공복혈당과 함께 보면 혈당을 유지하려고 몸이 더 많은 인슐린을 필요로 하는 상태, 즉 인슐린 저항성을 가늠할 수 있어요. 혈당은 정상인데 공복 인슐린이 높다면 이런 보상이 이미 진행 중이라는 이른 신호일 수 있어요. 단독으로 보기보다 대사 건강 전체 그림의 일부로 읽어요.", - "omega-3-index": "오메가3 지수는 적혈구 막의 지방산 가운데 긴사슬 해양 오메가3 지방산인 EPA와 DHA가 차지하는 비율이에요. 적혈구는 천천히 교체되기 때문에 최근 식사가 아니라 장기적인 오메가3 섭취를 반영해요. 지수가 높을수록 관찰연구에서 심혈관 위험이 낮은 것과 연관됐어요. 몇 달에 걸쳐 식사에 반응하는 안정적인 오메가3 상태 지표로 기록해요." + "omega-3-index": "오메가3 지수는 적혈구 막의 지방산 가운데 긴사슬 해양 오메가3 지방산인 EPA와 DHA가 차지하는 비율이에요. 적혈구는 천천히 교체되기 때문에 최근 식사가 아니라 장기적인 오메가3 섭취를 반영해요. 지수가 높을수록 관찰연구에서 심혈관 위험이 낮은 것과 연관됐어요. 몇 달에 걸쳐 식사에 반응하는 안정적인 오메가3 상태 지표로 기록해요.", + "alkaline-phosphatase": "알칼리성 인산분해효소(ALP)는 주로 간, 담관, 뼈에 있는 효소예요. 담즙 흐름이 막히거나, 어린이와 청소년의 성장기나 골절이 아무는 시기처럼 뼈가 활발히 만들어질 때 올라가요. 두 기관 모두에서 나오기 때문에 높은 값은 보통 GGT와 다른 간 지표와 함께 읽어서 간에서 온 것인지 뼈에서 온 것인지 구분해요. 기본 간 검사 지표 중 하나예요.", + "bilirubin-total": "총빌리루빈은 수명이 다한 적혈구의 헤모글로빈이 분해되면서 생기는 노란 물질로, 비포합형과 포합형을 합친 값으로 측정해요. 간이 이를 받아들여 포합한 뒤 담즙으로 내보내기 때문에, 수치는 이 경로가 얼마나 잘 작동하는지를 보여 줘요. 간 질환, 담관 폐쇄, 적혈구 파괴 증가 때 올라갈 수 있고, 길버트 증후군에서는 해롭지 않게 올라가기도 해요. 다른 간 지표와 함께 읽어요." }, "apob": "아포지단백 B (ApoB)", "lp-a": "지단백(a)", "fasting-insulin": "공복 인슐린", - "omega-3-index": "오메가3 지수" + "omega-3-index": "오메가3 지수", + "alkaline-phosphatase": "알칼리성 인산분해효소(ALP)", + "bilirubin-total": "총빌리루빈" }, "noteLoadFailed": "메모를 불러오지 못했어요. 저장해도 메모는 그대로 유지돼요.", "showingLatestOf": "측정값 {total}개 중 최근 {shown}개를 보여주고 있어요", diff --git a/messages/pl.json b/messages/pl.json index 87fa0206b..7a2f70e17 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9604,12 +9604,16 @@ "apob": "Apolipoproteina B to białko strukturalne obecne na każdej z aterogennych cząstek lipoprotein, więc jej pomiar zlicza te cząstki bezpośrednio, a nie cholesterol, który przenoszą. Ponieważ na każdej cząstce LDL, VLDL i pokrewnej znajduje się jedna cząsteczka ApoB, może ona wychwycić ryzyko sercowo-naczyniowe, które umyka wartości cholesterolu LDL, zwłaszcza gdy liczba cząstek i ich zawartość cholesterolu się rozchodzą. Z tego powodu coraz częściej używa się jej do uściślenia oceny ryzyka opartej na lipidach. Interpretuje się ją wraz ze zwykłym profilem lipidowym.", "lp-a": "Lipoproteina(a) to cząstka podobna do LDL z dodatkowo przyłączonym białkiem, której poziom jest w dużej mierze uwarunkowany genetycznie i pozostaje dość stabilny przez całe życie. Podwyższona Lp(a) jest niezależnym, dziedzicznym czynnikiem ryzyka chorób sercowo-naczyniowych i zwężenia zastawki aortalnej. Ponieważ niewiele się zmienia, zwykle oznacza się ją raz, aby ustalić ryzyko danej osoby na całe życie, a nie śledzić wielokrotnie. Podaje się ją w jednostkach molowych i odczytuje w ramach całościowego ryzyka sercowo-naczyniowego.", "fasting-insulin": "Insulina na czczo to poziom insuliny (hormonu, który przenosi glukozę z krwi do komórek) mierzony po nocnym poście. W połączeniu z glukozą na czczo pomaga oszacować insulinooporność, czyli stan, w którym organizm potrzebuje więcej insuliny, aby utrzymać glukozę w zakresie. Wysoka insulina na czczo przy prawidłowej glukozie może być wczesnym znakiem, że ta kompensacja jest w toku. Odczytuje się ją w ramach obrazu zdrowia metabolicznego, a nie w oderwaniu.", - "omega-3-index": "Indeks omega-3 to udział EPA i DHA (dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3) wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy." + "omega-3-index": "Indeks omega-3 to udział EPA i DHA (dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3) wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy.", + "alkaline-phosphatase": "Fosfataza alkaliczna (ALP) to enzym występujący głównie w wątrobie, drogach żółciowych i kościach. Rośnie przy utrudnionym odpływie żółci oraz w okresach aktywnej przebudowy kości, na przykład podczas wzrostu u dzieci i młodzieży albo gojenia złamania. Ponieważ uwalniają ją oba narządy, podwyższony wynik czyta się zwykle razem z GGTP i pozostałymi próbami wątrobowymi, aby odróżnić przyczynę wątrobową od kostnej. Należy do standardowych prób wątrobowych.", + "bilirubin-total": "Bilirubina całkowita to żółty produkt rozpadu hemoglobiny ze zużytych czerwonych krwinek, mierzony jako suma jej postaci niesprzężonej i sprzężonej. Wątroba ją wychwytuje, sprzęga i wydala z żółcią, więc jej poziom pokazuje, jak działa ta droga. Może rosnąć przy chorobach wątroby, zablokowanych drogach żółciowych lub nasilonym rozpadzie czerwonych krwinek, a nieszkodliwie w zespole Gilberta. Odczytuje się ją razem z pozostałymi próbami wątrobowymi." }, "apob": "Apolipoproteina B (ApoB)", "lp-a": "Lipoproteina(a)", "fasting-insulin": "Insulina na czczo", - "omega-3-index": "Indeks omega-3" + "omega-3-index": "Indeks omega-3", + "alkaline-phosphatase": "Fosfataza alkaliczna (ALP)", + "bilirubin-total": "Bilirubina całkowita" }, "noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.", "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników", diff --git a/src/lib/labs/__tests__/biomarker-resolve.test.ts b/src/lib/labs/__tests__/biomarker-resolve.test.ts new file mode 100644 index 000000000..6754c88b6 --- /dev/null +++ b/src/lib/labs/__tests__/biomarker-resolve.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vitest"; + +import { foldForMatch } from "@/lib/i18n/fold-for-match"; + +import { catalogKeyForName, catalogNameIndex } from "../analyte-match"; +import { ANALYTE_ALIASES, LAB_NAME_ALIASES } from "../biomarker-catalog"; +import { + addAliasToIndex, + matchForReview, + nameSimilarity, + resolveInIndex, + suggestInIndex, + type IndexedMarker, + type MarkerIndex, +} from "../biomarker-resolve"; + +let seq = 0; +function marker( + name: string, + unit: string, + extra: Partial = {}, +) { + seq += 1; + return { + id: `bm-${seq}`, + name, + unit, + lowerBound: null, + upperBound: null, + panel: null, + analyteKey: null, + hidden: false, + createdAt: new Date(Date.UTC(2026, 0, seq)), + ...extra, + } satisfies IndexedMarker; +} + +function index( + markers: IndexedMarker[], + aliases: Array<{ biomarkerId: string; name: string }> = [], +): MarkerIndex { + return { + markers, + aliases: aliases.map((a) => ({ ...a, nameFolded: foldForMatch(a.name) })), + }; +} + +describe("the catalog's shorthand (D1223)", () => { + it.each([ + ["Alk.P", "alkaline-phosphatase"], + ["Alk Phos", "alkaline-phosphatase"], + ["ALP", "alkaline-phosphatase"], + ["Alkalische Phosphatase (AP)", "alkaline-phosphatase"], + ["T.Bili", "bilirubin-total"], + ["T. Bili", "bilirubin-total"], + ["TBIL", "bilirubin-total"], + ["Bilirubin gesamt", "bilirubin-total"], + ["GPT", "alt"], + ["ALAT", "alt"], + ["AST(SGOT)", "ast"], + ["GOT", "ast"], + ["γ-GT", "ggt"], + ["Gamma-GT", "ggt"], + ["GGTP", "ggt"], + ["LDH", "ldh"], + ["CRP", "crp"], + ["hsCRP", "hs-crp"], + ["TSH basal", "tsh"], + ["fT3", "ft3"], + ["T4L", "ft4"], + ["HbA1c", "hba1c"], + ["eGFR", "egfr"], + ["Hb", "hemoglobin"], + ["Hkt", "hematocrit"], + ["MCV", "mcv"], + ["PLT", "platelets"], + ["Leuko", "wbc"], + ["Krea", "creatinine"], + ["25(OH)D", "vitamin-d"], + ["Ferr", "ferritin"], + ["B12", "vitamin-b12"], + ])("%s is %s", (name, key) => { + expect(catalogKeyForName(name)).toBe(key); + }); + + it.each([ + ["Bilirubin (Total)", "bilirubin-total"], + ["Kreatinin (Serum)", "creatinine"], + ["GPT (ALT)", "alt"], + ])("%s, with its parenthetical, is %s", (name, key) => { + expect(catalogKeyForName(name)).toBe(key); + }); + + it.each([ + ["PCR"], + ["TG"], + ["T3"], + ["T4"], + ["Cr"], + ["Calcitriol"], + ["LDL-P"], + ["Calcium (ionisiert)"], + ["Glucose (urine)"], + ["GPT (AST)"], + ])("%s is no catalog name (ambiguous or another test)", (name) => { + expect(catalogKeyForName(name)).toBeNull(); + }); + + it("keeps the standard and the high-sensitivity CRP apart", () => { + expect(catalogKeyForName("CRP")).not.toBe(catalogKeyForName("hs-CRP")); + }); + + it("writes every name already folded", () => { + for (const { key, names } of [...ANALYTE_ALIASES, ...LAB_NAME_ALIASES]) { + for (const name of names) { + expect(foldForMatch(name), `${key}: ${name}`).toBe(name); + } + } + }); + + it("has no name that two analytes claim", () => { + expect(catalogNameIndex().ambiguous).toEqual([]); + }); +}); + +describe("resolveInIndex", () => { + it("resolves the marker's own name, case aside", () => { + const ldl = marker("LDL", "mg/dL"); + expect(resolveInIndex(index([ldl]), "ldl")).toEqual({ + marker: ldl, + matchKind: "name", + }); + }); + + it("prefers the exact spelling over a case variant", () => { + const a = marker("ldl", "mg/dL"); + const b = marker("LDL", "mg/dL"); + expect(resolveInIndex(index([a, b]), "LDL")?.marker).toBe(b); + }); + + it("resolves an alias of the account, folded", () => { + const alp = marker("Alkaline Phosphatase", "U/L"); + const found = resolveInIndex( + index([alp], [{ biomarkerId: alp.id, name: "Alk.P" }]), + "ALK.P", + ); + expect(found).toEqual({ marker: alp, matchKind: "alias" }); + }); + + it("resolves a catalog abbreviation to the account's marker of that analyte", () => { + const alp = marker("Alkaline Phosphatase", "U/L"); + expect(resolveInIndex(index([alp]), "Alk.P", "U/L")).toEqual({ + marker: alp, + matchKind: "catalog", + }); + // The discussion's own pair: a marker named with a neutral qualifier. + const bili = marker("Bilirubin (Total)", "mg/dL"); + expect(resolveInIndex(index([bili]), "T.Bili", "mg/dL")).toEqual({ + marker: bili, + matchKind: "catalog", + }); + }); + + it("recognises a marker by one of its aliases' catalog analyte", () => { + const liver = marker("Leberwert 1", "U/L"); + const found = resolveInIndex( + index([liver], [{ biomarkerId: liver.id, name: "ALT" }]), + "GPT", + "U/L", + ); + expect(found).toEqual({ marker: liver, matchKind: "catalog" }); + }); + + it("converts across units in the catalog step where the registry can", () => { + const glucose = marker("Glucose", "mg/dL", { + analyteKey: "fasting-glucose", + }); + expect( + resolveInIndex(index([glucose]), "Blutzucker", "mmol/L")?.marker, + ).toBe(glucose); + }); + + it("steps aside in the catalog step for a unit it cannot reconcile", () => { + const alt = marker("ALT", "U/L"); + expect(resolveInIndex(index([alt]), "GPT", "µkat/L")).toBeNull(); + // A qualitative reading or none stated always fits. + expect(resolveInIndex(index([alt]), "GPT", null)?.marker).toBe(alt); + }); + + it("lets the name and the alias win regardless of the unit", () => { + const alt = marker("ALT", "U/L"); + expect(resolveInIndex(index([alt]), "ALT", "µkat/L")?.matchKind).toBe( + "name", + ); + expect( + resolveInIndex( + index([alt], [{ biomarkerId: alt.id, name: "Leber ALT" }]), + "Leber ALT", + "µkat/L", + )?.matchKind, + ).toBe("alias"); + }); + + it("prefers a visible marker over a hidden one in the catalog step", () => { + const hidden = marker("ALT", "U/L", { hidden: true }); + const visible = marker("GPT (ALT)", "U/L"); + expect( + resolveInIndex(index([hidden, visible]), "SGPT", "U/L")?.marker, + ).toBe(visible); + }); + + it("resolves nothing for an unknown name", () => { + expect(resolveInIndex(index([marker("ALT", "U/L")]), "Zinc")).toBeNull(); + expect(resolveInIndex(index([]), " ")).toBeNull(); + }); + + it("sees an alias added after the index was loaded", () => { + const tsh = marker("TSH", "mIU/L"); + const idx = index([tsh]); + addAliasToIndex(idx, tsh.id, "Thyreotropes Hormon"); + expect(resolveInIndex(idx, "thyreotropes hormon")?.matchKind).toBe("alias"); + }); +}); + +describe("suggestions", () => { + it("offers the same analyte first when its unit kept it from matching", () => { + const alt = marker("ALT", "U/L"); + const { match, suggestions } = matchForReview( + index([alt, marker("AST", "U/L")]), + "GPT", + "µkat/L", + ); + expect(match).toBeNull(); + expect(suggestions[0]).toEqual({ + biomarkerId: alt.id, + name: "ALT", + unit: "U/L", + matchKind: "catalog", + }); + }); + + it("offers similar names through the catalog's names for the marker", () => { + const bili = marker("T.Bili", "mg/dL"); + const suggestions = suggestInIndex(index([bili]), "Bilirubin ges."); + expect(suggestions).toEqual([ + { + biomarkerId: bili.id, + name: "T.Bili", + unit: "mg/dL", + matchKind: "similar", + }, + ]); + }); + + it("offers shorthand against a full name", () => { + const alp = marker("Alkaline Phosphatase", "U/L"); + expect(suggestInIndex(index([alp]), "Alk. Phosph.")[0]?.biomarkerId).toBe( + alp.id, + ); + }); + + it("offers at most three and never a hidden marker", () => { + const markers = [ + marker("Vitamin B1", "µg/L"), + marker("Vitamin B2", "µg/L"), + marker("Vitamin B6", "µg/L"), + marker("Vitamin B9", "µg/L"), + marker("Vitamin B3", "µg/L", { hidden: true }), + ]; + const suggestions = suggestInIndex(index(markers), "Vitamin B5"); + expect(suggestions).toHaveLength(3); + expect(suggestions.map((s) => s.name)).not.toContain("Vitamin B3"); + }); + + it("offers nothing unrelated and nothing when the name matched", () => { + const ldl = marker("LDL", "mg/dL"); + expect(suggestInIndex(index([ldl]), "Ferritin")).toEqual([]); + expect(matchForReview(index([ldl]), "LDL").suggestions).toEqual([]); + }); + + it("scores names", () => { + expect(nameSimilarity("Ferritin", "ferritin")).toBe(1); + expect(nameSimilarity("alk phos", "alkaline phosphatase")).toBe(0.8); + expect(nameSimilarity("LDL", "Ferritin")).toBeLessThan(0.5); + }); +}); diff --git a/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts index d6b1d3719..00bd4a313 100644 --- a/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts +++ b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts @@ -1,20 +1,24 @@ /** - * v1.18.1 — `resolveOrMintBiomarker`: the auto-link-on-write helper. + * `resolveOrMintBiomarker`: the auto-link-on-write helper. * - * Every free-text lab write resolves (or mints) a user-scoped Biomarker by - * `(userId, lower(analyte))` so NO `LabResult` row ever persists unlinked. - * Asserts: case-insensitive reuse, mint-on-miss from the reading's own - * spelling, no silent rewrite of an existing marker, and the concurrent + * Every free-text lab write resolves (or mints) a user-scoped Biomarker so NO + * `LabResult` row ever persists unlinked. Since v1.43 the name resolves + * through the account's markers and aliases (`resolveInIndex`), loaded in one + * read. Asserts: case-insensitive reuse, alias reuse, mint-on-miss from the + * reading's own spelling, no silent rewrite of an existing marker, a minted + * marker found by the next row through a shared index, and the concurrent * unique-violation race adopting the winner. */ import { beforeEach, describe, expect, it, vi } from "vitest"; const db = vi.hoisted(() => ({ - biomarker: { findFirst: vi.fn(), create: vi.fn() }, + biomarker: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() }, + biomarkerAlias: { findMany: vi.fn() }, })); vi.mock("@/lib/db", () => ({ prisma: db })); +import { loadMarkerIndex } from "@/lib/labs/biomarker-resolve"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; const baseInput = { @@ -25,21 +29,32 @@ const baseInput = { panel: "lipids" as string | null, }; +function marker(overrides: Record) { + return { + id: "bm", + name: "Marker", + unit: "mg/dL", + lowerBound: null, + upperBound: null, + panel: null, + analyteKey: null, + hidden: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + ...overrides, + }; +} + beforeEach(() => { vi.clearAllMocks(); + db.biomarker.findMany.mockResolvedValue([]); + db.biomarkerAlias.findMany.mockResolvedValue([]); }); describe("resolveOrMintBiomarker", () => { it("reuses an existing marker (case-insensitive) without minting", async () => { - db.biomarker.findFirst.mockResolvedValue({ - id: "bm_existing", - name: "LDL Cholesterol", - unit: "mg/dL", - lowerBound: null, - upperBound: 100, - panel: "lipids", - analyteKey: null, - }); + db.biomarker.findMany.mockResolvedValue([ + marker({ id: "bm_existing", name: "LDL", upperBound: 100 }), + ]); const out = await resolveOrMintBiomarker("u1", { ...baseInput, @@ -51,23 +66,33 @@ describe("resolveOrMintBiomarker", () => { // values do NOT rewrite it. expect(out.upperBound).toBe(100); expect(db.biomarker.create).not.toHaveBeenCalled(); - // Match is case-insensitive on the catalog identity. - const where = db.biomarker.findFirst.mock.calls[0][0].where; - expect(where.name).toEqual({ equals: "ldl", mode: "insensitive" }); - expect(where.userId).toBe("u1"); + expect(db.biomarker.findMany.mock.calls[0][0].where).toEqual({ + userId: "u1", + }); + }); + + it("reuses the marker an alias of the account names", async () => { + db.biomarker.findMany.mockResolvedValue([ + marker({ id: "bm_alp", name: "Alkaline Phosphatase", unit: "U/L" }), + ]); + db.biomarkerAlias.findMany.mockResolvedValue([ + { biomarkerId: "bm_alp", name: "Alk.P", nameFolded: "alk.p" }, + ]); + + const out = await resolveOrMintBiomarker("u1", { + ...baseInput, + analyte: "ALK.P", + unit: "U/L", + }); + + expect(out.id).toBe("bm_alp"); + expect(db.biomarker.create).not.toHaveBeenCalled(); }); it("mints a new marker from the reading when none exists", async () => { - db.biomarker.findFirst.mockResolvedValue(null); - db.biomarker.create.mockResolvedValue({ - id: "bm_new", - name: "LDL", - unit: "mg/dL", - lowerBound: null, - upperBound: 116, - panel: "lipids", - analyteKey: null, - }); + db.biomarker.create.mockResolvedValue( + marker({ id: "bm_new", name: "LDL", upperBound: 116, panel: "lipids" }), + ); const out = await resolveOrMintBiomarker("u1", baseInput); @@ -84,16 +109,9 @@ describe("resolveOrMintBiomarker", () => { }); it("trims the analyte before minting", async () => { - db.biomarker.findFirst.mockResolvedValue(null); - db.biomarker.create.mockResolvedValue({ - id: "bm_x", - name: "Ferritin", - unit: "ng/mL", - lowerBound: 30, - upperBound: 400, - panel: null, - analyteKey: null, - }); + db.biomarker.create.mockResolvedValue( + marker({ id: "bm_x", name: "Ferritin", unit: "ng/mL" }), + ); await resolveOrMintBiomarker("u1", { analyte: " Ferritin ", @@ -106,17 +124,32 @@ describe("resolveOrMintBiomarker", () => { expect(db.biomarker.create.mock.calls[0][0].data.name).toBe("Ferritin"); }); + it("finds a marker minted for an earlier row through the shared index", async () => { + db.biomarker.create.mockResolvedValue( + marker({ id: "bm_tsh", name: "TSH", unit: "mIU/L" }), + ); + const index = await loadMarkerIndex(db as never, "u1"); + const input = { ...baseInput, analyte: "TSH", unit: "mIU/L" }; + + const first = await resolveOrMintBiomarker("u1", input, { index }); + const second = await resolveOrMintBiomarker( + "u1", + { ...input, analyte: "tsh" }, + { index }, + ); + + expect(first.id).toBe("bm_tsh"); + expect(second.id).toBe("bm_tsh"); + expect(db.biomarker.create).toHaveBeenCalledTimes(1); + expect(db.biomarker.findMany).toHaveBeenCalledTimes(1); + }); + it("adopts the race winner on a concurrent unique violation", async () => { - // First lookup misses; create throws (the other writer won the unique + // The index misses; create throws (the other writer won the unique // index); the re-lookup finds the winner. - db.biomarker.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ - id: "bm_winner", - name: "TSH", - unit: "mIU/L", - lowerBound: 0.4, - upperBound: 4, - panel: "thyroid", - }); + db.biomarker.findFirst.mockResolvedValueOnce( + marker({ id: "bm_winner", name: "TSH", unit: "mIU/L" }), + ); db.biomarker.create.mockRejectedValue(new Error("unique constraint")); const out = await resolveOrMintBiomarker("u1", { @@ -128,7 +161,7 @@ describe("resolveOrMintBiomarker", () => { }); expect(out.id).toBe("bm_winner"); - expect(db.biomarker.findFirst).toHaveBeenCalledTimes(2); + expect(db.biomarker.findFirst).toHaveBeenCalledTimes(1); }); it("throws when the create fails and no race winner exists", async () => { diff --git a/src/lib/labs/analyte-match.ts b/src/lib/labs/analyte-match.ts index 2e7338ad4..02dbf3d4b 100644 --- a/src/lib/labs/analyte-match.ts +++ b/src/lib/labs/analyte-match.ts @@ -22,7 +22,11 @@ import { foldForMatch } from "@/lib/i18n/fold-for-match"; import { localisedValues } from "@/lib/i18n/shared-resolve"; -import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "./biomarker-catalog"; +import { + ANALYTE_ALIASES, + BIOMARKER_CATALOG, + LAB_NAME_ALIASES, +} from "./biomarker-catalog"; import { analyteAcceptsUnit } from "./unit-conversion/convert"; import { analyteConversionFor } from "./unit-conversion/registry"; @@ -77,7 +81,129 @@ export function matchAnalyteKey(input: { unit: string; }): string | null { cache ??= analyteNameIndex().index; - const key = cache.get(foldForMatch(input.name)); + const key = + cache.get(foldForMatch(input.name)) ?? + cache.get(foldWithoutDots(input.name)); if (!key) return null; return analyteAcceptsUnit(key, input.unit) ? key : null; } + +/** + * The fold with every full stop and comma read as a space as well: lab + * shorthand is printed "Alk.P", "T.Bili", "Bilirubin, total", and the dot or + * comma carries no meaning there. Applied to both sides, like the fold itself. + */ +export function foldWithoutDots(raw: string): string { + return foldForMatch(raw).replace(/[.,]/gu, " ").replace(/\s+/gu, " ").trim(); +} + +let catalogCache: ReadonlyMap | null = null; +let catalogNamesCache: ReadonlyMap | null = null; + +/** + * Which catalog analyte a NAME is, for telling two markers of one account + * apart from two spellings of one marker (D1223). Unlike `analyteNameIndex` + * this needs no conversion: "GPT" and "ALT" are one analyte whether or not + * the registry can move it between units, so every seed, every alias in + * `ANALYTE_ALIASES` and every name in `LAB_NAME_ALIASES` claims here, folded + * with and without dots. A name two analytes claim matches neither. + * Exported for the guard test. + */ +export function catalogNameIndex(): { + index: ReadonlyMap; + ambiguous: readonly string[]; +} { + const claims = new Map>(); + const claim = (folded: string, key: string) => { + if (!folded) return; + const keys = claims.get(folded) ?? new Set(); + keys.add(key); + claims.set(folded, keys); + }; + const claimName = (name: string, key: string) => { + claim(foldForMatch(name), key); + claim(foldWithoutDots(name), key); + }; + + for (const seed of BIOMARKER_CATALOG) { + for (const name of localisedValues(`labs.catalog.${seed.slug}`)) { + claimName(name, seed.slug); + claim(withoutGloss(foldForMatch(name)), seed.slug); + } + } + for (const { key, names } of [...ANALYTE_ALIASES, ...LAB_NAME_ALIASES]) { + for (const name of names) claimName(name, key); + } + + const index = new Map(); + const ambiguous: string[] = []; + for (const [folded, keys] of claims) { + if (keys.size === 1) index.set(folded, [...keys][0]); + else ambiguous.push(folded); + } + return { index, ambiguous }; +} + +/** + * Qualifiers that may stand in parentheses after a name without making it a + * different measurement: "Bilirubin (Total)", "Kreatinin (Serum)". Anything + * else in parentheses names something else ("Calcium (ionisiert)", "Glucose + * (urine)") and keeps the name unmatched. + */ +const NEUTRAL_GLOSSES = new Set([ + "total", + "totale", + "totales", + "gesamt", + "serum", + "s", + "plasma", + "blood", + "blut", + "sang", + "sangre", + "sangue", + "krew", +]); + +function lookupWhole(name: string): string | null { + catalogCache ??= catalogNameIndex().index; + return ( + catalogCache.get(foldForMatch(name)) ?? + catalogCache.get(foldWithoutDots(name)) ?? + null + ); +} + +/** + * The catalog analyte a name belongs to, or null. Whole names only, with one + * allowance for a trailing parenthetical: it is read when it names the same + * analyte as the rest ("GPT (ALT)") or is a neutral qualifier ("Bilirubin + * (Total)"), and never otherwise. + */ +export function catalogKeyForName(name: string): string | null { + const whole = lookupWhole(name); + if (whole) return whole; + const glossed = /^(.+?)\s*\(([^()]+)\)\s*$/u.exec(name.trim()); + if (!glossed) return null; + const outer = lookupWhole(glossed[1]); + if (!outer) return null; + const gloss = glossed[2]; + if (NEUTRAL_GLOSSES.has(foldWithoutDots(gloss))) return outer; + return lookupWhole(gloss) === outer ? outer : null; +} + +/** Every folded name the catalog knows for one analyte, for suggestions. */ +export function catalogNamesForKey(key: string): readonly string[] { + if (!catalogNamesCache) { + catalogCache ??= catalogNameIndex().index; + const byKey = new Map(); + for (const [folded, k] of catalogCache) { + const list = byKey.get(k) ?? []; + list.push(folded); + byKey.set(k, list); + } + catalogNamesCache = byKey; + } + return catalogNamesCache.get(key) ?? []; +} diff --git a/src/lib/labs/biomarker-aliases.ts b/src/lib/labs/biomarker-aliases.ts new file mode 100644 index 000000000..fcb88a2b6 --- /dev/null +++ b/src/lib/labs/biomarker-aliases.ts @@ -0,0 +1,172 @@ +/** + * The other spellings of a marker (D1223), stored per account in + * `biomarker_aliases`. + * + * `nameFolded` is `foldForMatch(name)` and unique per account, so one spelling + * resolves to one marker. An alias equal to its own marker's name (folded) is + * never stored: the name already resolves. + * + * Sources: `MERGE` (a merged marker's name), `IMPORT_CHOICE` (a name mapped to + * a marker on a scan or document review), `MANUAL` (typed on the marker). + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; + +export type AliasSource = "MERGE" | "IMPORT_CHOICE" | "MANUAL"; + +type AliasClient = Pick; +type AliasTx = Prisma.TransactionClient; + +/** Longest alias, matching the column. */ +export const BIOMARKER_ALIAS_MAX_LENGTH = 120; + +/** + * The aliases of each marker, sorted, without the marker's own name. One read + * for the whole set, so a list of markers costs one query. + */ +export async function aliasNamesByMarker( + client: Pick, + userId: string, + markers: ReadonlyArray<{ id: string; name: string }>, +): Promise> { + const out = new Map(); + if (markers.length === 0) return out; + const rows = await client.biomarkerAlias.findMany({ + where: { userId, biomarkerId: { in: markers.map((m) => m.id) } }, + select: { biomarkerId: true, name: true, nameFolded: true }, + }); + const ownFold = new Map(markers.map((m) => [m.id, foldForMatch(m.name)])); + for (const row of rows) { + if (row.nameFolded === ownFold.get(row.biomarkerId)) continue; + const list = out.get(row.biomarkerId) ?? []; + list.push(row.name); + out.set(row.biomarkerId, list); + } + for (const list of out.values()) { + list.sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" })); + } + return out; +} + +/** What blocks an alias: another marker's name or another marker's alias. */ +export interface AliasConflict { + alias: string; + biomarkerId: string; +} + +/** + * The first alias in `names` that another marker of the account already + * answers to, by name or alias; null when none does. + */ +export async function findAliasConflict( + client: AliasClient | AliasTx, + userId: string, + biomarkerId: string, + names: readonly string[], +): Promise { + if (names.length === 0) return null; + const folds = new Map(names.map((n) => [foldForMatch(n), n])); + const [markers, aliases] = await Promise.all([ + client.biomarker.findMany({ + where: { userId, id: { not: biomarkerId } }, + select: { id: true, name: true }, + }), + client.biomarkerAlias.findMany({ + where: { + userId, + biomarkerId: { not: biomarkerId }, + nameFolded: { in: [...folds.keys()] }, + }, + select: { biomarkerId: true, nameFolded: true }, + }), + ]); + for (const marker of markers) { + const hit = folds.get(foldForMatch(marker.name)); + if (hit !== undefined) return { alias: hit, biomarkerId: marker.id }; + } + const alias = aliases[0]; + if (alias) { + return { + alias: folds.get(alias.nameFolded) ?? alias.nameFolded, + biomarkerId: alias.biomarkerId, + }; + } + return null; +} + +/** + * Make `names` the marker's aliases: what is listed stays (keeping where it + * came from) or is added as `MANUAL`, what is not listed is removed. The list + * is the whole set the marker answers to, as `aliases` on the marker reads it; + * the caller has already checked it for conflicts. + */ +export async function replaceAliases( + tx: AliasTx, + userId: string, + marker: { id: string; name: string }, + names: readonly string[], +): Promise { + const ownFold = foldForMatch(marker.name); + const wanted = new Map(); + for (const raw of names) { + const name = raw.trim(); + const folded = foldForMatch(name); + if (!folded || folded === ownFold || wanted.has(folded)) continue; + wanted.set(folded, name); + } + const existing = await tx.biomarkerAlias.findMany({ + where: { userId, biomarkerId: marker.id }, + select: { id: true, nameFolded: true }, + }); + const stale = existing + .filter((row) => !wanted.has(row.nameFolded)) + .map((row) => row.id); + if (stale.length > 0) { + await tx.biomarkerAlias.deleteMany({ + where: { userId, id: { in: stale } }, + }); + } + const kept = new Set(existing.map((row) => row.nameFolded)); + const fresh = [...wanted].filter(([folded]) => !kept.has(folded)); + if (fresh.length > 0) { + await tx.biomarkerAlias.createMany({ + data: fresh.map(([nameFolded, name]) => ({ + userId, + biomarkerId: marker.id, + name: name.slice(0, BIOMARKER_ALIAS_MAX_LENGTH), + nameFolded: nameFolded.slice(0, BIOMARKER_ALIAS_MAX_LENGTH), + source: "MANUAL", + })), + }); + } +} + +/** + * Remember a name the person mapped to a marker on a review screen, so the + * next report with the same spelling resolves without asking. A spelling + * already attached to another marker moves to this one: the choice is the + * person's latest word on it. The marker's own name is not stored. + * Returns whether an alias was written. + */ +export async function rememberImportAlias( + client: Pick, + userId: string, + marker: { id: string; name: string }, + printedName: string, +): Promise { + const name = printedName.trim().slice(0, BIOMARKER_ALIAS_MAX_LENGTH); + const nameFolded = foldForMatch(name).slice(0, BIOMARKER_ALIAS_MAX_LENGTH); + if (!nameFolded || nameFolded === foldForMatch(marker.name)) return false; + await client.biomarkerAlias.upsert({ + where: { userId_nameFolded: { userId, nameFolded } }, + create: { + userId, + biomarkerId: marker.id, + name, + nameFolded, + source: "IMPORT_CHOICE", + }, + update: { biomarkerId: marker.id, name, source: "IMPORT_CHOICE" }, + }); + return true; +} diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts index 376a63b6a..1f722f255 100644 --- a/src/lib/labs/biomarker-catalog.ts +++ b/src/lib/labs/biomarker-catalog.ts @@ -217,6 +217,23 @@ export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [ lowerBound: null, upperBound: 55, }, + // v1.43 (D1223) — the two markers a report most often prints in shorthand + // ("Alk.P", "T.Bili"). Adult reference windows per MedlinePlus: ALP 44-147 + // IU/L, total bilirubin 0.1-1.2 mg/dL. Editable defaults like every seed. + { + slug: "alkaline-phosphatase", + panel: "liver", + unit: "U/L", + lowerBound: 44, + upperBound: 147, + }, + { + slug: "bilirubin-total", + panel: "liver", + unit: "mg/dL", + lowerBound: 0.1, + upperBound: 1.2, + }, // Electrolytes { slug: "sodium", @@ -294,8 +311,8 @@ export const BIOMARKER_PANELS = [ * Names are compared folded (lower case, no accents, hyphens and slashes as * spaces), so "Harnsäure" is written "harnsaure" here and "Nüchtern-Glukose" * needs no entry of its own. The registry-only keys (urea, blood urea - * nitrogen, uric acid, total calcium, total bilirubin) have no seed, so their - * names live only here. + * nitrogen, uric acid, total calcium) have no seed, so their names live only + * here; total bilirubin has had a seed since v1.43. */ export interface AnalyteAliases { /** The analyte key: a catalog slug, or a registry-only key. */ @@ -336,6 +353,24 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [ "glycosylated hemoglobin", "glykohamoglobin", "hamoglobin a1c", + // v1.43 (D1223) — MedlinePlus, Lab Tests Online (es, it, pl), Medics + // Labor, CHU de Lille, IUCPQ / CHUM, Asan Medical Center. + "hba1c", + "glycohemoglobin", + "glykohamoglobin a1c", + "hemoglobina glicada", + "hemoglobina glicosilada", + "glicohemoglobina", + "hemoglobine glyquee", + "hb glyque", + "glycohemoglobine", + "hemoglobine glycosylee", + "emoglobina a1c", + "glicoemoglobina", + "emoglobina glicosilata", + "hemoglobina glikowana", + "glikohemoglobina", + "당화혈색소", ], }, { @@ -351,8 +386,33 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [ "cholesterol calkowity", ], }, - { key: "ldl", names: ["ldl", "ldl c", "ldl chol", "ldl cholesterin"] }, - { key: "hdl", names: ["hdl", "hdl c", "hdl chol", "hdl cholesterin"] }, + { + key: "ldl", + names: [ + "ldl", + "ldl c", + "ldl chol", + "ldl cholesterin", + // v1.43 — Labor Limbach, Medics Labor, gesundheit.gv.at, CHU de Lille, + // the Korean national screening form. + "ldl direkt", + "ldlch", + "cholesterol ldl", + "저밀도 콜레스테롤", + ], + }, + { + key: "hdl", + names: [ + "hdl", + "hdl c", + "hdl chol", + "hdl cholesterin", + "hdlch", + "cholesterol hdl", + "고밀도 콜레스테롤", + ], + }, { key: "creatinine", names: [ @@ -360,6 +420,18 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [ "serumkreatinin", "kreatinin im serum", "creatinine serique", + // v1.43 — Labor Limbach ("Krea/S", serum only: the urine "Krea/U" is a + // different measurement and is not here), Medics Labor, Lab Tests + // Online (es, it, pl), CHU de Lille, CHUM, the Korean screening form. + "krea s", + "krea", + "crea", + "creatinin", + "kreatinin", + "creatininemie", + "creat", + "creatinina sierica", + "혈청 크레아티닌", ], }, { @@ -371,6 +443,22 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [ "25 oh d", "calcidiol", "vitamin d 25 oh", + // v1.43 — 25-OH only; calcitriol (1,25-(OH)2 D) is a different test and + // stays out. MedlinePlus, Medics Labor, gesundheit.gv.at, Lab Tests + // Online (es, it, pl), CHU de Lille, IUCPQ. + "25(oh)d", + "25 (oh)d", + "calcifediol", + "25 hydroxycholecalciferol", + "vd25", + "25 hidroxivitamina d", + "25 (oh) vitamine d", + "25 oh, vitamine d3", + "25 oh vitamina d", + "25 idrossi vitamina d", + "25 hydroksy witamina d3", + "kalcydiol", + "25 hydroksykolekalciferol", ], }, { key: "urea", names: ["urea", "harnstoff", "uree", "urea serum"] }, @@ -422,6 +510,417 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [ "bilirrubina", "bilirubine", "bilirubina", + // v1.43 (D1223) — "Alk.P" / "T.Bili" are how the reports in the + // discussion print them; the rest are MedlinePlus, testing.com, Labor + // Limbach, Medics Labor, Lab Tests Online (es, it), Dimedic (pl) and + // Asan Medical Center. The bare "BIL" is left out: lower case it is the + // urine dipstick field in German code lists. + "t.bili", + "t. bili", + "tbil", + "total serum bilirubin", + "tsb", + "bili", + "totales bilirubin", + "bilirrubina total", + "bilirubina totale", + "bilirubina całkowita", + "총 빌리루빈", + ], + }, +]; + +/** + * v1.43 (D1223) — other names for analytes, for recognising that two + * spellings on two reports are one marker. Same shape and fold as + * `ANALYTE_ALIASES`, and read together with it by `catalogNameIndex`, but + * with no conversion attached: an analyte here may have no entry in the + * unit-conversion registry ("GPT" is "ALT" whatever the unit), and the key may + * be a catalog slug or a name-only key for an analyte with no seed (`ldh`, + * `mcv`). Nothing here changes which unit conversion a marker gets. + * + * Every name is written folded (`foldForMatch`); a dot in shorthand is kept + * ("alk.p") and also matched without it. Each name was checked against a + * laboratory or clinical reference; a shorthand that names two analytes in + * different places ("PCR", "AP" outside German reports, "Cr") is left out, + * because a wrong automatic match moves a reading into the wrong history. + */ +export const LAB_NAME_ALIASES: readonly AnalyteAliases[] = [ + { + key: "alkaline-phosphatase", + names: [ + "alp", + "alkp", + "alk phos", + "alk.p", + "ap", + "alk. phosphatase", + "alkalische phosphatase", + "fosfatasa alcalina", + "pal", + "phosphatases alcalines", + "fosfatasi alcalina", + "fosfataza alkaliczna", + "fosfataza zasadowa", + "알칼리인산분해효소", + ], + }, + { + key: "alt", + names: [ + "alt", + "sgpt", + "gpt", + "alanine transaminase", + "alanine aminotransferase", + "alat", + "glutamat pyruvat transaminase", + "alanina aminotransferasa", + "transaminasa glutamico piruvico", + "tgp", + "alanina aminotransferasi", + "aminotransferaza alaninowa", + "alt(sgpt)", + "혈청gpt", + "알라닌아미노전달효소", + "알라닌아미노전이효소", + ], + }, + { + key: "ast", + names: [ + "ast", + "sgot", + "got", + "aspartate transaminase", + "aspartate aminotransferase", + "asat", + "glutamat oxalacetat transaminase", + "aspartato aminotransferasa", + "transaminasa glutamico oxalacetico", + "tgo", + "aspartato transaminasi", + "aspat", + "aminotransferaza asparaginianowa", + "ast(sgot)", + "혈청got", + "아스파테이트아미노전달효소", + "아스파테이트아미노전이효소", + ], + }, + { + key: "ggt", + names: [ + "ggt", + "ggtp", + "gtp", + "gamma gt", + "γ gt", + "γgt", + "gamma glutamyltransferase", + "gammaglutamyltransferase", + "gamma glutamil transferasa", + "gamma glutamylotranspeptydaza", + "감마지티피(γgtp)", + "γgtp", + "감마지티피", + "감마지티", + "r gtp", + "감마 gtp", + "감마글루타밀전이효소", + ], + }, + { + key: "ldh", + names: [ + "ldh", + "ld", + "lactate dehydrogenase", + "lactic dehydrogenase", + "lactatdehydrogenase", + "laktatdehydrogenase", + "lactato deshidrogenasa", + "lactate deshydrogenase", + "lattico deidrogenasi", + "lattato deidrogenasi", + "dehydrogenaza mleczanowa", + "젖산탈수효소", + ], + }, + { + // The standard assay. "PCR" is left out: it is CRP on Spanish and + // Italian reports and polymerase chain reaction everywhere else. + key: "crp", + names: [ + "crp", + "c reactive protein", + "c reaktives protein", + "proteina c reactiva", + "proteine c reactive", + "proteina c reattiva", + "białko c reaktywne", + "c 반응성 단백", + ], + }, + { + // The high-sensitivity assay, kept apart from the standard one: the two + // are read against different ranges, and a merge is the person's call. + key: "hs-crp", + names: [ + "hs crp", + "hscrp", + "high sensitivity crp", + "cardiac crp", + "crp hochsensitiv", + "pcr ultrasensible", + "pcr us", + "proteina c reactiva ultrasensible", + "crp ultrasensible", + "crp hs", + "hs pcr", + "pcr ultrasensibile", + "proteina c reattiva ultrasensibile", + "crp wysoko czułe", + "crp ultra czułe", + "wysoko czułe białko c reaktywne", + ], + }, + { + key: "tsh", + names: [ + "tsh", + "thyrotropin", + "thyroid stimulating hormone", + "thyreotropin", + "tsh basal", + "tirotropina", + "hormona estimulante del tiroides", + "hormona tiroestimulante", + "tsh us", + "tsh ultra sensible", + "thyrotropine", + "tireotropina", + "ormone tireotropo", + "tyreotropina", + "hormon tyreotropowy", + "갑상선자극호르몬", + ], + }, + { + // Free T3 only: a bare "T3" is the total hormone. + key: "ft3", + names: [ + "ft3", + "free t3", + "free triiodothyronine", + "t3 frei", + "freies t3", + "trijodthyronin frei", + "t3 libre", + "t3l", + "triiodotironina libera", + "wolna t3", + ], + }, + { + // Free T4 only: a bare "T4" is the total hormone. + key: "ft4", + names: [ + "ft4", + "free t4", + "free thyroxine", + "t4 frei", + "freies t4", + "thyroxin frei", + "t4l", + "t4 libre", + "tiroxina libre", + "thyroxine libre", + "tiroxina libera", + "wolna t4", + "유리 싸이록신", + ], + }, + { + // Estimates only: a bare "GFR" may be a measured rate, and a + // Cockcroft-Gault clearance is a different quantity. + key: "egfr", + names: [ + "egfr", + "estimated gfr", + "cgfr", + "egfr nach ckd epi formel", + "ckdepi", + "egfr nach mdrd formel", + "tefg", + "tfg calculada", + "tasa estimada de filtrado glomerular", + "fge", + "dfg", + "dfge", + "szacowana filtracja kłebuszkowa", + "신사구체여과율 (e gfr)", + "e gfr", + "신사구체여과율", + ], + }, + { + key: "hemoglobin", + names: ["hb", "hgb", "hemogl", "hemoglobine", "혈색소"], + }, + { + key: "hematocrit", + names: [ + "hct", + "pcv", + "crit", + "packed cell volume", + "hkt", + "hto", + "ht", + "적혈구용적률", + ], + }, + { + key: "mcv", + names: [ + "mcv", + "mittleres zellvolumen", + "vcm", + "volumen corpuscular medio", + "vgm", + "volume globulaire moyen", + "평균적혈구용적", + ], + }, + { + // "Hb/E" is Labor Limbach's code for MCH, not haemoglobin. + key: "mch", + names: ["mch", "hb e", "hcm", "tcmh", "tgmh", "평균적혈구혈색소량"], + }, + { + key: "mchc", + names: [ + "mchc", + "chcm", + "ccmh", + "cgmh", + "평균적혈구 헤모글로빈농도", + "평균 적혈구혈색소 농도", + ], + }, + { + key: "rbc", + names: [ + "rbc", + "rbc count", + "erythrocyte count", + "red count", + "ery", + "erythrozyten", + "recuento de hematies", + "recuento eritrocitario", + "erythrocytes", + "hematies", + "liczba erytrocytow", + "적혈구", + ], + }, + { + key: "wbc", + names: [ + "wbc", + "leukocyte count", + "white cell count", + "leuko", + "leukozyten", + "leuc", + "leucocitos", + "leucocytes", + "liczba leukocytow", + "백혈구", + ], + }, + { + key: "platelets", + names: [ + "plt", + "plt count", + "thrombocyte count", + "thromb", + "thr", + "thrombozyten", + "plaq", + "plaquetas", + "plaquettes", + "conta piastrinica", + "liczba płytek krwi", + "liczba trombocytow", + "platelet", + "혈소판", + ], + }, + { + // "TG" is left out: written "Tg" it is thyroglobulin, and the match + // ignores case. + key: "triglycerides", + names: [ + "trig", + "tgl", + "trg", + "triglyceride", + "triglycerides", + "trigliceridos", + "trigliceridi", + "triglicerydy", + "중성지방", + ], + }, + { + key: "ferritin", + names: [ + "ferritin", + "serum ferritin", + "ferr", + "ferritina", + "ferritine", + "ferritina sierica", + "ferrytyna", + ], + }, + { + key: "vitamin-b12", + names: [ + "vitamin b12", + "b12", + "cobalamin", + "cobalamina", + "vitamine b12", + "cyanocobalamine", + "vb12", + "vitamina b12", + "witamina b12", + "kobalamina", + ], + }, + { + key: "sodium", + names: ["na", "na+", "natrium", "sodium", "sodio", "natremie", "소디움"], + }, + { + // A bare "K" only ever reaches a potassium marker whose unit the reading + // fits (`resolveInIndex`), never vitamin K. + key: "potassium", + names: [ + "k", + "k+", + "kalium", + "potassium", + "potasio", + "potassio", + "potas", + "kaliemie", + "포타슘", ], }, ]; diff --git a/src/lib/labs/biomarker-resolve.ts b/src/lib/labs/biomarker-resolve.ts new file mode 100644 index 000000000..e32a56bea --- /dev/null +++ b/src/lib/labs/biomarker-resolve.ts @@ -0,0 +1,379 @@ +/** + * Which of an account's markers a printed name belongs to (D1223). + * + * One lab writes "Alkaline Phosphatase", the next "Alk.P", and each spelling + * used to become a marker of its own with half the history. Every path that + * turns a name into a marker resolves it here, in one order: + * + * 1. `name` the marker's own name, case and spacing aside; + * 2. `alias` a spelling the account attached to a marker (merged in, + * chosen at an import, or typed on the marker), folded; + * 3. `catalog` a name the catalog knows for the same analyte as a marker of + * the account ("GPT" for a marker called "ALT"), when the + * reading's unit is that marker's or converts to it; + * 4. nothing the caller mints a marker, or the review offers suggestions. + * + * Steps 1 and 2 are the account's own decisions and win regardless of the + * unit; a unit that does not fit is then refused by the write, exactly as it + * is for a marker picked by hand. Step 3 is the app's guess, so it steps aside + * for a unit it cannot reconcile and the reading keeps its own marker. + * + * The matching is pure over a {@link MarkerIndex} loaded once per request, so + * the scan review, the commit and the document import cannot disagree about + * the same name. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; + +import { + catalogKeyForName, + catalogNamesForKey, + foldWithoutDots, +} from "./analyte-match"; +import { convertLabValue } from "./unit-conversion/convert"; +import { sameLabUnit } from "./unit-normalise"; + +export type BiomarkerMatchKind = "name" | "alias" | "catalog"; +export type BiomarkerSuggestionKind = "catalog" | "similar"; + +/** Most suggestions a review row offers. */ +export const BIOMARKER_SUGGESTION_LIMIT = 3; + +export interface IndexedMarker { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + analyteKey: string | null; + hidden: boolean; + createdAt: Date; +} + +export interface IndexedAlias { + biomarkerId: string; + name: string; + nameFolded: string; +} + +export interface MarkerIndex { + markers: readonly IndexedMarker[]; + aliases: readonly IndexedAlias[]; +} + +export interface MarkerMatch { + marker: IndexedMarker; + matchKind: BiomarkerMatchKind; +} + +export interface MarkerSuggestion { + biomarkerId: string; + name: string; + unit: string; + matchKind: BiomarkerSuggestionKind; +} + +const MARKER_SELECT = { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + analyteKey: true, + hidden: true, + createdAt: true, +} as const; + +/** Every marker and alias of the account, for {@link resolveInIndex}. */ +export async function loadMarkerIndex( + client: Pick, + userId: string, +): Promise { + const [markers, aliases] = await Promise.all([ + client.biomarker.findMany({ + where: { userId }, + select: MARKER_SELECT, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + client.biomarkerAlias.findMany({ + where: { userId }, + select: { biomarkerId: true, name: true, nameFolded: true }, + }), + ]); + return { markers, aliases }; +} + +/** Add a marker minted after the index was loaded, so later rows see it. */ +export function addToIndex(index: MarkerIndex, marker: IndexedMarker): void { + if (index.markers.some((m) => m.id === marker.id)) return; + (index.markers as IndexedMarker[]).push(marker); +} + +/** Record an alias written after the index was loaded. */ +export function addAliasToIndex( + index: MarkerIndex, + biomarkerId: string, + name: string, +): void { + const nameFolded = foldForMatch(name.trim()); + const list = index.aliases as IndexedAlias[]; + const at = list.findIndex((a) => a.nameFolded === nameFolded); + const entry = { biomarkerId, name: name.trim(), nameFolded }; + if (at >= 0) list[at] = entry; + else list.push(entry); +} + +/** Visible before hidden, then the oldest: the order a tie is broken in. */ +function preferred(candidates: IndexedMarker[]): IndexedMarker | null { + if (candidates.length === 0) return null; + const visible = candidates.filter((m) => !m.hidden); + return (visible.length > 0 ? visible : candidates)[0] ?? null; +} + +/** The catalog analytes a marker answers to: through its name and its aliases. */ +function markerCatalogKeys( + index: MarkerIndex, + marker: IndexedMarker, +): Set { + const keys = new Set(); + const own = catalogKeyForName(marker.name) ?? marker.analyteKey; + if (own) keys.add(own); + for (const alias of index.aliases) { + if (alias.biomarkerId !== marker.id) continue; + const key = catalogKeyForName(alias.name); + if (key) keys.add(key); + } + return keys; +} + +/** + * Whether a reading stated in `unit` can be stored under the marker: the same + * unit, or one the conversion moves into it. No unit (a qualitative reading, + * or none stated) always fits. + */ +export function unitFitsMarker( + marker: Pick, + unit: string | null | undefined, + analyteKey: string | null, +): boolean { + if (unit === null || unit === undefined || unit.trim() === "") return true; + if (sameLabUnit(unit, marker.unit)) return true; + return convertLabValue({ + analyteKey: marker.analyteKey ?? analyteKey, + value: 1, + from: unit, + to: marker.unit, + }).ok; +} + +/** + * The marker a printed name resolves to, and how; null when nothing does. + * `unit` is the unit the reading states, used only by the catalog step. + */ +export function resolveInIndex( + index: MarkerIndex, + rawName: string, + unit?: string | null, +): MarkerMatch | null { + const name = rawName.trim(); + if (!name) return null; + + // 1. The marker's own name. An exact spelling wins over one that differs in + // case, which wins over one that differs only in punctuation and spacing, + // and that last one only when it names a single marker. + const exact = index.markers.find((m) => m.name === name); + if (exact) return { marker: exact, matchKind: "name" }; + const lower = name.toLowerCase(); + const caseless = preferred( + index.markers.filter((m) => m.name.trim().toLowerCase() === lower), + ); + if (caseless) return { marker: caseless, matchKind: "name" }; + const folded = foldForMatch(name); + const byFold = index.markers.filter((m) => foldForMatch(m.name) === folded); + if (byFold.length === 1) return { marker: byFold[0], matchKind: "name" }; + + // 2. An alias of the account. + const alias = index.aliases.find((a) => a.nameFolded === folded); + if (alias) { + const marker = index.markers.find((m) => m.id === alias.biomarkerId); + if (marker) return { marker, matchKind: "alias" }; + } + + // 3. The catalog: a marker of the same analyte that can take the unit. + const key = catalogKeyForName(name); + if (key) { + const candidates = index.markers.filter( + (m) => + markerCatalogKeys(index, m).has(key) && unitFitsMarker(m, unit, key), + ); + const marker = preferred(candidates); + if (marker) return { marker, matchKind: "catalog" }; + } + + return null; +} + +/** Folded name without dots, commas or parentheses, for the similarity. */ +function loose(raw: string): string { + return foldWithoutDots(raw) + .replace(/[()[\]]/gu, " ") + .replace(/\s+/gu, " ") + .trim(); +} + +function bigrams(text: string): string[] { + const out: string[] = []; + for (let i = 0; i < text.length - 1; i++) out.push(text.slice(i, i + 2)); + return out; +} + +/** Sørensen-Dice over character bigrams, 0..1. */ +export function diceSimilarity(a: string, b: string): number { + const x = bigrams(a); + const y = bigrams(b); + if (x.length === 0 || y.length === 0) return a === b && a !== "" ? 1 : 0; + const counts = new Map(); + for (const g of y) counts.set(g, (counts.get(g) ?? 0) + 1); + let shared = 0; + for (const g of x) { + const n = counts.get(g) ?? 0; + if (n > 0) { + shared += 1; + counts.set(g, n - 1); + } + } + return (2 * shared) / (x.length + y.length); +} + +/** + * Shorthand against the full name: every word of the shorter is the start of + * a word of the longer, in order ("alk phos" against "alkaline phosphatase"). + */ +function abbreviates(short: string, long: string): boolean { + const s = short.split(" ").filter(Boolean); + const l = long.split(" ").filter(Boolean); + if (s.length === 0 || s.length > l.length) return false; + let j = 0; + for (const word of s) { + while (j < l.length && !l[j].startsWith(word)) j++; + if (j === l.length) return false; + j++; + } + return true; +} + +/** How alike two names are, 0..1. */ +export function nameSimilarity(a: string, b: string): number { + const x = loose(a); + const y = loose(b); + if (!x || !y) return 0; + if (x === y) return 1; + const [shorter, longer] = x.length <= y.length ? [x, y] : [y, x]; + const prefix = abbreviates(shorter, longer) ? 0.8 : 0; + return Math.max(prefix, diceSimilarity(x, y)); +} + +/** Below this a marker is not offered as similar. */ +export const SIMILARITY_FLOOR = 0.5; + +/** + * Up to three markers a review row offers for a name nothing resolved: + * first the markers of the same catalog analyte (their unit kept the catalog + * step from taking them), then the markers whose name, aliases or catalog + * names are most alike. Hidden markers are not offered. + */ +export function suggestInIndex( + index: MarkerIndex, + rawName: string, + limit = BIOMARKER_SUGGESTION_LIMIT, +): MarkerSuggestion[] { + const name = rawName.trim(); + if (!name) return []; + const out: MarkerSuggestion[] = []; + const taken = new Set(); + const visible = index.markers.filter((m) => !m.hidden); + + const key = catalogKeyForName(name); + if (key) { + for (const marker of visible) { + if (out.length >= limit) break; + if (!markerCatalogKeys(index, marker).has(key)) continue; + out.push(toSuggestion(marker, "catalog")); + taken.add(marker.id); + } + } + + const scored: Array<{ marker: IndexedMarker; score: number }> = []; + for (const marker of visible) { + if (taken.has(marker.id)) continue; + const names = [marker.name]; + for (const alias of index.aliases) { + if (alias.biomarkerId === marker.id) names.push(alias.name); + } + for (const k of markerCatalogKeys(index, marker)) { + names.push(...catalogNamesForKey(k)); + } + let score = 0; + for (const candidate of names) { + score = Math.max(score, nameSimilarity(name, candidate)); + } + if (score >= SIMILARITY_FLOOR) scored.push({ marker, score }); + } + scored.sort( + (a, b) => b.score - a.score || a.marker.name.localeCompare(b.marker.name), + ); + for (const { marker } of scored) { + if (out.length >= limit) break; + out.push(toSuggestion(marker, "similar")); + } + return out; +} + +function toSuggestion( + marker: IndexedMarker, + matchKind: BiomarkerSuggestionKind, +): MarkerSuggestion { + return { + biomarkerId: marker.id, + name: marker.name, + unit: marker.unit, + matchKind, + }; +} + +/** + * Resolution and suggestions for one name, as the review rows carry them: + * the matched marker and how, or none and up to three to choose from. + */ +export function matchForReview( + index: MarkerIndex, + name: string, + unit?: string | null, +): { + match: MarkerMatch | null; + suggestions: MarkerSuggestion[]; +} { + const match = resolveInIndex(index, name, unit); + return { + match, + suggestions: match ? [] : suggestInIndex(index, name), + }; +} + +/** + * The marker a name asked for on a read path (MCP `get_labs`, the + * `healthlog://lab/{analyte}` resource) resolves to: its name, an alias or a + * catalog abbreviation, with no unit to weigh. Null when nothing resolves; + * the caller then keeps its own text match. + */ +export async function resolveMarkerForRead( + userId: string, + name: string, +): Promise<{ id: string; name: string } | null> { + const index = await loadMarkerIndex(prisma, userId); + const match = resolveInIndex(index, name); + return match ? { id: match.marker.id, name: match.marker.name } : null; +} diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index c6ca11e3a..fb1db574b 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -14,6 +14,13 @@ import { Buffer } from "node:buffer"; import { decrypt, encrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; import { matchAnalyteKey } from "@/lib/labs/analyte-match"; +import { + addToIndex, + loadMarkerIndex, + resolveInIndex, + type IndexedMarker, + type MarkerIndex, +} from "@/lib/labs/biomarker-resolve"; import { getEvent } from "@/lib/logging/context"; /** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */ @@ -72,18 +79,19 @@ export interface MintedBiomarker { * v1.18.1 — resolve (or mint) the user-scoped Biomarker for a free-text lab * write so NO `LabResult` row ever persists unlinked. * - * The catalog identity is `(userId, lower(analyte))`: a reading for "LDL", - * "ldl", and "LDL " all resolve to one marker. An existing marker (hand- - * defined or previously minted) is reused as-is — its unit / range are - * authoritative and the incoming free-text unit / bounds are NOT allowed to - * silently rewrite it. When no marker exists yet, one is minted from the - * reading's own free-text spelling / unit / bounds. The `@@unique([userId, - * name])` index is the structural backstop; a concurrent double-write - * converges via the catch-and-refetch. + * Since v1.43 (D1223) the name resolves through `resolveInIndex`: the + * marker's own name (case aside), then an alias of the account, then a + * catalog name for the same analyte as one of the account's markers ("GPT" + * for "ALT") when the reading's unit fits that marker. An existing marker is + * reused as-is: its unit / range are authoritative and the incoming free-text + * unit / bounds are NOT allowed to silently rewrite it. When nothing resolves, + * a marker is minted from the reading's own spelling / unit / bounds. The + * `@@unique([userId, name])` index is the structural backstop; a concurrent + * double-write converges via the catch-and-refetch. * - * Turning the boot-time backfill into a pure historical migration: from - * v1.18.1 on, every write goes through here, so the only unlinked rows that - * can exist are pre-upgrade ones the backfill heals. + * `index` lets a caller writing many rows load the account's markers once; + * a marker minted here is added to it so a later row with the same name finds + * it. */ export async function resolveOrMintBiomarker( userId: string, @@ -94,28 +102,15 @@ export async function resolveOrMintBiomarker( referenceHigh: number | null; panel: string | null; }, + options: { index?: MarkerIndex } = {}, ): Promise { const name = input.analyte.trim(); - const select = { - id: true, - name: true, - unit: true, - lowerBound: true, - upperBound: true, - panel: true, - analyteKey: true, - } as const; - - // Case-insensitive match on the catalog identity. Postgres is the store; - // `mode: "insensitive"` keeps "LDL" === "ldl" without a functional index. - const existing = await prisma.biomarker.findFirst({ - where: { userId, name: { equals: name, mode: "insensitive" } }, - select, - }); - if (existing) return existing; + const index = options.index ?? (await loadMarkerIndex(prisma, userId)); + const hit = resolveInIndex(index, name, input.unit || null); + if (hit) return toMinted(hit.marker); try { - return await prisma.biomarker.create({ + const created = await prisma.biomarker.create({ data: { userId, name, @@ -125,15 +120,44 @@ export async function resolveOrMintBiomarker( panel: input.panel, analyteKey: matchAnalyteKey({ name, unit: input.unit }), }, - select, + select: INDEXED_SELECT, }); + addToIndex(index, created); + return toMinted(created); } catch { // A concurrent write minted the same `(userId, name)` first — adopt it. const raced = await prisma.biomarker.findFirst({ where: { userId, name: { equals: name, mode: "insensitive" } }, - select, + select: INDEXED_SELECT, }); - if (raced) return raced; + if (raced) { + addToIndex(index, raced); + return toMinted(raced); + } throw new Error("Failed to resolve biomarker for lab write"); } } + +const INDEXED_SELECT = { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + analyteKey: true, + hidden: true, + createdAt: true, +} as const; + +function toMinted(marker: IndexedMarker): MintedBiomarker { + return { + id: marker.id, + name: marker.name, + unit: marker.unit, + lowerBound: marker.lowerBound, + upperBound: marker.upperBound, + panel: marker.panel, + analyteKey: marker.analyteKey, + }; +} From 81be73fca8be182d53be9250f17a044a6ca462a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:22:40 +0200 Subject: [PATCH 017/127] feat(labs): merge markers, keep aliases and read lab series per marker POST /api/biomarkers/{id}/merge folds markers into one in a transaction, converting readings where the registry knows the units and refusing the whole merge where it does not; a dry run answers the same result and writes nothing. Aliases appear on the marker and are edited through the marker's PUT. Scan and document review rows name the matched marker or offer up to three, and a chosen marker is remembered as an alias. The doctor report, FHIR and MCP group readings by marker. Aliases and the merge ledger travel through backup and restore. --- docs/api/openapi.yaml | 562 +++++++++++------- prisma/schema.prisma | 16 +- src/__tests__/contract-stub-inventory.test.ts | 3 +- .../biomarkers/[id]/__tests__/route.test.ts | 36 +- src/app/api/biomarkers/[id]/merge/route.ts | 102 +++- src/app/api/biomarkers/[id]/route.ts | 89 +-- src/app/api/biomarkers/route.ts | 47 +- .../documents/inbound/[id]/confirm/route.ts | 7 +- .../[id]/extract/__tests__/route.test.ts | 1 + .../documents/inbound/[id]/extract/route.ts | 19 +- .../inbound/[id]/facts/[factId]/route.ts | 10 +- src/app/api/documents/inbound/[id]/route.ts | 7 +- .../labs/ocr/commit/__tests__/route.test.ts | 5 + src/app/api/labs/ocr/commit/route.ts | 108 +++- .../__tests__/ocr-review-validation.test.ts | 6 + src/lib/doctor-report/clinical-records.ts | 19 +- src/lib/documents/commit.ts | 61 +- src/lib/documents/store.ts | 53 +- .../export/__tests__/records-backup.test.ts | 2 + .../restore-backup-round-trip.test.ts | 2 + src/lib/export/backup-plan.ts | 12 +- src/lib/export/records-backup.ts | 77 +++ src/lib/export/restore-backup.ts | 71 +++ src/lib/fhir/resources/labs.ts | 6 +- src/lib/labs/biomarker-dto.ts | 56 ++ src/lib/labs/biomarker-merge.ts | 388 ++++++++++++ src/lib/labs/ocr-extract.ts | 32 +- .../mcp/__tests__/module-gate-sweep.test.ts | 1 + .../mcp/__tests__/resource-templates.test.ts | 18 +- src/lib/mcp/__tests__/rich-reads.test.ts | 5 + src/lib/mcp/__tests__/tools.test.ts | 5 + src/lib/mcp/resources.ts | 44 +- src/lib/mcp/rich-reads.ts | 38 +- src/lib/mcp/tools.ts | 17 +- src/lib/openapi/error-codes.ts | 2 + src/lib/openapi/routes/biomarkers.ts | 25 +- src/lib/openapi/routes/ocr.ts | 10 +- src/lib/validations/backup.ts | 29 + src/lib/validations/biomarkers.ts | 4 + src/lib/validations/inbound-documents.ts | 21 + src/lib/validations/labs-ocr.ts | 30 +- tests/integration/backup-round-trip.test.ts | 22 + 42 files changed, 1645 insertions(+), 423 deletions(-) create mode 100644 src/lib/labs/biomarker-dto.ts create mode 100644 src/lib/labs/biomarker-merge.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 43407e243..27982c45f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1904,7 +1904,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": &a30 + "501": &a44 description: "Not implemented on this server yet: the contract is published, the effect is not. `meta.errorCode` = `not_implemented`, `meta.operation` names the operation. Authentication, module gates and request validation already answer as documented. A client reaches this only by ignoring the server feature flag that guards the @@ -15171,7 +15171,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a39 + "202": &a37 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -15318,7 +15318,7 @@ paths: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 "403": *a11 - "409": &a29 + "409": description: A biomarker with this name already exists for the caller. content: application/json: @@ -15331,7 +15331,8 @@ paths: tags: - Labs summary: Fetch a single biomarker - description: Returns the biomarker including its decrypted `context`. Cross-user rows surface as 404. + description: Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows + surface as 404. A marker merged into another answers 404 with `meta.mergedInto`. parameters: - in: path name: id @@ -15361,8 +15362,10 @@ paths: tags: - Labs summary: Edit a biomarker - description: "Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits - as `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422 + description: "Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Since + v1.43 `aliases` (0..50, each 1..120 characters) replaces the marker's set of other spellings: listed spellings + stay or are added, unlisted ones are removed; omitting the field leaves them as they are. Audits as + `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422 `biomarkers.unit.locked` while any reading is linked to the marker (`meta.readingCount` carries how many): readers label every linked reading with the marker's unit, so a change would relabel stored numbers without converting them. Sending the current unit, or changing it on a marker with no readings, is accepted." @@ -15388,7 +15391,14 @@ paths: "401": *a1 "403": *a11 "404": *a28 - "409": *a29 + "409": + description: "`labs.biomarker.duplicate`: a rename collides with another of the caller's markers. + `labs.biomarker.alias.taken` (v1.43): one of `aliases` is already the name or an alias of another marker; + `meta.alias` names the spelling and `meta.biomarkerId` the marker that answers to it." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: @@ -15443,17 +15453,27 @@ paths: $ref: "#/components/schemas/BiomarkerMergeRequest" responses: "200": - description: The merge result (or the dry-run preview). + description: 'The merge result, or the dry-run preview. A preview reports a source that cannot be converted with + `conversion: "refused"` and its `refusal`; the same request without `dryRun` answers 422 + `labs.biomarker.merge.unitMismatch`.' content: application/json: schema: $ref: "#/components/schemas/MergeBiomarkersResponse" "401": *a1 + "403": *a11 "404": *a28 "409": *a7 - "422": *a4 + "422": + description: "`labs.biomarker.merge.invalid`: a malformed body, or the target among the sources (Zod issues in + `details.issues[]`). `labs.biomarker.merge.unitMismatch`: a source holds readings in a unit the registry + cannot convert to the target's; `meta.sources` lists each `{ id, unit, targetUnit, reason }` with `reason` + one of `unknown_analyte`, `unknown_unit`, `not_finite`. Nothing was moved." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a12 - "501": *a30 /api/encounters: get: tags: @@ -15666,7 +15686,7 @@ paths: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 "403": *a11 - "404": &a31 + "404": &a29 description: Visit not found (or owned by another user). content: application/json: @@ -15700,7 +15720,7 @@ paths: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a11 - "404": *a31 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -15725,7 +15745,7 @@ paths: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a11 - "404": *a31 + "404": *a29 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -15751,7 +15771,7 @@ paths: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a11 - "404": *a31 + "404": *a29 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -15781,7 +15801,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a31 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -15810,7 +15830,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a31 + "404": *a29 "422": *a4 "429": *a5 /api/practitioners: @@ -15889,7 +15909,7 @@ paths: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 "403": *a11 - "404": &a32 + "404": &a30 description: Practitioner not found (or owned by another user). content: application/json: @@ -15923,7 +15943,7 @@ paths: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a11 - "404": *a32 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -15947,7 +15967,7 @@ paths: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a11 - "404": *a32 + "404": *a30 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -15973,7 +15993,7 @@ paths: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a11 - "404": *a32 + "404": *a30 "422": *a4 "429": *a5 /api/vaccinations/custom: @@ -16017,7 +16037,7 @@ paths: $ref: "#/components/schemas/CreateCustomVaccineEnvelope" "401": *a1 "403": *a11 - "409": &a33 + "409": &a31 description: A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`). content: @@ -16054,13 +16074,13 @@ paths: $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" "401": *a1 "403": *a11 - "404": &a34 + "404": &a32 description: No live definition with this id on the record (`vaccination.custom.not-found`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a33 + "409": *a31 "422": *a4 "429": *a5 delete: @@ -16085,7 +16105,7 @@ paths: $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a32 "422": *a4 "429": *a5 /api/vaccinations: @@ -16187,7 +16207,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a11 - "404": &a35 + "404": &a33 description: Vaccination not found (or owned by another user). content: application/json: @@ -16222,7 +16242,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a33 "422": *a4 "429": *a5 delete: @@ -16247,7 +16267,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a33 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -16272,7 +16292,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a33 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -16338,7 +16358,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a35 + "404": *a33 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -16396,7 +16416,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a35 + "404": *a33 "422": *a4 "429": *a5 delete: @@ -16425,7 +16445,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a35 + "404": *a33 "422": *a4 "429": *a5 /api/illness/episodes: @@ -16540,7 +16560,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": &a36 + "404": &a34 description: Illness episode not found (or owned by another user). content: application/json: @@ -16575,7 +16595,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -16599,7 +16619,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a34 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -16627,7 +16647,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a34 "409": *a7 "422": *a4 "429": *a5 @@ -16660,7 +16680,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a34 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -16711,7 +16731,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a34 "422": *a4 "429": *a5 post: @@ -16748,7 +16768,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a34 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -16775,7 +16795,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a36 + "404": *a34 "422": *a4 "429": *a5 /api/illness/insights: @@ -16991,7 +17011,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a11 - "404": &a37 + "404": &a35 description: Allergy not found (or owned by another user). content: application/json: @@ -17025,7 +17045,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a37 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -17049,7 +17069,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a37 + "404": *a35 "422": *a4 "429": *a5 /api/family-history: @@ -17123,7 +17143,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": &a38 + "404": &a36 description: Family history entry not found (or owned by another user). content: application/json: @@ -17157,7 +17177,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a38 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -17181,7 +17201,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a38 + "404": *a36 "422": *a4 "429": *a5 /api/documents/inbound: @@ -17846,7 +17866,7 @@ paths: required: true schema: type: string - - &a40 + - &a38 name: Prefer in: header required: false @@ -17871,9 +17891,9 @@ paths: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" - "202": *a39 + "202": *a37 "401": *a1 - "403": &a41 + "403": &a39 description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master @@ -17897,7 +17917,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a42 + "503": &a40 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -17927,7 +17947,7 @@ paths: required: true schema: type: string - - *a40 + - *a38 requestBody: required: false content: @@ -17963,12 +17983,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" - "202": *a39 + "202": *a37 "401": *a1 - "403": *a41 + "403": *a39 "422": *a4 "429": *a5 - "503": *a42 + "503": *a40 /api/documents/inbound/{id}/summary: post: tags: @@ -18018,7 +18038,7 @@ paths: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - - *a40 + - *a38 requestBody: required: false content: @@ -18054,12 +18074,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" - "202": *a39 + "202": *a37 "401": *a1 - "403": *a41 + "403": *a39 "422": *a4 "429": *a5 - "503": *a42 + "503": *a40 /api/documents/inbound/{id}/index: post: tags: @@ -18085,7 +18105,7 @@ paths: required: true schema: type: string - - *a40 + - *a38 requestBody: required: false content: @@ -18121,12 +18141,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a39 + "202": *a37 "401": *a1 - "403": *a41 + "403": *a39 "422": *a4 "429": *a5 - "503": *a42 + "503": *a40 /api/documents/inbound/{id}/chat: post: tags: @@ -18166,10 +18186,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a41 + "403": *a39 "422": *a4 "429": *a5 - "503": *a42 + "503": *a40 get: tags: - Documents @@ -18232,10 +18252,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a41 + "403": *a39 "422": *a4 "429": *a5 - "503": *a42 + "503": *a40 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -18342,7 +18362,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a44 + "409": &a42 description: A custom metric with this name already exists for the caller. content: application/json: @@ -18371,7 +18391,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a11 - "404": &a43 + "404": &a41 description: Custom metric not found (or owned by another user). content: application/json: @@ -18405,8 +18425,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a43 - "409": *a44 + "404": *a41 + "409": *a42 "422": *a4 "429": *a5 delete: @@ -18429,7 +18449,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a43 + "404": *a41 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -18473,7 +18493,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a11 - "404": *a43 + "404": *a41 "422": *a4 "429": *a5 post: @@ -18502,7 +18522,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a43 + "404": *a41 "409": *a7 "422": *a4 "429": *a12 @@ -18538,7 +18558,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a45 + "404": &a43 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -18571,7 +18591,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a45 + "404": *a43 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -18873,7 +18893,7 @@ paths: schema: $ref: "#/components/schemas/NutrientCatalogEnvelope" "401": *a1 - "403": &a46 + "403": &a45 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -18895,7 +18915,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/nutrients/batch: post: tags: @@ -19021,7 +19041,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": *a46 + "403": *a45 "422": *a4 "429": *a5 /api/nutrients/daily: @@ -19057,7 +19077,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a46 + "403": *a45 "422": *a4 "429": *a5 /api/nutrients/water: @@ -19086,7 +19106,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a46 + "403": *a45 "409": *a7 "422": *a4 "429": *a5 @@ -19588,26 +19608,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a47 + "400": &a46 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a48 + "413": &a47 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a49 + "415": &a48 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a50 + "422": &a49 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -19793,11 +19813,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a47 + "400": *a46 "401": *a1 - "413": *a48 - "415": *a49 - "422": *a50 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19940,11 +19960,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a47 + "400": *a46 "401": *a1 - "413": *a48 - "415": *a49 - "422": *a50 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -20105,11 +20125,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a47 + "400": *a46 "401": *a1 - "413": *a48 - "415": *a49 - "422": *a50 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -20185,21 +20205,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a51 + "422": &a50 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a52 + "429": &a51 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a53 + "502": &a52 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -20244,11 +20264,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a47 + "400": *a46 "401": *a1 - "413": *a48 - "415": *a49 - "422": *a50 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -20324,9 +20344,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a51 - "429": *a52 - "502": *a53 + "422": *a50 + "429": *a51 + "502": *a52 /api/strava/credentials: get: tags: @@ -20362,11 +20382,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a47 + "400": *a46 "401": *a1 - "413": *a48 - "415": *a49 - "422": *a50 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -20444,9 +20464,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a51 - "429": *a52 - "502": *a53 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/status: get: tags: @@ -20482,7 +20502,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a52 + "429": *a51 /api/integrations/fitbit/test: post: tags: @@ -20500,9 +20520,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a51 - "429": *a52 - "502": *a53 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/google-health/resume: post: tags: @@ -20519,7 +20539,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a52 + "429": *a51 /api/integrations/google-health/test: post: tags: @@ -20552,9 +20572,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a51 - "429": *a52 - "502": *a53 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/whoop/resume: post: tags: @@ -20571,7 +20591,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a52 + "429": *a51 /api/integrations/whoop/test: post: tags: @@ -20588,9 +20608,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a51 - "429": *a52 - "502": *a53 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/withings/resume: post: tags: @@ -20608,7 +20628,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a52 + "429": *a51 /api/integrations/withings/test: post: tags: @@ -20626,8 +20646,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a51 - "429": *a52 + "422": *a50 + "429": *a51 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -21029,7 +21049,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a54 + "403": &a53 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -21054,7 +21074,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a54 + "403": *a53 "422": *a4 "429": *a5 post: @@ -21156,7 +21176,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a54 + "403": *a53 "404": description: No such invitation for this account. content: @@ -21200,7 +21220,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a54 + "403": *a53 "404": description: No such grant on this account's record. content: @@ -21241,7 +21261,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a54 + "403": *a53 "404": description: No such grant held by this account. content: @@ -21623,7 +21643,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a54 + "403": *a53 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -22003,7 +22023,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a55 + "403": &a54 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -22036,7 +22056,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a55 + "403": *a54 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -22063,7 +22083,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a55 + "403": *a54 "422": *a4 "429": *a5 post: @@ -22096,7 +22116,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a55 + "403": *a54 "413": description: Body exceeds 16 KiB. content: @@ -22139,7 +22159,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a55 + "403": *a54 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -22198,7 +22218,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a56 + - &a55 name: family in: path required: true @@ -22242,7 +22262,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a56 + - *a55 requestBody: required: true content: @@ -22815,13 +22835,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a57 + "403": &a56 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a58 + "404": &a57 description: No stored copy with this id. content: application/json: @@ -22895,8 +22915,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a57 - "404": *a58 + "403": *a56 + "404": *a57 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -22940,7 +22960,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a57 + "403": *a56 /api/admin/backups/restores/{jobId}: get: tags: @@ -22963,7 +22983,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a57 + "403": *a56 "404": description: No restore job with this id. content: @@ -22982,7 +23002,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a59 + security: &a58 - cookieAuth: [] responses: "200": @@ -22992,7 +23012,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a60 + "403": &a59 description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the Documents module is off." @@ -23015,9 +23035,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a59 + security: *a58 parameters: - - &a61 + - &a60 name: system in: path required: true @@ -23056,8 +23076,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a60 - "404": &a62 + "403": *a59 + "404": &a61 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -23076,7 +23096,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a63 + "502": &a62 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -23095,9 +23115,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a59 + security: *a58 parameters: - - *a61 + - *a60 responses: "200": description: Removed, or there was nothing to remove. @@ -23106,7 +23126,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a60 + "403": *a59 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -23117,9 +23137,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a59 + security: *a58 parameters: - - *a61 + - *a60 responses: "200": description: The source answered. @@ -23128,9 +23148,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a60 - "404": *a62 - "422": &a64 + "403": *a59 + "404": *a61 + "422": &a63 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -23139,7 +23159,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a63 + "502": *a62 /api/documents/sources/{system}/tags: get: tags: @@ -23147,9 +23167,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a59 + security: *a58 parameters: - - *a61 + - *a60 responses: "200": description: The tags. @@ -23158,11 +23178,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a60 - "404": *a62 - "422": *a64 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a63 + "502": *a62 /api/documents/sources/{system}/search: get: tags: @@ -23176,9 +23196,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a59 + security: *a58 parameters: - - *a61 + - *a60 - name: q in: query required: false @@ -23219,11 +23239,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a60 - "404": *a62 - "422": *a64 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a63 + "502": *a62 /api/documents/sources/{system}/import: post: tags: @@ -23242,9 +23262,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a59 + security: *a58 parameters: - - *a61 + - *a60 requestBody: required: true content: @@ -23302,7 +23322,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a60 + "403": *a59 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -23333,7 +23353,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a64 + "422": *a63 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -23341,7 +23361,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a63 + "502": *a62 /api/symptoms/definitions: get: tags: @@ -23365,7 +23385,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a65 + "403": &a64 description: >- `illness.disabled`: the record has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module. @@ -23408,7 +23428,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a65 + "403": *a64 "409": *a7 "422": *a4 "429": *a5 @@ -23438,8 +23458,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a65 - "404": &a66 + "403": *a64 + "404": &a65 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -23473,8 +23493,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a65 - "404": *a66 + "403": *a64 + "404": *a65 "422": *a4 "429": *a5 /api/symptoms/events: @@ -23523,7 +23543,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a65 + "403": *a64 "422": *a4 "429": *a5 post: @@ -23549,8 +23569,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a65 - "404": *a66 + "403": *a64 + "404": *a65 "409": *a7 "422": *a4 "429": *a5 @@ -23580,8 +23600,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a65 - "404": &a67 + "403": *a64 + "404": &a66 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -23608,8 +23628,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a65 - "404": *a67 + "403": *a64 + "404": *a66 "422": *a4 "429": *a5 /api/day/index: @@ -23759,7 +23779,7 @@ paths: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 - "403": &a68 + "403": &a67 description: >- `module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it off server-wide. @@ -23796,7 +23816,7 @@ paths: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 - "403": *a68 + "403": *a67 "422": *a4 "429": *a5 /api/life-events: @@ -23814,7 +23834,7 @@ paths: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 - "403": &a69 + "403": &a68 description: >- Owner-only in v1.42: no share reaches life events, not a `profile` share, a whole-record share or MANAGE, and not a guardian acting for a managed profile. @@ -23854,7 +23874,7 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 - "403": *a69 + "403": *a68 "409": *a7 "422": *a4 "429": *a12 @@ -23885,8 +23905,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "403": *a69 - "404": &a70 + "403": *a68 + "404": &a69 description: No such life event on this account. content: application/json: @@ -23913,8 +23933,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "403": *a69 - "404": *a70 + "403": *a68 + "404": *a69 "422": *a4 "429": *a5 /api/search: @@ -23992,7 +24012,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "501": *a30 + "501": *a44 /api/medications/{id}/ingredients: put: tags: @@ -24034,7 +24054,7 @@ paths: "409": *a7 "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/medications/{id}/ingredients/extract: post: tags: @@ -24064,7 +24084,7 @@ paths: required: - file responses: - "202": *a39 + "202": *a37 "401": *a1 "403": *a19 "413": @@ -24075,7 +24095,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 "503": description: "`aiRuns.workerUnavailable`: the run could not be handed to the background worker. Nothing was read and nothing was charged; try again." @@ -24100,7 +24120,7 @@ paths: "403": *a11 "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/anamnesis/life-circumstances/ai-sections: patch: tags: @@ -24130,7 +24150,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/anamnesis/life-circumstances/confirm: post: tags: @@ -24154,7 +24174,7 @@ paths: "401": *a1 "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/anamnesis/audit-c: post: tags: @@ -24187,7 +24207,7 @@ paths: "409": *a7 "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/anamnesis/audit-c/{id}: delete: tags: @@ -24216,7 +24236,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/dawarich/connect: post: tags: @@ -24242,7 +24262,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/dawarich/status: get: tags: @@ -24259,7 +24279,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/dawarich/places: get: tags: @@ -24290,7 +24310,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 "502": description: "`dawarich.unreachable`." content: @@ -24320,7 +24340,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/dawarich/test: post: tags: @@ -24339,7 +24359,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/dawarich/sync: post: tags: @@ -24374,7 +24394,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/dawarich/disconnect: post: tags: @@ -24403,7 +24423,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/environment/travel/suggestions: get: tags: @@ -24420,7 +24440,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/environment/travel/suggestions/{id}/dismiss: post: tags: @@ -24450,7 +24470,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/medications/{id}/links: post: tags: @@ -24487,7 +24507,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 delete: tags: - Links @@ -24523,7 +24543,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/labs/{id}/links: post: tags: @@ -24560,7 +24580,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 delete: tags: - Links @@ -24596,7 +24616,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a44 /api/bugreport: post: security: [] @@ -31225,6 +31245,12 @@ components: 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))$ + biomarkerId: + type: string + minLength: 1 + maxLength: 64 + rememberAlias: + type: boolean required: - analyte - takenAt @@ -31312,6 +31338,13 @@ components: - type: "null" hidden: type: boolean + aliases: + maxItems: 50 + type: array + items: + type: string + minLength: 1 + maxLength: 120 description: Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. Set `hidden` to drop / restore the marker in the active list + lab-entry pickers. A rename that collides with another of the caller's markers is rejected 409. @@ -32713,6 +32746,12 @@ components: enum: - approve - reject + biomarkerId: + type: string + minLength: 1 + maxLength: 64 + rememberAlias: + type: boolean required: - factId - action @@ -35553,12 +35592,13 @@ components: import.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — - labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.merge.invalid, - labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, - labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, - labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, - labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, - labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, + labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate, + labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound, + labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, + labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, + labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, + labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, + labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found; managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, @@ -40024,6 +40064,31 @@ components: anyOf: - type: string - type: "null" + aliases: + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + maxLength: 120 + source: + type: string + enum: + - MERGE + - IMPORT_CHOICE + - MANUAL + createdAt: + type: string + minLength: 1 + required: + - name + - source + additionalProperties: {} createdAt: type: string minLength: 1 @@ -40034,6 +40099,42 @@ components: - name - unit additionalProperties: {} + biomarkerMerges: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + targetBiomarkerId: + type: string + minLength: 1 + targetName: + type: string + minLength: 1 + sourceBiomarkerId: + type: string + minLength: 1 + sourceName: + type: string + minLength: 1 + maxLength: 120 + readingCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + mergedAt: + type: string + minLength: 1 + required: + - targetName + - sourceBiomarkerId + - sourceName + - readingCount + - mergedAt + additionalProperties: {} illnessEpisodes: default: [] type: array @@ -43069,6 +43170,7 @@ components: - customMoodTags - labResults - biomarkers + - biomarkerMerges - illnessEpisodes - symptomDefinitions - lifeEvents @@ -69467,9 +69569,11 @@ components: enum: - duplicate - unit_mismatch + - unknown_biomarker description: "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair - that can be converted is written in the marker's unit and reported with `converted: true`." + that can be converted is written in the marker's unit and reported with `converted: true`. + `unknown_biomarker` (v1.43): the row named a `biomarkerId` that is not one of the record's markers." required: - analyte - reason diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6328b220d..f423e3f64 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9905,11 +9905,11 @@ model BiomarkerAlias { id String @id @default(cuid()) userId String @map("user_id") biomarkerId String @map("biomarker_id") - /// @pending-consumer(#v143-B3) The alias as written, shown on the marker. + /// The alias as written, shown on the marker. name String @db.VarChar(120) - /// @pending-consumer(#v143-B3) `foldForMatch(name)`, the match key; unique per account. + /// `foldForMatch(name)`, the match key; unique per account. nameFolded String @map("name_folded") @db.VarChar(120) - /// @pending-consumer(#v143-B3) `MERGE`, `IMPORT_CHOICE` or `MANUAL` (CHECK in migration 0403). + /// `MERGE`, `IMPORT_CHOICE` or `MANUAL` (CHECK in migration 0403). source String createdAt DateTime @default(now()) @map("created_at") @@ -9927,15 +9927,15 @@ model BiomarkerAlias { model BiomarkerMerge { id String @id @default(cuid()) userId String @map("user_id") - /// @pending-consumer(#v143-B3) The marker the source was folded into. + /// The marker the source was folded into. targetBiomarkerId String @map("target_biomarker_id") - /// @pending-consumer(#v143-B3) Id of the deleted source marker (no FK: the row is gone). + /// Id of the deleted source marker (no FK: the row is gone). sourceBiomarkerId String @map("source_biomarker_id") - /// @pending-consumer(#v143-B3) The source marker's name at merge time. + /// The source marker's name at merge time. sourceName String @map("source_name") @db.VarChar(120) - /// @pending-consumer(#v143-B3) How many readings moved from the source. + /// How many readings moved from the source. readingCount Int @map("reading_count") - /// @pending-consumer(#v143-B3) When the merge ran. + /// When the merge ran. mergedAt DateTime @default(now()) @map("merged_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index 102729345..e3c69dfbc 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -40,7 +40,6 @@ const CONTRACT_STUBS: Readonly> = { "anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"], "auth/claim/sso/route.ts": ["profile_claim.sso.start"], "auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"], - "biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"], "dawarich/connect/route.ts": ["dawarich.connect"], "dawarich/disconnect/route.ts": ["dawarich.disconnect"], "dawarich/places/route.ts": ["dawarich.places.get"], @@ -64,7 +63,7 @@ const CONTRACT_STUBS: Readonly> = { }; /** The size of the register when it was written; see the header. */ -const REGISTER_SIZE = 24; +const REGISTER_SIZE = 23; function routeFiles(): string[] { return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] }) diff --git a/src/app/api/biomarkers/[id]/__tests__/route.test.ts b/src/app/api/biomarkers/[id]/__tests__/route.test.ts index 9a7cb416d..9cf66d9dd 100644 --- a/src/app/api/biomarkers/[id]/__tests__/route.test.ts +++ b/src/app/api/biomarkers/[id]/__tests__/route.test.ts @@ -1,14 +1,28 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { - biomarker: { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() }, +vi.mock("@/lib/db", () => { + const prisma = { + biomarker: { + findFirst: vi.fn(), + findMany: vi.fn(async () => []), + update: vi.fn(), + delete: vi.fn(), + }, + // v1.43 — the marker's other spellings, read and replaced with it. + biomarkerAlias: { + findMany: vi.fn(async () => []), + deleteMany: vi.fn(), + createMany: vi.fn(), + }, + biomarkerMerge: { findFirst: vi.fn(async () => null) }, labResult: { deleteMany: vi.fn() }, auditLog: { create: vi.fn().mockResolvedValue({}) }, - $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), - }, -})); + }; + // `$transaction` is set in `beforeEach`: the batch form for the delete, the + // callback form for the edit. + return { prisma: { ...prisma, $transaction: vi.fn() } }; +}); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -82,9 +96,13 @@ beforeEach(() => { // v1.37.0 — the 422 breadcrumb goes through `auditLog()` and the route // chains `.catch()` on it; `resetAllMocks` clears the factory's resolution. vi.mocked(auditLog).mockResolvedValue(undefined as never); - vi.mocked(prisma.$transaction).mockImplementation((async ( - ops: Promise[], - ) => Promise.all(ops)) as never); + vi.mocked(prisma.$transaction).mockImplementation((async (arg: unknown) => + typeof arg === "function" + ? (arg as (tx: unknown) => Promise)(prisma) + : Promise.all(arg as Promise[])) as never); + vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.biomarkerMerge.findFirst).mockResolvedValue(null as never); }); describe("DELETE /api/biomarkers/[id]", () => { diff --git a/src/app/api/biomarkers/[id]/merge/route.ts b/src/app/api/biomarkers/[id]/merge/route.ts index d6d2b2fdd..ea266a552 100644 --- a/src/app/api/biomarkers/[id]/merge/route.ts +++ b/src/app/api/biomarkers/[id]/merge/route.ts @@ -7,19 +7,32 @@ * (`MERGE`), one `biomarker_merges` row is written per source and the sources * are deleted. A pair of units without a known conversion refuses the whole * merge (422 `labs.biomarker.merge.unitMismatch`); nothing moves. `dryRun` - * answers the same result and writes nothing. There is no undo. + * answers the same result and writes nothing, refusals included, so the + * preview can show them. There is no undo. See `src/lib/labs/biomarker-merge.ts`. * - * Contract authority: `requireRecordAuth("manage", "labs")` with the shared - * record-write ceiling. Until the merge lands the stub resolves the caller's - * own record only. + * Authority: `requireRecordAuth("manage", "labs")`, the same as the edit and + * the delete beside it, because a merge rewrites and deletes markers. The + * shared record-write ceiling is charged to the ACTOR (C1). Each deleted + * source files what was destroyed (C3): its id and how many readings it took + * along, never its name; the name stays in the merge ledger. */ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; +import { mergeBiomarkers as runMerge } from "@/lib/labs/biomarker-merge"; +import { annotate } from "@/lib/logging/context"; import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; +import { destroyedDetails } from "@/lib/sharing/audit-details"; import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge"; type RouteParams = { params: Promise<{ id: string }> }; @@ -32,10 +45,10 @@ async function mergeBiomarkers( request: NextRequest, { params }: RouteParams, ): Promise { - const { user } = await requireAuth(); + const { user, actor } = await requireRecordAuth("manage", "labs"); const { id } = await params; - const writeRl = await checkRecordWriteRateLimit(user.id); + const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { return apiError("Too many writes, try again later", 429, { errorCode: "record_write.rate_limited", @@ -58,5 +71,74 @@ async function mergeBiomarkers( }); } - return notImplemented("labs.biomarker.merge"); + const outcome = await runMerge({ + userId: user.id, + targetId: id, + sourceIds: parsed.data.sourceIds, + dryRun: parsed.data.dryRun, + }); + + if (outcome.kind === "notFound") { + return apiError("Biomarker not found", 404, { + errorCode: "labs.biomarker.notFound", + ids: outcome.ids, + }); + } + if (outcome.kind === "unitMismatch") { + annotate({ + action: { name: "labs.biomarker.merge.unit-mismatch" }, + meta: { biomarkerId: id, refused: outcome.sources.length }, + }); + return apiError( + "Some readings are in a unit that cannot be converted to the target's unit", + 422, + { + errorCode: "labs.biomarker.merge.unitMismatch", + sources: outcome.sources, + }, + ); + } + + const { result } = outcome; + annotate({ + action: { + name: result.dryRun + ? "labs.biomarker.merge.preview" + : "labs.biomarker.merge", + }, + meta: { + biomarkerId: id, + sources: result.sources.length, + movedReadings: result.movedReadings, + convertedReadings: result.convertedReadings, + }, + }); + if (result.dryRun) return apiSuccess(result); + + invalidateUserHealthScore(user.id); + const ipAddress = getClientIp(request); + for (const source of result.sources) { + await auditLog("labs.biomarker.merge", { + userId: user.id, + ipAddress, + // IDs and numbers only. The source's name is not filed here; the merge + // ledger keeps it with the row this audit points at. + details: { + biomarkerId: id, + mergeId: result.mergeId, + ...destroyedDetails({ + model: "Biomarker", + id: source.id, + label: null, + effectiveAt: new Date(), + extra: { + readingCount: source.readingCount, + converted: source.conversion === "converted", + }, + }), + }, + }); + } + + return apiSuccess(result); } diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index bdfdbae1b..0cc1601b9 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -17,9 +17,13 @@ import { import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { - decryptContextFromBytes, - encryptContextToBytes, -} from "@/lib/labs/biomarker-store"; + aliasNamesByMarker, + findAliasConflict, + replaceAliases, +} from "@/lib/labs/biomarker-aliases"; +import { serialiseBiomarker } from "@/lib/labs/biomarker-dto"; +import { mergedInto } from "@/lib/labs/biomarker-merge"; +import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; import { updateBiomarkerSchema } from "@/lib/validations/biomarkers"; @@ -38,37 +42,6 @@ import { effectiveBound, isInvertedRange } from "@/lib/validations/labs"; type RouteParams = { params: Promise<{ id: string }> }; -function serialiseBiomarker(row: { - id: string; - name: string; - unit: string; - lowerBound: number | null; - upperBound: number | null; - panel: string | null; - analyteKey: string | null; - hidden: boolean; - 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, - analyteKey: row.analyteKey, - hasContext: row.contextEncrypted !== null, - context: row.contextEncrypted - ? decryptContextFromBytes(row.contextEncrypted) - : null, - hidden: row.hidden, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { const { user } = await requireRecordAuth("read", "labs"); @@ -76,15 +49,23 @@ export const GET = apiHandler( const row = await prisma.biomarker.findFirst({ where: { id } }); if (!row || row.userId !== user.id) { - return apiError("Biomarker not found", 404); + // v1.43 — a marker merged into another names where it went, so a + // client holding the old id (a cached screen, a bookmark) can follow. + const into = await mergedInto(user.id, id); + return apiError("Biomarker not found", 404, { + errorCode: "labs.biomarker.notFound", + ...(into ? { mergedInto: into } : {}), + }); } + const aliases = await aliasNamesByMarker(prisma, user.id, [row]); + annotate({ action: { name: "labs.biomarker.get" }, meta: { biomarkerId: id }, }); - return apiSuccess(serialiseBiomarker(row)); + return apiSuccess(serialiseBiomarker(row, aliases.get(row.id) ?? [])); }, ); @@ -171,6 +152,20 @@ export const PUT = apiHandler( } } + // v1.43 — the aliases, when sent, become the marker's whole set. A + // spelling another marker already answers to (its name or one of its + // aliases) is refused rather than silently moved. + if (d.aliases !== undefined) { + const conflict = await findAliasConflict(prisma, user.id, id, d.aliases); + if (conflict) { + return apiError("Another biomarker already answers to this name", 409, { + errorCode: "labs.biomarker.alias.taken", + alias: conflict.alias, + biomarkerId: conflict.biomarkerId, + }); + } + } + // Build `data` field-by-field. `undefined` → leave the column untouched; // an explicit `null` on `context` / `panel` / a bound clears it. const data: Record = {}; @@ -212,7 +207,17 @@ export const PUT = apiHandler( : null; } - const updated = await prisma.biomarker.update({ where: { id }, data }); + const aliasesBefore = + (await aliasNamesByMarker(prisma, user.id, [existing])).get(id) ?? []; + const updated = await prisma.$transaction(async (tx) => { + const row = await tx.biomarker.update({ where: { id }, data }); + if (d.aliases !== undefined) { + await replaceAliases(tx, user.id, row, d.aliases); + } + return row; + }); + const aliasesAfter = + (await aliasNamesByMarker(prisma, user.id, [updated])).get(id) ?? []; invalidateUserHealthScore(user.id); await auditLog("biomarker.update", { @@ -241,7 +246,13 @@ export const PUT = apiHandler( panel: updated.panel, hidden: updated.hidden, }, - redacted: d.context !== undefined ? ["context"] : [], + redacted: [ + ...(d.context !== undefined ? ["context"] : []), + // Named, not quoted: the spellings are the person's own text. + ...(aliasesBefore.join("\n") !== aliasesAfter.join("\n") + ? ["aliases"] + : []), + ], }), }, }); @@ -251,7 +262,7 @@ export const PUT = apiHandler( meta: { biomarkerId: id }, }); - return apiSuccess(serialiseBiomarker(updated)); + return apiSuccess(serialiseBiomarker(updated, aliasesAfter)); }, ); diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index 8736ab76d..2ff94ce4f 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -13,10 +13,9 @@ import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; -import { - decryptContextSoft, - encryptContextToBytes, -} from "@/lib/labs/biomarker-store"; +import { aliasNamesByMarker } from "@/lib/labs/biomarker-aliases"; +import { serialiseBiomarker } from "@/lib/labs/biomarker-dto"; +import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; import { createBiomarkerSchema } from "@/lib/validations/biomarkers"; @@ -45,35 +44,6 @@ import { createBiomarkerSchema } from "@/lib/validations/biomarkers"; // a bad-key / malformed row) so one un-decryptable row never 500s the whole // catalog — the single-resource GET is the authoritative read for the // plaintext context. This bounds the fail-closed blast radius to one marker. -function serialiseBiomarker(row: { - id: string; - name: string; - unit: string; - lowerBound: number | null; - upperBound: number | null; - panel: string | null; - analyteKey: string | null; - hidden: boolean; - 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, - analyteKey: row.analyteKey, - hasContext: row.contextEncrypted !== null, - context: decryptContextSoft(row.contextEncrypted), - hidden: row.hidden, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - export const GET = apiHandler(async () => { const { user } = await requireRecordAuth("read", "labs"); @@ -84,12 +54,19 @@ export const GET = apiHandler(async () => { orderBy: [{ hidden: "asc" }, { name: "asc" }], }); + // v1.43 — every marker's other spellings, one read for the whole list. + const aliases = await aliasNamesByMarker(prisma, user.id, rows); + annotate({ action: { name: "labs.biomarker.list" }, meta: { total: rows.length }, }); - return apiSuccess({ biomarkers: rows.map(serialiseBiomarker) }); + return apiSuccess({ + biomarkers: rows.map((row) => + serialiseBiomarker(row, aliases.get(row.id) ?? [], { soft: true }), + ), + }); }); export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker)); @@ -179,5 +156,5 @@ async function postBiomarker(request: NextRequest) { meta: { biomarkerId: created.id }, }); - return apiSuccess(serialiseBiomarker(created), 201); + return apiSuccess(serialiseBiomarker(created, [], { soft: true }), 201); } diff --git a/src/app/api/documents/inbound/[id]/confirm/route.ts b/src/app/api/documents/inbound/[id]/confirm/route.ts index a4fbba519..0fc910a1f 100644 --- a/src/app/api/documents/inbound/[id]/confirm/route.ts +++ b/src/app/api/documents/inbound/[id]/confirm/route.ts @@ -100,7 +100,12 @@ async function confirmDocument( } try { - const ref = await commitApprovedFact(user.id, fact); + // v1.43 (D1223) — an OBSERVATION may name the marker the person chose + // on the review screen; the label is then remembered as its alias. + const ref = await commitApprovedFact(user.id, fact, { + biomarkerId: decision.biomarkerId, + rememberAlias: decision.rememberAlias, + }); await prisma.extractedFact.update({ where: { id: fact.id }, data: { diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts index a03e75fe6..74a90428d 100644 --- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -51,6 +51,7 @@ vi.mock("@/lib/documents/store", () => ({ encryptFactData: vi.fn(() => new Uint8Array([1])), encryptFactProvenance: vi.fn(() => new Uint8Array([2])), serialiseDocumentDetail: vi.fn(() => ({ id: "doc-1", facts: [] })), + markerIndexForFacts: vi.fn(async () => null), })); vi.mock("@/lib/documents/content-index", () => ({ loadDocumentChatText: vi.fn(), diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index e011909a8..e3f9eebbf 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -55,7 +55,10 @@ import { } from "@/lib/ai/coach/budget"; import type { ProviderChainType } from "@/lib/ai/provider-chain"; import { prisma } from "@/lib/db"; -import { serialiseDocumentDetail } from "@/lib/documents/store"; +import { + markerIndexForFacts, + serialiseDocumentDetail, +} from "@/lib/documents/store"; import { requireDocumentTextProvider, requireDocumentVisionProvider, @@ -241,7 +244,19 @@ async function runNow( origin: { ipAddress: getClientIp(request), worker: false }, }); if (!outcome.ok) return outcomeResponse(outcome); - return apiSuccess(serialiseDocumentDetail(outcome.data, outcome.data.facts)); + return apiSuccess( + serialiseDocumentDetail( + outcome.data, + outcome.data.facts, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + await markerIndexForFacts(userId, outcome.data.facts), + ), + ); } /** TEXT mode — structure in-browser-OCR'd text against the stored row. */ diff --git a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts index 498a7ce25..808f6c789 100644 --- a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts +++ b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts @@ -18,7 +18,11 @@ import { safeJson, } from "@/lib/api-response"; import { prisma } from "@/lib/db"; -import { encryptFactData, serialiseFact } from "@/lib/documents/store"; +import { + encryptFactData, + markerIndexForFacts, + serialiseFact, +} from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { @@ -125,6 +129,8 @@ export const PATCH = apiHandler( meta: { documentId: id, factId: fact.id, factType: fact.factType }, }); - return apiSuccess(serialiseFact(updated)); + return apiSuccess( + serialiseFact(updated, await markerIndexForFacts(user.id, [updated])), + ); }, ); diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index b6f512a27..0d3468b07 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -32,7 +32,10 @@ import { replaceVaccinationLinks, } from "@/lib/documents/links"; import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; -import { serialiseDocumentDetail } from "@/lib/documents/store"; +import { + markerIndexForFacts, + serialiseDocumentDetail, +} from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -138,6 +141,8 @@ export const GET = apiHandler( thumbnail !== null, visitLinks.get(document.id) ?? [], doseLinks === null ? null : (doseLinks.get(document.id) ?? []), + // v1.43 — the marker each pending lab value resolves to, or choices. + await markerIndexForFacts(user.id, document.facts), ), ); }, diff --git a/src/app/api/labs/ocr/commit/__tests__/route.test.ts b/src/app/api/labs/ocr/commit/__tests__/route.test.ts index 3ab2468ad..b855c1fd6 100644 --- a/src/app/api/labs/ocr/commit/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/commit/__tests__/route.test.ts @@ -16,6 +16,9 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn(), create: vi.fn(), }, + // v1.43 — the account's markers and aliases, loaded once per commit. + biomarker: { findMany: vi.fn() }, + biomarkerAlias: { findMany: vi.fn(), upsert: vi.fn() }, auditLog: { create: vi.fn() }, }, })); @@ -107,6 +110,8 @@ beforeEach(() => { // No live reading collides at commit time — the in-batch path is what's // under test, so the DB-visibility check returns empty for every row. vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never); let nextId = 0; vi.mocked(prisma.labResult.create).mockImplementation((() => Promise.resolve({ id: `lr-${++nextId}` })) as never); diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 7e0a00d17..9dbc01d45 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -34,6 +34,13 @@ import { withIdempotency } from "@/lib/idempotency"; import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; +import { rememberImportAlias } from "@/lib/labs/biomarker-aliases"; +import { + addAliasToIndex, + loadMarkerIndex, + resolveInIndex, + type IndexedMarker, +} from "@/lib/labs/biomarker-resolve"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { sameLabUnit } from "@/lib/labs/unit-normalise"; @@ -69,13 +76,20 @@ async function isDuplicate( userId: string, row: OcrCommitRow, tz: string, + /** The marker the row resolves to, when it resolves to one already. */ + biomarkerId: string | null, ): Promise { const day = labReadingDay(new Date(row.takenAt), tz); const rows = await prisma.labResult.findMany({ where: { userId, deletedAt: null, - analyte: { equals: row.analyte.trim(), mode: "insensitive" }, + // The printed name, or a reading already filed under the marker this + // name resolves to: an earlier report may have spelled it another way. + OR: [ + { analyte: { equals: row.analyte.trim(), mode: "insensitive" } }, + ...(biomarkerId ? [{ biomarkerId }] : []), + ], takenAt: labReadingDaySearchRange(day), }, select: { @@ -123,8 +137,15 @@ async function isDuplicate( * Mirrors the `isDuplicate` match semantics so in-batch dedup no longer depends * on a prior row autocommitting before the next row's live query runs. */ -function inBatchKey(row: OcrCommitRow, tz: string): string { - const analyte = row.analyte.trim().toLowerCase(); +function inBatchKey( + row: OcrCommitRow, + tz: string, + biomarkerId: string | null, +): string { + // Two spellings of one marker in one document are one marker (D1223). + const analyte = biomarkerId + ? `bm:${biomarkerId}` + : row.analyte.trim().toLowerCase(); const dayKey = labReadingDay(new Date(row.takenAt), tz); const valuePart = row.value !== undefined @@ -188,14 +209,59 @@ async function commitOcrRows(request: NextRequest) { const writtenInBatch = new Set(); const tz = await resolveUserTimezone(user.id); + // v1.43 (D1223) — the account's markers and aliases, loaded once: every row + // resolves its name against them (name, alias, catalog abbreviation) the + // way the review did, and a marker minted for one row is found by the next. + const markerIndex = await loadMarkerIndex(prisma, user.id); + let aliasesRemembered = 0; for (const row of parsed.data.rows) { - const key = inBatchKey(row, tz); - if (writtenInBatch.has(key) || (await isDuplicate(user.id, row, tz))) { + const isQualitative = row.valueText !== undefined; + + // The marker the person picked on the review screen, if they picked one. + let chosen: IndexedMarker | null = null; + if (row.biomarkerId) { + chosen = + markerIndex.markers.find((m) => m.id === row.biomarkerId) ?? null; + if (!chosen) { + skipped.push({ + analyte: row.analyte.trim(), + reason: "unknown_biomarker", + }); + continue; + } + // The choice is remembered, so the next report spelled this way + // matches on its own. + if (row.rememberAlias !== false) { + const wrote = await rememberImportAlias( + prisma, + user.id, + chosen, + row.analyte, + ); + if (wrote) { + addAliasToIndex(markerIndex, chosen.id, row.analyte); + aliasesRemembered += 1; + } + } + } + const resolvedId = + chosen?.id ?? + resolveInIndex( + markerIndex, + row.analyte, + isQualitative ? null : (row.unit ?? null), + )?.marker.id ?? + null; + + const key = inBatchKey(row, tz, resolvedId); + if ( + writtenInBatch.has(key) || + (await isDuplicate(user.id, row, tz, resolvedId)) + ) { skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" }); continue; } - const isQualitative = row.valueText !== undefined; // The window the scan read off the report. The confirmed numeric bounds // win over a re-parse of the string (the human may have corrected them on // the review screen); the string rides along either way. @@ -203,14 +269,20 @@ async function commitOcrRows(request: NextRequest) { const sourceLow = row.referenceLow ?? printed?.low ?? null; const sourceHigh = row.referenceHigh ?? printed?.high ?? null; const sourceText = printed?.text ?? null; - const biomarker = await resolveOrMintBiomarker(user.id, { - analyte: row.analyte, - // A qualitative reading has no numeric unit / range. - unit: isQualitative ? (row.unit ?? "") : (row.unit as string), - referenceLow: isQualitative ? null : sourceLow, - referenceHigh: isQualitative ? null : sourceHigh, - panel: row.panel ?? null, - }); + const biomarker = chosen + ? chosen + : await resolveOrMintBiomarker( + user.id, + { + analyte: row.analyte, + // A qualitative reading has no numeric unit / range. + unit: isQualitative ? (row.unit ?? "") : (row.unit as string), + referenceLow: isQualitative ? null : sourceLow, + referenceHigh: isQualitative ? null : sourceHigh, + panel: row.panel ?? null, + }, + { index: markerIndex }, + ); // The marker's unit is the one the reading is stored under. A row that // states another unit is converted when the marker's analyte is known and @@ -273,6 +345,8 @@ async function commitOcrRows(request: NextRequest) { }); writtenInBatch.add(key); + // A freshly minted marker is now the row's; key the batch on it too. + writtenInBatch.add(inBatchKey(row, tz, biomarker.id)); inserted.push(serialiseLabResult(created, biomarker)); linkable.push({ labResultId: created.id, @@ -319,7 +393,11 @@ async function commitOcrRows(request: NextRequest) { await auditLog("labs.ocr.commit", { userId: user.id, ipAddress: getClientIp(request), - details: { count: inserted.length, skipped: skipped.length }, + details: { + count: inserted.length, + skipped: skipped.length, + aliasesRemembered, + }, }); annotate({ diff --git a/src/components/labs/__tests__/ocr-review-validation.test.ts b/src/components/labs/__tests__/ocr-review-validation.test.ts index 234d18a59..aca6c0535 100644 --- a/src/components/labs/__tests__/ocr-review-validation.test.ts +++ b/src/components/labs/__tests__/ocr-review-validation.test.ts @@ -205,6 +205,9 @@ describe("a reading in another unit than its marker's", () => { markerUnit, markerAnalyteKey: null, duplicateOf: null, + biomarkerId: "bm-glucose", + matchKind: "name" as const, + suggestions: [], }); const [same, other, fresh] = seedReviewRows( [dto("mmol/L", "mmol/L"), dto("mmol/L", "mg/dL"), dto("mmol/L", null)], @@ -271,6 +274,9 @@ describe("a reading in another unit that can be converted", () => { markerUnit: "mg/dL", markerAnalyteKey: "fasting-glucose", duplicateOf: null, + biomarkerId: "bm-glucose", + matchKind: "name" as const, + suggestions: [], }; const [converts, blocked] = seedReviewRows( [dto, { ...dto, markerAnalyteKey: null }], diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts index 423d42f19..e6eddc582 100644 --- a/src/lib/doctor-report/clinical-records.ts +++ b/src/lib/doctor-report/clinical-records.ts @@ -34,8 +34,9 @@ import type { /** * Structured lab results over the window, reduced to the latest reading per - * analyte with a count, so the report is a concise panel rather than a raw - * dump. Analyte keys fold case-insensitively. + * marker with a count, so the report is a concise panel rather than a raw + * dump. Readings group by their biomarker and carry its name; a legacy + * unlinked reading groups by its own name, case-insensitively. */ export async function loadLabResults( userId: string, @@ -58,6 +59,8 @@ export async function loadLabResults( sourceReferenceText: true, sourceUnit: true, takenAt: true, + biomarkerId: true, + biomarker: { select: { name: true, panel: true } }, }, }); const byAnalyte = new Map< @@ -65,7 +68,13 @@ export async function loadLabResults( NonNullable[number] >(); for (const r of rows) { - const key = r.analyte.toLowerCase(); + // One line per marker: readings filed under one biomarker are one series + // whatever each report printed (a merge keeps the printed `analyte` on + // every reading), and the marker's own name heads it. A legacy unlinked + // reading still groups by its name. + const key = r.biomarkerId + ? `bm:${r.biomarkerId}` + : `analyte:${r.analyte.toLowerCase()}`; const prev = byAnalyte.get(key); // The row already stamps the catalog band it was written under; the // reading's own report window, when present, is what the physician read @@ -77,8 +86,8 @@ export async function loadLabResults( r, ); byAnalyte.set(key, { - panel: r.panel, - analyte: r.analyte, + panel: r.biomarker?.panel ?? r.panel, + analyte: r.biomarker?.name ?? r.analyte, value: r.value, valueText: r.valueText, unit: r.unit, diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index a29b4ddc6..8c92183d8 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -27,7 +27,11 @@ import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { decryptFactData } from "@/lib/documents/store"; -import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; +import { rememberImportAlias } from "@/lib/labs/biomarker-aliases"; +import { + resolveOrMintBiomarker, + type MintedBiomarker, +} from "@/lib/labs/biomarker-store"; import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { isUnreadableRange, @@ -67,9 +71,19 @@ function statedDateOrNow(date: string | null): Date { return new Date(); } +/** + * v1.43 (D1223) — the marker the person picked for an OBSERVATION on the + * review screen, and whether to remember the label as its alias. + */ +export interface ObservationChoice { + biomarkerId?: string; + rememberAlias?: boolean; +} + async function commitObservation( userId: string, data: ObservationFactData, + choice: ObservationChoice = {}, ): Promise { const isQualitative = typeof data.value !== "number"; // A numeric reading needs a unit so the catalog has something to mint with — @@ -121,13 +135,41 @@ async function commitObservation( }); } - const biomarker = await resolveOrMintBiomarker(userId, { - analyte: data.label, - unit: isQualitative ? (data.unit ?? "") : (data.unit as string), - referenceLow: isQualitative ? null : sourceLow, - referenceHigh: isQualitative ? null : sourceHigh, - panel: null, - }); + // The marker the person chose wins; otherwise the label resolves through + // the account's names, aliases and the catalog, or a marker is minted. + let biomarker: MintedBiomarker; + if (choice.biomarkerId) { + const chosen = await prisma.biomarker.findFirst({ + where: { id: choice.biomarkerId, userId }, + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + analyteKey: true, + }, + }); + if (!chosen) { + throw new FactCommitError( + "observation.unknownBiomarker", + "The chosen marker does not exist", + ); + } + if (choice.rememberAlias !== false) { + await rememberImportAlias(prisma, userId, chosen, data.label); + } + biomarker = chosen; + } else { + biomarker = await resolveOrMintBiomarker(userId, { + analyte: data.label, + unit: isQualitative ? (data.unit ?? "") : (data.unit as string), + referenceLow: isQualitative ? null : sourceLow, + referenceHigh: isQualitative ? null : sourceHigh, + panel: null, + }); + } // A unit that differs from an EXISTING marker's is converted when the // marker's analyte is known and the registry has the pair (a document states // "6.1 mmol/L" against a marker stored in mg/dL), keeping what the document @@ -274,12 +316,13 @@ async function commitMedication( export async function commitApprovedFact( userId: string, fact: ExtractedFact, + choice: ObservationChoice = {}, ): Promise { // Decrypt the staged payload at confirm time to write it into the normal // structured store. Fail-closed: a bad key id throws and aborts the commit. const data = decryptFactData(fact.dataEncrypted); if (fact.factType === "OBSERVATION") { - return commitObservation(userId, data as ObservationFactData); + return commitObservation(userId, data as ObservationFactData, choice); } if (fact.factType === "CONDITION") { return commitCondition(userId, data as ConditionFactData); diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 41849833b..a6ef65d24 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -16,7 +16,13 @@ import type { InboundDocument, InboundDocumentStatus, } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; import { servingClassFor } from "@/lib/documents/upload-policy"; +import { + loadMarkerIndex, + matchForReview, + type MarkerIndex, +} from "@/lib/labs/biomarker-resolve"; import { toDocumentSourceSystem, toIndexOutcome, @@ -32,6 +38,7 @@ import type { FactProvenance, InboundDocumentDetailDto, InboundDocumentDto, + ObservationFactData, InboundDocumentKindValue, InboundFactType, } from "@/lib/validations/inbound-documents"; @@ -254,19 +261,54 @@ export function serialiseDocument( }; } -/** Map a persisted staged-fact row to the review DTO. */ -export function serialiseFact(fact: ExtractedFact): ExtractedFactDto { - return { +/** + * Map a persisted staged-fact row to the review DTO. + * + * v1.43 (D1223) — with the account's `markerIndex`, a pending OBSERVATION + * also says which marker its label resolves to (`biomarkerId`, `matchKind`) + * or offers up to three (`suggestions`), the same resolution the confirm + * applies. Without an index, or for any other fact, the fields are absent. + */ +export function serialiseFact( + fact: ExtractedFact, + markerIndex?: MarkerIndex | null, +): ExtractedFactDto { + const data = decryptFactData(fact.dataEncrypted); + const dto: ExtractedFactDto = { id: fact.id, factType: fact.factType as InboundFactType, status: fact.status as ExtractedFactStatus, confidence: fact.confidence, needsReview: fact.needsReview, - data: decryptFactData(fact.dataEncrypted), + data, provenance: decryptFactProvenance(fact.provenanceEncrypted), committedRecordId: fact.committedRecordId, committedRecordType: fact.committedRecordType, }; + if (markerIndex && fact.factType === "OBSERVATION") { + const observation = data as ObservationFactData; + const isNumeric = typeof observation.value === "number"; + const { match, suggestions } = matchForReview( + markerIndex, + observation.label ?? "", + isNumeric ? (observation.unit ?? null) : null, + ); + dto.biomarkerId = match?.marker.id ?? null; + dto.matchKind = match?.matchKind ?? null; + dto.suggestions = suggestions; + } + return dto; +} + +/** The marker index a review needs, or null when no fact would use it. */ +export async function markerIndexForFacts( + userId: string, + facts: readonly Pick[], +): Promise { + const needed = facts.some( + (f) => f.factType === "OBSERVATION" && f.status === "PENDING", + ); + return needed ? loadMarkerIndex(prisma, userId) : null; } /** Assemble the detail DTO (document + its staged facts + links). */ @@ -279,6 +321,7 @@ export function serialiseDocumentDetail( hasThumbnail = false, encounterLinks: DocumentEncounterLinkDto[] = [], vaccinationLinks: DocumentVaccinationLinkDto[] | null = [], + markerIndex: MarkerIndex | null = null, ): InboundDocumentDetailDto { const pendingCount = facts.filter((f) => f.status === "PENDING").length; // `factCount` excludes REJECTED facts (a rejected fact is discarded, not part @@ -327,7 +370,7 @@ export function serialiseDocumentDetail( hasThumbnail, encounterLinks, ), - facts: facts.map(serialiseFact), + facts: facts.map((fact) => serialiseFact(fact, markerIndex)), vaccinationLinks, summary, summaryGeneratedAt: doc.summaryGeneratedAt diff --git a/src/lib/export/__tests__/records-backup.test.ts b/src/lib/export/__tests__/records-backup.test.ts index 8a1eb2838..c0d68ee1b 100644 --- a/src/lib/export/__tests__/records-backup.test.ts +++ b/src/lib/export/__tests__/records-backup.test.ts @@ -73,6 +73,8 @@ function buildMockPrisma() { }, ]), }, + biomarkerAlias: { findMany: vi.fn().mockResolvedValue([]) }, + biomarkerMerge: { findMany: vi.fn().mockResolvedValue([]) }, illnessEpisode: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index 3bad664fe..a4dbcd92b 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -160,6 +160,8 @@ function sourceClient() { // Records section: empty, exercised by its own suite. labResult: empty, biomarker: empty, + biomarkerAlias: empty, + biomarkerMerge: empty, illnessEpisode: empty, allergy: empty, familyHistoryEntry: empty, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 7409fc369..aeba3f647 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -576,6 +576,14 @@ export const TWO_ENDED_MODELS = [ // questionnaire over every module decision taken in Settings since. It is // carried and asserted, not defaulted. "OnboardingRecord", + // v1.43 (D1223) — a marker's other spellings ride on the marker as + // `aliases[]`, and the merge ledger travels as its own list whose target is + // resolved by id or by name like a lab result's marker. Without the aliases a + // restored account is asked again at the next scan which marker each + // spelling belongs to; without the ledger a link to a merged marker reads as + // a plain 404 instead of leading to the marker it went into. + "BiomarkerAlias", + "BiomarkerMerge", ] as const; /** One model claimed to travel both ways. */ @@ -614,10 +622,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly> = { export const COVERAGE_PENDING: Readonly> = { // v1.43 — the new tables are declared here with the schema and move to // TWO_ENDED_MODELS when their section writer and restore branch land. - BiomarkerAlias: - "Every other spelling a marker answers to: the names folded in by a merge, the choices made at a scan or a document import, the ones typed on the marker. Without them a restored account asks again at the next scan which marker each spelling belongs to.", - BiomarkerMerge: - "The ledger of which markers were merged into which. Without it a link or a cached screen that still names a merged marker reads as a plain 404 instead of being sent to the marker it went into.", MedicationIngredient: "What one tablet or capsule of a supplement contains. Without it a restored account's supplements stop counting toward the day's micronutrients, and the composition has to be typed in again from the label.", DawarichDay: diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index b0ef9e7a2..f2ca65bd2 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -126,10 +126,38 @@ export interface BiomarkerBackupEntry { context: string | null; /** The catalogue key the marker's name and unit resolve to. */ analyteKey?: string | null; + /** + * v1.43 (D1223) — the other spellings the marker answers to, with where + * each came from. Absent on files written before them. + */ + aliases?: BiomarkerAliasBackupEntry[]; createdAt?: string; updatedAt?: string; } +export interface BiomarkerAliasBackupEntry { + /** Present in canonical DR payloads. */ + id?: string; + name: string; + source: "MERGE" | "IMPORT_CHOICE" | "MANUAL"; + createdAt?: string; +} + +/** + * v1.43 (D1223) — one marker folded into another. The target travels by id + * in a disaster-recovery file and by name in both, the way a lab result's + * marker does; the source is gone, so its id and name are the record. + */ +export interface BiomarkerMergeBackupEntry { + id?: string; + targetBiomarkerId?: string; + targetName: string; + sourceBiomarkerId: string; + sourceName: string; + readingCount: number; + mergedAt: string; +} + export interface WorkoutBackupEntry { /** Present in canonical DR payloads for manual workouts without externalId. */ id?: string; @@ -237,6 +265,7 @@ type CanonicalAllergy = AllergyDTO & { export interface RecordsBackupSection { labResults: LabResultBackupEntry[]; biomarkers: BiomarkerBackupEntry[]; + biomarkerMerges: BiomarkerMergeBackupEntry[]; illnessEpisodes: CanonicalIllnessEpisode[]; allergies: CanonicalAllergy[]; familyHistory: FamilyHistoryEntryDTO[]; @@ -286,6 +315,8 @@ export async function buildRecordsBackupSection( PrismaClient, | "labResult" | "biomarker" + | "biomarkerAlias" + | "biomarkerMerge" | "illnessEpisode" | "allergy" | "familyHistoryEntry" @@ -351,6 +382,8 @@ export async function buildRecordsBackupSection( const [ labResultRows, biomarkerRows, + biomarkerAliasRows, + biomarkerMergeRows, episodeRows, allergyRows, familyRows, @@ -366,6 +399,22 @@ export async function buildRecordsBackupSection( where: { userId }, orderBy: { name: "asc" }, }), + prisma.biomarkerAlias.findMany({ + where: { userId }, + orderBy: [{ biomarkerId: "asc" }, { nameFolded: "asc" }], + select: { + id: true, + biomarkerId: true, + name: true, + source: true, + createdAt: true, + }, + }), + prisma.biomarkerMerge.findMany({ + where: { userId }, + orderBy: { mergedAt: "asc" }, + include: { target: { select: { name: true } } }, + }), prisma.illnessEpisode.findMany({ where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { onsetAt: "desc" }, @@ -429,6 +478,32 @@ export async function buildRecordsBackupSection( biomarkerName: r.biomarker?.name ?? null, })); + const aliasesByMarker = new Map(); + for (const alias of biomarkerAliasRows) { + const list = aliasesByMarker.get(alias.biomarkerId) ?? []; + list.push({ + name: alias.name, + source: alias.source as BiomarkerAliasBackupEntry["source"], + ...(disasterRecovery + ? { id: alias.id, createdAt: alias.createdAt.toISOString() } + : {}), + }); + aliasesByMarker.set(alias.biomarkerId, list); + } + + const biomarkerMerges: BiomarkerMergeBackupEntry[] = biomarkerMergeRows.map( + (m) => ({ + ...(disasterRecovery + ? { id: m.id, targetBiomarkerId: m.targetBiomarkerId } + : {}), + targetName: m.target.name, + sourceBiomarkerId: m.sourceBiomarkerId, + sourceName: m.sourceName, + readingCount: m.readingCount, + mergedAt: m.mergedAt.toISOString(), + }), + ); + const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({ ...(disasterRecovery ? { @@ -449,6 +524,7 @@ export async function buildRecordsBackupSection( decryptContextFromBytes, ), analyteKey: b.analyteKey, + aliases: aliasesByMarker.get(b.id) ?? [], })); const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery @@ -598,6 +674,7 @@ export async function buildRecordsBackupSection( return { labResults, biomarkers, + biomarkerMerges, illnessEpisodes, allergies, familyHistory, diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index b2659d8ea..2ee7c4416 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -72,6 +72,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; import type { BackupMeasurement } from "@/lib/validations/backup"; import { isCompatibleSchemaVersion, @@ -2076,6 +2077,76 @@ export async function restoreBackup( }); biomarkerByName.set(biomarker.name, created.id); restoredBiomarkerIds.add(created.id); + // v1.43 (D1223) — the other spellings, folded the way the resolver + // reads them, so the next scan matches them again. + const aliasRows = (biomarker.aliases ?? []) + .map((alias) => ({ + id: alias.id, + name: alias.name.trim().slice(0, 120), + nameFolded: foldForMatch(alias.name).slice(0, 120), + source: alias.source, + createdAt: alias.createdAt, + })) + .filter( + (alias) => + alias.nameFolded !== "" && + alias.nameFolded !== foldForMatch(biomarker.name), + ); + if (aliasRows.length > 0) { + await tx.biomarkerAlias.createMany({ + data: aliasRows.map((alias) => ({ + ...(alias.id ? { id: alias.id } : {}), + userId: ownerId, + biomarkerId: created.id, + name: alias.name, + nameFolded: alias.nameFolded, + source: alias.source, + ...(alias.createdAt + ? { createdAt: new Date(alias.createdAt) } + : {}), + })), + // One spelling answers to one marker per account; a file that + // names it twice keeps the first. + skipDuplicates: true, + }); + } + } + + // v1.43 (D1223) — the merge ledger, against the markers just written: + // by id where the file carries one this restore kept, by name + // otherwise. A row whose target the file does not carry has nothing + // to point at and is left out (counted on the event). + let unresolvedMerges = 0; + const mergeRows = (payload.biomarkerMerges ?? []).flatMap((merge) => { + const targetId = + merge.targetBiomarkerId && + restoredBiomarkerIds.has(merge.targetBiomarkerId) + ? merge.targetBiomarkerId + : biomarkerByName.get(merge.targetName); + if (!targetId) { + unresolvedMerges += 1; + return []; + } + return [ + { + ...(merge.id ? { id: merge.id } : {}), + userId: ownerId, + targetBiomarkerId: targetId, + sourceBiomarkerId: merge.sourceBiomarkerId, + sourceName: merge.sourceName, + readingCount: merge.readingCount, + mergedAt: new Date(merge.mergedAt), + }, + ]; + }); + if (mergeRows.length > 0) { + await tx.biomarkerMerge.createMany({ data: mergeRows }); + } + if (unresolvedMerges > 0) { + annotate({ + action: { name: "backup.restore.biomarkerMerge.unresolved" }, + meta: { count: unresolvedMerges }, + }); } // Collected for the staged facts further down: an approved fact's diff --git a/src/lib/fhir/resources/labs.ts b/src/lib/fhir/resources/labs.ts index 60b70d019..3b9bf0dab 100644 --- a/src/lib/fhir/resources/labs.ts +++ b/src/lib/fhir/resources/labs.ts @@ -35,9 +35,9 @@ export function labObservations( const observations: FhirObservation[] = []; for (const lab of data.labResults ?? []) { - // Forward-compat: when the DTO carries a resolved canonical analyte name, - // thread it as the third arg so the catalog name wins over the free-text - // `analyte`. Absent today. + // `analyte` is the marker's own name (v1.43): readings of one biomarker + // are one Observation whatever each report printed, so two spellings + // merged into one marker never reach a receiver as two analytes. const coding = resolveLabCoding(lab.analyte, lab.unit); const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte; const code = coding diff --git a/src/lib/labs/biomarker-dto.ts b/src/lib/labs/biomarker-dto.ts new file mode 100644 index 000000000..2c0294d83 --- /dev/null +++ b/src/lib/labs/biomarker-dto.ts @@ -0,0 +1,56 @@ +/** + * The `Biomarker` DTO, one shape for the list, the detail, the edit and the + * merge result. The encrypted context bytes are never echoed: `hasContext` + * flags them and `context` carries the plaintext. `aliases` are the other + * spellings the marker answers to (v1.43), sorted, without its own name. + */ +import { + decryptContextFromBytes, + decryptContextSoft, +} from "@/lib/labs/biomarker-store"; + +export interface BiomarkerDtoRow { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + analyteKey: string | null; + hidden: boolean; + contextEncrypted: Uint8Array | null; + createdAt: Date; + updatedAt: Date; +} + +/** + * `soft` decrypts fail-soft (the list: one bad row must not 500 the catalog); + * otherwise a decrypt failure throws so a key gap surfaces on the detail. + */ +export function serialiseBiomarker( + row: BiomarkerDtoRow, + aliases: readonly string[], + options: { soft?: boolean } = {}, +) { + return { + id: row.id, + name: row.name, + unit: row.unit, + lowerBound: row.lowerBound, + upperBound: row.upperBound, + panel: row.panel, + analyteKey: row.analyteKey, + hasContext: row.contextEncrypted !== null, + context: options.soft + ? decryptContextSoft(row.contextEncrypted) + : row.contextEncrypted + ? decryptContextFromBytes(row.contextEncrypted) + : null, + hidden: row.hidden, + aliases: [...aliases], + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export type BiomarkerDto = ReturnType; diff --git a/src/lib/labs/biomarker-merge.ts b/src/lib/labs/biomarker-merge.ts new file mode 100644 index 000000000..ac8c545f8 --- /dev/null +++ b/src/lib/labs/biomarker-merge.ts @@ -0,0 +1,388 @@ +/** + * Merge markers into one (D1223): "Alk.P" and "Alkaline Phosphatase" become + * one marker with one history. + * + * The plan and the write are one function, so a dry run answers exactly what + * the merge would do: it reads the same rows, converts the same values and + * returns the same numbers, and only the write step is skipped. + * + * What a merge does, in one transaction: + * - every reading of every source moves to the target. A reading in a unit + * other than the target's is converted with the target's analyte + * (`convertLabValue`), together with the windows stamped on it; the value + * and unit the report printed stay in `sourceValue` / `sourceUnit` (a + * reading that was already converted keeps its original pair). The + * reading's own `analyte` stays the name that was printed. + * - a pair of units the registry cannot convert refuses the WHOLE merge; + * nothing moves, not even the sources that would convert; + * - medication efficacy targets follow their marker; + * - each source's name and its aliases become aliases of the target + * (`MERGE`), unless the target's name or another marker already answers + * to that spelling; + * - one `biomarker_merges` row per source, and the rows that already pointed + * at a source now point at the target, so an id merged twice still finds + * where it went; + * - the sources are deleted. + * + * There is no undo. The ledger keeps what a later split would need to know + * (which marker, which name, how many readings). + */ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; + +import { matchAnalyteKey } from "./analyte-match"; +import { + BIOMARKER_ALIAS_MAX_LENGTH, + aliasNamesByMarker, +} from "./biomarker-aliases"; +import { serialiseBiomarker, type BiomarkerDto } from "./biomarker-dto"; +import { + convertLabRange, + convertLabValue, + type ConversionRefusal, +} from "./unit-conversion/convert"; +import { sameLabUnit } from "./unit-normalise"; + +export type MergeConversion = "none" | "converted" | "refused"; + +export interface MergeSourceSummary { + id: string; + name: string; + unit: string; + readingCount: number; + conversion: MergeConversion; + refusal: ConversionRefusal | null; +} + +export interface BiomarkerMergeResult { + dryRun: boolean; + target: BiomarkerDto; + sources: MergeSourceSummary[]; + movedReadings: number; + convertedReadings: number; + aliasesAdded: string[]; + mergeId: string | null; +} + +export type BiomarkerMergeOutcome = + | { kind: "ok"; result: BiomarkerMergeResult } + | { kind: "notFound"; ids: string[] } + | { + kind: "unitMismatch"; + sources: Array<{ + id: string; + unit: string; + targetUnit: string; + reason: ConversionRefusal; + }>; + }; + +interface ReadingUpdate { + id: string; + value: number; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + sourceReferenceLow: number | null; + sourceReferenceHigh: number | null; + sourceValue: number; + sourceUnit: string; +} + +/** Thrown inside the transaction to roll it back with the refusal. */ +class MergeRefused extends Error { + constructor(readonly outcome: BiomarkerMergeOutcome) { + super("merge refused"); + } +} + +export async function mergeBiomarkers(input: { + userId: string; + targetId: string; + sourceIds: readonly string[]; + dryRun: boolean; +}): Promise { + try { + return await prisma.$transaction( + (tx) => runMerge(tx, input), + // A source with a long history updates row by row where it converts. + { timeout: 60_000, maxWait: 10_000 }, + ); + } catch (err) { + if (err instanceof MergeRefused) return err.outcome; + throw err; + } +} + +async function runMerge( + tx: Prisma.TransactionClient, + input: { + userId: string; + targetId: string; + sourceIds: readonly string[]; + dryRun: boolean; + }, +): Promise { + const { userId, targetId, sourceIds, dryRun } = input; + + const markers = await tx.biomarker.findMany({ + where: { userId, id: { in: [targetId, ...sourceIds] } }, + }); + const byId = new Map(markers.map((m) => [m.id, m])); + const missing = [targetId, ...sourceIds].filter((id) => !byId.has(id)); + if (missing.length > 0) return { kind: "notFound", ids: missing }; + const target = byId.get(targetId)!; + const sources = sourceIds.map((id) => byId.get(id)!); + + // The target's analyte, derived the way every write path derives it, so a + // stale stored key cannot pick the wrong factor. + const analyteKey = matchAnalyteKey({ name: target.name, unit: target.unit }); + + const readings = await tx.labResult.findMany({ + where: { userId, biomarkerId: { in: [...sourceIds] } }, + select: { + id: true, + biomarkerId: true, + value: true, + unit: true, + referenceLow: true, + referenceHigh: true, + sourceReferenceLow: true, + sourceReferenceHigh: true, + sourceValue: true, + sourceUnit: true, + deletedAt: true, + }, + }); + + const summaries: MergeSourceSummary[] = []; + const updates: ReadingUpdate[] = []; + let movedReadings = 0; + // Counted like `readingCount`: the live readings a person sees. A tombstoned + // reading moves and converts with them, so an undo brings it back right. + let convertedLive = 0; + for (const source of sources) { + const own = readings.filter((r) => r.biomarkerId === source.id); + let converted = 0; + let refusal: ConversionRefusal | null = null; + for (const reading of own) { + // A qualitative reading, or one already in the target's unit, moves as + // it is. + if (reading.value === null || sameLabUnit(reading.unit, target.unit)) { + continue; + } + const value = convertLabValue({ + analyteKey, + value: reading.value, + from: reading.unit, + to: target.unit, + }); + const stamped = convertLabRange({ + analyteKey, + low: reading.referenceLow, + high: reading.referenceHigh, + from: reading.unit, + to: target.unit, + }); + const printed = convertLabRange({ + analyteKey, + low: reading.sourceReferenceLow, + high: reading.sourceReferenceHigh, + from: reading.unit, + to: target.unit, + }); + if (!value.ok || !stamped.ok || !printed.ok) { + refusal ??= !value.ok + ? value.reason + : !stamped.ok + ? stamped.reason + : !printed.ok + ? printed.reason + : "unknown_unit"; + continue; + } + converted += 1; + if (reading.deletedAt === null) convertedLive += 1; + updates.push({ + id: reading.id, + value: value.value, + unit: target.unit, + referenceLow: stamped.low, + referenceHigh: stamped.high, + sourceReferenceLow: printed.low, + sourceReferenceHigh: printed.high, + // A reading converted on its way in keeps what the report printed. + sourceValue: reading.sourceValue ?? reading.value, + sourceUnit: reading.sourceUnit ?? reading.unit.trim(), + }); + } + const live = own.filter((r) => r.deletedAt === null).length; + movedReadings += live; + summaries.push({ + id: source.id, + name: source.name, + unit: source.unit, + readingCount: live, + conversion: refusal ? "refused" : converted > 0 ? "converted" : "none", + refusal, + }); + } + const convertedReadings = convertedLive; + + // The spellings the target gains: each source's name and its aliases, once + // each, except what the target's name already is or another marker (not a + // source) already answers to. + const sourceAliases = await tx.biomarkerAlias.findMany({ + where: { userId, biomarkerId: { in: [...sourceIds] } }, + select: { id: true, name: true, nameFolded: true }, + }); + const otherAliases = await tx.biomarkerAlias.findMany({ + where: { userId, biomarkerId: { notIn: [...sourceIds] } }, + select: { biomarkerId: true, nameFolded: true }, + }); + const blocked = new Set( + otherAliases + .filter((a) => a.biomarkerId !== targetId) + .map((a) => a.nameFolded), + ); + const targetAliasFolds = new Set( + otherAliases + .filter((a) => a.biomarkerId === targetId) + .map((a) => a.nameFolded), + ); + const otherMarkerFolds = new Set( + ( + await tx.biomarker.findMany({ + where: { userId, id: { notIn: [targetId, ...sourceIds] } }, + select: { name: true }, + }) + ).map((m) => foldForMatch(m.name)), + ); + const targetFold = foldForMatch(target.name); + const added = new Map(); + const consider = (name: string) => { + const folded = foldForMatch(name).slice(0, BIOMARKER_ALIAS_MAX_LENGTH); + if (!folded || folded === targetFold) return; + if (targetAliasFolds.has(folded) || added.has(folded)) return; + if (blocked.has(folded) || otherMarkerFolds.has(folded)) return; + added.set(folded, name.trim().slice(0, BIOMARKER_ALIAS_MAX_LENGTH)); + }; + for (const source of sources) consider(source.name); + for (const alias of sourceAliases) consider(alias.name); + const aliasesAdded = [...added.values()]; + + const refused = summaries.filter((s) => s.conversion === "refused"); + if (!dryRun && refused.length > 0) { + throw new MergeRefused({ + kind: "unitMismatch", + sources: refused.map((s) => ({ + id: s.id, + unit: s.unit, + targetUnit: target.unit, + reason: s.refusal ?? "unknown_unit", + })), + }); + } + + let mergeId: string | null = null; + if (!dryRun) { + for (const update of updates) { + await tx.labResult.update({ + where: { id: update.id }, + data: { + biomarkerId: targetId, + value: update.value, + unit: update.unit, + referenceLow: update.referenceLow, + referenceHigh: update.referenceHigh, + sourceReferenceLow: update.sourceReferenceLow, + sourceReferenceHigh: update.sourceReferenceHigh, + sourceValue: update.sourceValue, + sourceUnit: update.sourceUnit, + }, + }); + } + await tx.labResult.updateMany({ + where: { userId, biomarkerId: { in: [...sourceIds] } }, + data: { biomarkerId: targetId }, + }); + await tx.medicationEfficacyTarget.updateMany({ + where: { biomarkerId: { in: [...sourceIds] } }, + data: { biomarkerId: targetId }, + }); + // The sources' own alias rows go with the sources; the spellings that + // survive are written again on the target below. + await tx.biomarkerAlias.deleteMany({ + where: { userId, id: { in: sourceAliases.map((a) => a.id) } }, + }); + if (added.size > 0) { + await tx.biomarkerAlias.createMany({ + data: [...added].map(([nameFolded, name]) => ({ + userId, + biomarkerId: targetId, + name, + nameFolded, + source: "MERGE", + })), + }); + } + await tx.biomarkerMerge.updateMany({ + where: { userId, targetBiomarkerId: { in: [...sourceIds] } }, + data: { targetBiomarkerId: targetId }, + }); + const now = new Date(); + for (const summary of summaries) { + const row = await tx.biomarkerMerge.create({ + data: { + userId, + targetBiomarkerId: targetId, + sourceBiomarkerId: summary.id, + sourceName: summary.name.slice(0, BIOMARKER_ALIAS_MAX_LENGTH), + readingCount: summary.readingCount, + mergedAt: now, + }, + select: { id: true }, + }); + mergeId ??= row.id; + } + await tx.biomarker.deleteMany({ + where: { userId, id: { in: [...sourceIds] } }, + }); + } + + const after = dryRun + ? target + : await tx.biomarker.findUniqueOrThrow({ where: { id: targetId } }); + const aliases = await aliasNamesByMarker(tx, userId, [after]); + + return { + kind: "ok", + result: { + dryRun, + target: serialiseBiomarker(after, aliases.get(after.id) ?? []), + sources: summaries, + movedReadings, + convertedReadings, + aliasesAdded, + mergeId, + }, + }; +} + +/** + * Where a merged marker went: the target of the newest merge that removed + * `id`, or null. Chains resolve because a merge re-points the rows that named + * its sources. + */ +export async function mergedInto( + userId: string, + id: string, +): Promise { + const row = await prisma.biomarkerMerge.findFirst({ + where: { userId, sourceBiomarkerId: id }, + orderBy: { mergedAt: "desc" }, + select: { targetBiomarkerId: true }, + }); + return row?.targetBiomarkerId ?? null; +} diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index 59740c81a..adebcc033 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -29,6 +29,11 @@ import { parseReferenceRange, } from "@/lib/labs/parse-reference-range"; import { matchAnalyteKey } from "@/lib/labs/analyte-match"; +import { + loadMarkerIndex, + matchForReview, + type MarkerIndex, +} from "@/lib/labs/biomarker-resolve"; import { sameLabUnit } from "@/lib/labs/unit-normalise"; import { annotate } from "@/lib/logging/context"; import { @@ -138,16 +143,18 @@ export async function annotateRow( row: ExtractedRow, reportDate: string | null, tz: string, + /** The account's markers and aliases, loaded once per extraction. */ + markerIndex?: MarkerIndex, ): Promise { const analyte = row.analyte.trim(); const takenAt = normaliseDate(row.takenAt) ?? reportDate; const printed = parseReferenceRange(row.referenceText, row.unit); - // Biomarker match — case-insensitive on the catalog identity, presence-only. - const existingBiomarker = await prisma.biomarker.findFirst({ - where: { userId, name: { equals: analyte, mode: "insensitive" } }, - select: { id: true, name: true, unit: true }, - }); + // v1.43 (D1223) — the marker this name resolves to (name, alias, catalog), + // exactly as the commit resolves it, or up to three to choose from. + const index = markerIndex ?? (await loadMarkerIndex(prisma, userId)); + const { match, suggestions } = matchForReview(index, analyte, row.unit); + const existingBiomarker = match?.marker ?? null; // The analyte the marker is, so the review can say whether a row in another // unit converts. Derived from the name and unit exactly as the commit derives // it (a stored key is only that derivation, written down), so the review and @@ -171,7 +178,12 @@ export async function annotateRow( where: { userId, deletedAt: null, - analyte: { equals: analyte, mode: "insensitive" }, + // The printed name, or any reading already filed under the marker it + // resolves to: an earlier report may have spelled it another way. + OR: [ + { analyte: { equals: analyte, mode: "insensitive" } }, + ...(existingBiomarker ? [{ biomarkerId: existingBiomarker.id }] : []), + ], takenAt: labReadingDaySearchRange(takenAt), }, select: { @@ -235,6 +247,9 @@ export async function annotateRow( markerUnit: existingBiomarker?.unit ?? null, markerAnalyteKey, duplicateOf, + biomarkerId: existingBiomarker?.id ?? null, + matchKind: match?.matchKind ?? null, + suggestions, }; } @@ -322,11 +337,12 @@ export async function runOcrExtraction( // hearing about it. A count only: the string is the user's document. let unreadableRanges = 0; const tz = await resolveUserTimezone(args.userId); + const markerIndex = await loadMarkerIndex(prisma, args.userId); for (const row of envelope.rows.slice(0, OCR_MAX_ROWS)) { if (isUnreadableRange(parseReferenceRange(row.referenceText, row.unit))) { unreadableRanges += 1; } - rows.push(await annotateRow(args.userId, row, reportDate, tz)); + rows.push(await annotateRow(args.userId, row, reportDate, tz, markerIndex)); } annotate({ @@ -337,6 +353,8 @@ export async function runOcrExtraction( rows: rows.length, duplicates: rows.filter((r) => r.duplicateOf !== null).length, newBiomarkers: rows.filter((r) => r.biomarkerMatch === "new").length, + aliasMatches: rows.filter((r) => r.matchKind === "alias").length, + catalogMatches: rows.filter((r) => r.matchKind === "catalog").length, unreadableRanges, }, }); diff --git a/src/lib/mcp/__tests__/module-gate-sweep.test.ts b/src/lib/mcp/__tests__/module-gate-sweep.test.ts index 64847aa79..20d0bfdf6 100644 --- a/src/lib/mcp/__tests__/module-gate-sweep.test.ts +++ b/src/lib/mcp/__tests__/module-gate-sweep.test.ts @@ -51,6 +51,7 @@ vi.mock("@/lib/db", () => ({ })), }, labResult: { findMany: vi.fn(async () => [{ analyte: "LDL" }]) }, + biomarker: { findMany: vi.fn(async () => []) }, encounter: { findMany: vi.fn(async () => [ { diff --git a/src/lib/mcp/__tests__/resource-templates.test.ts b/src/lib/mcp/__tests__/resource-templates.test.ts index c170449b7..4b467f61f 100644 --- a/src/lib/mcp/__tests__/resource-templates.test.ts +++ b/src/lib/mcp/__tests__/resource-templates.test.ts @@ -4,6 +4,7 @@ vi.mock("@/lib/db", () => ({ prisma: { medication: { findMany: vi.fn(), findFirst: vi.fn() }, labResult: { findMany: vi.fn() }, + biomarker: { findMany: vi.fn(async () => []) }, // v1.30 (G1) — the nutrients pipeline. nutrientIntakeDay: { findMany: vi.fn(async () => []), @@ -13,6 +14,9 @@ vi.mock("@/lib/db", () => ({ }, })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/labs/biomarker-resolve", () => ({ + resolveMarkerForRead: vi.fn(async () => null), +})); vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool: vi.fn(), })); @@ -284,15 +288,25 @@ describe("argument completion (user-scoped, doubles as discovery)", () => { }); it("completes analytes from ONLY the user's own lab set", async () => { + // One name per marker: the markers with readings, then the names of + // readings filed under no marker (v1.43). + vi.mocked(prisma.biomarker.findMany).mockResolvedValue([ + { name: "LDL" }, + ] as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([ - { analyte: "LDL" }, { analyte: "HDL" }, + { analyte: "ldl" }, ] as never); const complete = template("lab").complete!.analyte; const result = await complete(CTX, "ld"); + expect(prisma.biomarker.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", results: { some: { deletedAt: null } } }, + }), + ); expect(prisma.labResult.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { userId: "user-1", deletedAt: null }, + where: { userId: "user-1", deletedAt: null, biomarkerId: null }, distinct: ["analyte"], }), ); diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index 08bedaa6d..6fe0c667d 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -27,6 +27,11 @@ const { labResult, measurement, user } = vi.hoisted(() => ({ // narrated sentence, which reads the stored preference. user: { findUnique: vi.fn(async () => ({ locale: "de" })) }, })); +// v1.43 — a lab name resolves to a marker through the account's names and +// aliases; no marker resolves here, so the reads keep their text match. +vi.mock("@/lib/labs/biomarker-resolve", () => ({ + resolveMarkerForRead: vi.fn(async () => null), +})); vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement, user } })); import { diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 08c06007d..fa2a07e2b 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -23,6 +23,11 @@ vi.mock("@/lib/mcp/record-search", async (importOriginal) => ({ ...(await importOriginal()), loadRecordCandidates: vi.fn(async () => []), })); +// v1.43 — a lab name resolves to a marker through the account's names and +// aliases; no marker resolves here, so the reads keep their text match. +vi.mock("@/lib/labs/biomarker-resolve", () => ({ + resolveMarkerForRead: vi.fn(async () => null), +})); // v1.22.0 — `search` reads the record directly via Prisma; stub it so the // registry-wide loops never reach a DB. vi.mock("@/lib/db", () => ({ diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index d3374d90c..8f3a8e3e9 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -26,6 +26,7 @@ * external-assistant boundary. */ import { prisma } from "@/lib/db"; +import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve"; import { fenceUserText } from "@/lib/ai/coach/data-fence"; import { resolveMedicationCategories } from "@/lib/medication-category"; import { dayKeyOfDate } from "@/lib/medications/course-window"; @@ -397,13 +398,35 @@ async function completeMetric( async function userAnalytes(ctx: McpAuthContext): Promise { // A switched-off labs module lists and completes nothing (v1.39.3). if (!(await isModuleEnabled(ctx.userId, "labs"))) return []; - const rows = await prisma.labResult.findMany({ - where: { userId: ctx.userId, deletedAt: null }, - select: { analyte: true }, - distinct: ["analyte"], - take: 200, - }); - return rows.map((r) => r.analyte); + // One name per marker (v1.43): readings filed under a biomarker list as the + // marker's name, whatever each report printed, so two spellings merged into + // one marker are one resource. A legacy unlinked reading keeps its own name. + const [markers, unlinked] = await Promise.all([ + prisma.biomarker.findMany({ + where: { + userId: ctx.userId, + results: { some: { deletedAt: null } }, + }, + select: { name: true }, + orderBy: { name: "asc" }, + take: 200, + }), + prisma.labResult.findMany({ + where: { userId: ctx.userId, deletedAt: null, biomarkerId: null }, + select: { analyte: true }, + distinct: ["analyte"], + take: 200, + }), + ]); + const names = new Map(); + for (const name of [ + ...markers.map((m) => m.name), + ...unlinked.map((r) => r.analyte), + ]) { + const key = name.trim().toLowerCase(); + if (!names.has(key)) names.set(key, name); + } + return [...names.values()].slice(0, 200); } async function completeAnalyte( @@ -514,7 +537,12 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [ if (!(await isModuleEnabled(ctx.userId, "labs"))) { return MODULE_DISABLED; } - const analyte = decodeURIComponent(firstVar(variables.analyte)); + const asked = decodeURIComponent(firstVar(variables.analyte)); + // An alias or a catalog abbreviation reads as the marker's own name. + const marker = asked.trim() + ? await resolveMarkerForRead(ctx.userId, asked) + : null; + const analyte = marker?.name ?? asked; const result = await executeCoachTool({ userId: ctx.userId, name: "get_labs", diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 398d20559..4c7318286 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -31,6 +31,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve"; import { windowWeighting } from "@/lib/measurements/day-statistic"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; @@ -1400,18 +1401,33 @@ export async function getLabHistory( : LAB_HISTORY_MAX_LIMIT; const limit = Math.min(LAB_HISTORY_MAX_LIMIT, rawLimit); - // Match the analyte either on the LabResult's own `analyte` column or on the - // linked Biomarker's canonical name. `take: limit + 1` peeks one row past the - // page to decide whether a `nextCursor` is warranted. Field-by-field `where`. + // A name that resolves to one marker (its name, an alias of the account, a + // catalog abbreviation) reads that marker's whole series, merged spellings + // included (v1.43). Anything else matches the analyte either on the + // LabResult's own `analyte` column, on the linked Biomarker's canonical name + // or on one of its aliases. `take: limit + 1` peeks one row past the page to + // decide whether a `nextCursor` is warranted. Field-by-field `where`. + const marker = await resolveMarkerForRead(userId, needle); const rows = await prisma.labResult.findMany({ - where: { - userId, - deletedAt: null, - OR: [ - { analyte: { contains: needle, mode: "insensitive" } }, - { biomarker: { name: { contains: needle, mode: "insensitive" } } }, - ], - }, + where: marker + ? { userId, deletedAt: null, biomarkerId: marker.id } + : { + userId, + deletedAt: null, + OR: [ + { analyte: { contains: needle, mode: "insensitive" } }, + { + biomarker: { name: { contains: needle, mode: "insensitive" } }, + }, + { + biomarker: { + aliases: { + some: { name: { contains: needle, mode: "insensitive" } }, + }, + }, + }, + ], + }, orderBy: { takenAt: "desc" }, skip: offset, take: limit + 1, diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index d00699618..e4c53cb80 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -20,6 +20,7 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; +import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve"; import { annotate } from "@/lib/logging/context"; import { getDayArgsSchema } from "@/lib/ai/coach/tools/definitions"; import { readDayForTool } from "@/lib/ai/coach/tools/day-read"; @@ -1510,10 +1511,18 @@ function readLabs( limit: LAB_HISTORY_MAX_LIMIT, }); } - // Default: latest reading per biomarker via the Coach labs read. - return runCoachTool(ctx, "get_labs", { - ...(typeof args.analyte === "string" ? { analyte: args.analyte } : {}), - }); + // Default: latest reading per biomarker via the Coach labs read. A name that + // resolves to one of the user's markers (an alias or a catalog abbreviation + // such as "GPT" for "ALT") is read as that marker's own name (v1.43). + return (async () => { + const asked = typeof args.analyte === "string" ? args.analyte : null; + const marker = asked?.trim() + ? await resolveMarkerForRead(ctx.userId, asked) + : null; + return runCoachTool(ctx, "get_labs", { + ...(asked !== null ? { analyte: marker?.name ?? asked } : {}), + }); + })(); } export const MCP_TOOLS: McpToolDefinition[] = [ diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index b2acc7860..5bfc38b1f 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -216,9 +216,11 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "insights.generate.scopeChanged", ], labs: [ + "labs.biomarker.alias.taken", "labs.biomarker.create.invalid", "labs.biomarker.duplicate", "labs.biomarker.merge.invalid", + "labs.biomarker.merge.unitMismatch", "labs.biomarker.notFound", "labs.biomarker.update.invalid", "labs.biomarker.update.referenceRangeInvalid", diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 123627175..b467b9ba3 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -29,7 +29,6 @@ import { errorEnvelope, idempotencyKeyParameter, idempotentWrite, - notImplementedResponse, recordRefusal, recordWriteRateLimitResponse, stdResponses, @@ -89,6 +88,14 @@ const conflict = { }, }; +const updateConflict = { + "409": { + description: + "`labs.biomarker.duplicate`: a rename collides with another of the caller's markers. `labs.biomarker.alias.taken` (v1.43): one of `aliases` is already the name or an alias of another marker; `meta.alias` names the spelling and `meta.biomarkerId` the marker that answers to it.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + const notFound = { "404": { description: @@ -184,7 +191,7 @@ export const biomarkerPaths: NonNullable = { tags: ["Labs"], summary: "Fetch a single biomarker", description: - "Returns the biomarker including its decrypted `context`. Cross-user rows surface as 404.", + "Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`.", requestParams: { path: z.object({ id: z.string() }) }, responses: { ...recordRefusal(), @@ -204,7 +211,7 @@ export const biomarkerPaths: NonNullable = { tags: ["Labs"], summary: "Edit a biomarker", description: - "Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits as `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422 `biomarkers.unit.locked` while any reading is linked to the marker (`meta.readingCount` carries how many): readers label every linked reading with the marker's unit, so a change would relabel stored numbers without converting them. Sending the current unit, or changing it on a marker with no readings, is accepted.", + "Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Since v1.43 `aliases` (0..50, each 1..120 characters) replaces the marker's set of other spellings: listed spellings stay or are added, unlisted ones are removed; omitting the field leaves them as they are. Audits as `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422 `biomarkers.unit.locked` while any reading is linked to the marker (`meta.readingCount` carries how many): readers label every linked reading with the marker's unit, so a change would relabel stored numbers without converting them. Sending the current unit, or changing it on a marker with no readings, is accepted.", requestParams: { path: z.object({ id: z.string() }) }, requestBody: { required: true, @@ -220,7 +227,7 @@ export const biomarkerPaths: NonNullable = { }, }, }, - ...conflict, + ...updateConflict, ...notFound, ...stdResponses, }, @@ -263,8 +270,10 @@ export const biomarkerPaths: NonNullable = { }, responses: { ...idempotentWrite(), + ...recordRefusal(), "200": { - description: "The merge result (or the dry-run preview).", + description: + 'The merge result, or the dry-run preview. A preview reports a source that cannot be converted with `conversion: "refused"` and its `refusal`; the same request without `dryRun` answers 422 `labs.biomarker.merge.unitMismatch`.', content: { "application/json": { schema: dataEnvelope( @@ -276,8 +285,12 @@ export const biomarkerPaths: NonNullable = { }, ...notFound, ...stdResponses, + "422": { + description: + "`labs.biomarker.merge.invalid`: a malformed body, or the target among the sources (Zod issues in `details.issues[]`). `labs.biomarker.merge.unitMismatch`: a source holds readings in a unit the registry cannot convert to the target's; `meta.sources` lists each `{ id, unit, targetUnit, reason }` with `reason` one of `unknown_analyte`, `unknown_unit`, `not_finite`. Nothing was moved.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...recordWriteRateLimitResponse, - ...notImplementedResponse, }, }, }, diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 8b9834d26..7bc03b47a 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -177,10 +177,12 @@ const commitResponse = z skipped: z.array( z.object({ analyte: z.string(), - reason: z.enum(["duplicate", "unit_mismatch"]).meta({ - description: - "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair that can be converted is written in the marker's unit and reported with `converted: true`.", - }), + reason: z + .enum(["duplicate", "unit_mismatch", "unknown_biomarker"]) + .meta({ + description: + "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair that can be converted is written in the marker's unit and reported with `converted: true`. `unknown_biomarker` (v1.43): the row named a `biomarkerId` that is not one of the record's markers.", + }), }), ), outcome: z.enum(["empty", "failed", "partial", "success"]).meta({ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 70ca04c42..3cdb869bb 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1134,11 +1134,38 @@ const biomarkerBackupSchema = z // v1.42 (#1095) — the catalogue analyte the marker resolves to. analyteKey: z.string().nullable().optional(), context: z.string().nullable().optional(), + // v1.43 (D1223) — the other spellings the marker answers to. Absent on + // files written before them. + aliases: z + .array( + z + .object({ + id: z.string().min(1).optional(), + name: z.string().min(1).max(120), + source: z.enum(["MERGE", "IMPORT_CHOICE", "MANUAL"]), + createdAt: isoDateTime.optional(), + }) + .passthrough(), + ) + .optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); +// v1.43 (D1223) — the ledger of markers merged into others. +const biomarkerMergeBackupSchema = z + .object({ + id: z.string().min(1).optional(), + targetBiomarkerId: z.string().min(1).optional(), + targetName: z.string().min(1), + sourceBiomarkerId: z.string().min(1), + sourceName: z.string().min(1).max(120), + readingCount: z.number().int().min(0), + mergedAt: isoDateTime, + }) + .passthrough(); + const illnessSymptomBackupSchema = z .object({ key: z.string().min(1), @@ -2101,6 +2128,8 @@ export const backupPayloadSchema = z // fields; portable exports retain the metadata-only subset. labResults: z.array(labResultBackupSchema).default([]), biomarkers: z.array(biomarkerBackupSchema).default([]), + // Defaulted: a file written before v1.43 carries no key. + biomarkerMerges: z.array(biomarkerMergeBackupSchema).default([]), illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]), // Defaulted: a file written before v1.40 carries no key. symptomDefinitions: z.array(symptomDefinitionBackupSchema).default([]), diff --git a/src/lib/validations/biomarkers.ts b/src/lib/validations/biomarkers.ts index 7718fa087..c0b2973a5 100644 --- a/src/lib/validations/biomarkers.ts +++ b/src/lib/validations/biomarkers.ts @@ -75,6 +75,10 @@ export const updateBiomarkerSchema = z // v1.22 — hide / unhide a marker. An omitted key leaves visibility // untouched; `true` drops the marker from the active list + pickers. hidden: z.boolean().optional(), + // v1.43 (D1223) — the other spellings this marker answers to, as a whole + // set: what is listed stays or is added, what is not is removed. Omitted + // leaves them untouched, so an older client's PUT keeps them. + aliases: z.array(z.string().trim().min(1).max(120)).max(50).optional(), }) .refine( (d) => diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 1768dfe99..b33ae103c 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -340,6 +340,18 @@ export interface ExtractedFactDto { provenance: FactProvenance; committedRecordId: string | null; committedRecordType: string | null; + /** + * v1.43 (D1223), pending OBSERVATION facts only — the marker the label + * resolves to and how, or up to three markers to choose from. + */ + biomarkerId?: string | null; + matchKind?: "name" | "alias" | "catalog" | null; + suggestions?: Array<{ + biomarkerId: string; + name: string; + unit: string; + matchKind: "catalog" | "similar"; + }>; } /** One condition link on a document DTO (chip → `/illness/[id]`). */ @@ -602,6 +614,15 @@ export const inboundConfirmSchema = z.object({ z.object({ factId: z.string().trim().min(1).max(40), action: z.enum(["approve", "reject"]), + /** + * v1.43 (D1223), OBSERVATION only — the marker the person chose for + * the fact; the reading is filed under it whatever the label says. + * An id that is not one of the record's markers fails the fact with + * `observation.unknownBiomarker`. + */ + biomarkerId: z.string().trim().min(1).max(64).optional(), + /** With `biomarkerId`: remember the label as an alias. Default true. */ + rememberAlias: z.boolean().optional(), }), ) .min(1) diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 8e11aa7d9..38c7937dd 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -138,6 +138,19 @@ export interface OcrExtractedRowDto { */ markerAnalyteKey: string | null; duplicateOf: string | null; + /** + * v1.43 (D1223) — the marker the row was matched to automatically: by its + * name, an alias of the account, or a catalog abbreviation. Null when none. + */ + biomarkerId: string | null; + matchKind: "name" | "alias" | "catalog" | null; + /** Up to three markers to choose from when nothing matched; else empty. */ + suggestions: Array<{ + biomarkerId: string; + name: string; + unit: string; + matchKind: "catalog" | "similar"; + }>; } export interface OcrExtractResponseDto { @@ -190,6 +203,18 @@ export const ocrCommitRowSchema = z .optional() .or(z.literal("").transform(() => undefined)), takenAt: takenAtField, + /** + * v1.43 (D1223) — the marker the person chose for this row on the review + * screen. The row is written under it whatever `analyte` says; an id that + * is not one of the record's markers skips the row (`unknown_biomarker`). + */ + biomarkerId: z.string().trim().min(1).max(64).optional(), + /** + * With `biomarkerId`: remember `analyte` as another spelling of that + * marker, so the next report with the same name matches on its own. + * Default true. Ignored when the name is the marker's own. + */ + rememberAlias: z.boolean().optional(), }) // Numeric XOR qualitative: exactly one of value / valueText. .refine((d) => (d.value !== undefined) !== (d.valueText !== undefined), { @@ -246,11 +271,12 @@ export type OcrCommitInput = z.infer; /** * A row the commit route did not write. `duplicate`: it matched a live reading * at commit time. `unit_mismatch`: it states a unit other than the one its - * marker is tracked in. + * marker is tracked in. `unknown_biomarker`: the chosen `biomarkerId` is not + * one of the record's markers. */ export interface OcrSkippedRowDto { analyte: string; - reason: "duplicate" | "unit_mismatch"; + reason: "duplicate" | "unit_mismatch" | "unknown_biomarker"; } export interface OcrCommitResponseDto { diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 88971c88a..1f2497960 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -244,6 +244,8 @@ const COUNT_BACK: Record< MoodTag: (p, userId) => p.moodTag.count({ where: { userId } }), LabResult: (p, userId) => p.labResult.count({ where: { userId } }), Biomarker: (p, userId) => p.biomarker.count({ where: { userId } }), + BiomarkerAlias: (p, userId) => p.biomarkerAlias.count({ where: { userId } }), + BiomarkerMerge: (p, userId) => p.biomarkerMerge.count({ where: { userId } }), Allergy: (p, userId) => p.allergy.count({ where: { userId } }), FamilyHistoryEntry: (p, userId) => p.familyHistoryEntry.count({ where: { userId } }), @@ -738,6 +740,26 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { const biomarker = await prisma.biomarker.create({ data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" }, }); + // Another spelling of the marker, and the ledger row of a marker that was + // merged into it: both ride the marker (D1223). + await prisma.biomarkerAlias.create({ + data: { + userId: OWNER_ID, + biomarkerId: biomarker.id, + name: "Ferr.", + nameFolded: "ferr.", + source: "IMPORT_CHOICE", + }, + }); + await prisma.biomarkerMerge.create({ + data: { + userId: OWNER_ID, + targetBiomarkerId: biomarker.id, + sourceBiomarkerId: "merged-away-marker", + sourceName: "Ferritin (Serum)", + readingCount: 3, + }, + }); const labResult = await prisma.labResult.create({ data: { userId: OWNER_ID, From e5544913f6ebcca03ff2cb05c2cb9ea1cf808bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:28:32 +0200 Subject: [PATCH 018/127] test(labs): prove merges, aliases and their backup against real SQL The merge moves every reading and every reader shows one series, a dry run answers the same and writes nothing, conversions keep the printed value, an unconvertible source refuses the whole merge, a chosen spelling resolves on the next scan and on manual entry, and aliases and the merge ledger survive both kinds of backup. The merge route joins the record-scoped manage inventory with its driver. --- src/__tests__/sharing-surface-guard.test.ts | 16 +- src/__tests__/v137-wave-zero-fixtures.test.ts | 2 +- tests/fixtures/v137/sharing-matrix.ts | 8 + .../biomarker-backup-completeness.test.ts | 282 +++++++ tests/integration/biomarker-merge.test.ts | 722 ++++++++++++++++++ .../sharing-manage-handler-matrix.test.ts | 51 +- 6 files changed, 1075 insertions(+), 6 deletions(-) create mode 100644 tests/integration/biomarker-backup-completeness.test.ts create mode 100644 tests/integration/biomarker-merge.test.ts diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index cf8be76b0..a00e4ca65 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -859,6 +859,10 @@ const DELEGABLE_ROUTES: Record = { domain: "labs", why: "One biomarker of the record, fetch-then-guard against the resolved user.", }, + "app/api/biomarkers/[id]/merge/route.ts": { + domain: "labs", + why: "Merging markers of the record into one (v1.43): every read and write is narrowed to the resolved user, and the sources must be that record's markers.", + }, "app/api/allergies/route.ts": { domain: "profile", why: "The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows. The POST beside it is NOT delegable and is deliberately absent from the write literal below: the only surface that posts to it lives under `/settings`, which a switch closes, so admitting the write would freeze a permission ahead of any caller for it. The route comment carries the argument.", @@ -1612,6 +1616,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: ["C3", "C4"], why: "Editing an analyte's reference range, the one admitted edit whose error is silent, which is why C4 puts it in the feed. The DELETE beside it resolves through the same labs authority since v1.43 instead of a bare requireAuth; it hard-deletes the marker with its readings, so C3 files the name, unit and reading count it took.", }, + "app/api/biomarkers/[id]/merge/route.ts": { + domain: "labs", + conditions: ["C3"], + why: "Merging markers moves their readings into one and deletes the sources, with no undo, so it sits at the same level as the delete beside it. C3: each deleted source files its id and how many readings went with it; the name stays in the merge ledger. The shared record-write ceiling is charged to the actor, like every record write.", + }, "app/api/allergies/route.ts": { domain: "profile", conditions: [], @@ -2117,8 +2126,11 @@ const ACTOR_ROUTES: Record = { * facts (`profile`) and the nutrient catalogue beside the nutrient reads * (`measurements`). The biomarker DELETE joins the manage literal on a module * that was already there. 256 -> 258. + * + * v1.43 -- merging biomarkers joins the record list and the manage literal + * (D1223). 258 -> 260. */ -const FROZEN_ENTRY_COUNT = 258; +const FROZEN_ENTRY_COUNT = 260; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2879,7 +2891,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(88); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(89); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 6ef3fb5ba..6a9646dc2 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(88); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(89); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index a204608b9..b3c04c3ab 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -296,6 +296,14 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "delete", ), + handler( + "app/api/biomarkers/[id]/merge/route.ts", + "/api/biomarkers/[id]/merge", + "POST", + "labs", + "manage", + "update", + ), handler( "app/api/allergies/route.ts", "/api/allergies", diff --git a/tests/integration/biomarker-backup-completeness.test.ts b/tests/integration/biomarker-backup-completeness.test.ts new file mode 100644 index 000000000..7fe4faa53 --- /dev/null +++ b/tests/integration/biomarker-backup-completeness.test.ts @@ -0,0 +1,282 @@ +/** + * A marker's other spellings and the merge ledger survive a backup and a + * restore, in both kinds of file (D1223). + * + * Without the aliases a restored account is asked again at the next scan + * which marker each spelling belongs to; without the ledger a link to a + * merged marker reads as a plain 404. Every column of both tables is compared + * after a disaster-recovery round trip, and the portable file, which carries + * no ids, is checked for what the resolver and the 404 actually use: the + * spelling, its fold, where it came from and which marker it belongs to, and + * which marker a merged id went into. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; +import { loadMarkerIndex, resolveInIndex } from "@/lib/labs/biomarker-resolve"; +import { mergedInto } from "@/lib/labs/biomarker-merge"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob"; + +import { cookieJar } from "./mock-next-headers"; +import { POST } from "./restore-job-driver"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", async (importOriginal) => ({ + ...(await importOriginal()), + invalidateUserData: vi.fn(), +})); + +const OWNER = "biomarker-backup-owner"; + +async function createOwner() { + await getPrismaClient().user.create({ + data: { + id: OWNER, + username: "biomarker-backup-owner", + email: "biomarker-backup-owner@example.test", + }, + }); +} + +async function seedAdmin() { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: "biomarker-backup-admin", + email: "biomarker-backup-admin@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +/** Two markers, an alias from each source, and a two-step merge ledger. */ +async function seed() { + const prisma = getPrismaClient(); + const alp = await prisma.biomarker.create({ + data: { userId: OWNER, name: "Alkaline Phosphatase", unit: "U/L" }, + }); + const bili = await prisma.biomarker.create({ + data: { userId: OWNER, name: "Bilirubin (Total)", unit: "mg/dL" }, + }); + await prisma.labResult.create({ + data: { + userId: OWNER, + biomarkerId: alp.id, + analyte: "Alk.P", + value: 80, + unit: "U/L", + takenAt: new Date("2026-05-01T08:00:00.000Z"), + }, + }); + await prisma.biomarkerAlias.createMany({ + data: [ + { + userId: OWNER, + biomarkerId: alp.id, + name: "Alk.P", + nameFolded: "alk.p", + source: "MERGE", + createdAt: new Date("2026-05-02T10:00:00.000Z"), + }, + { + userId: OWNER, + biomarkerId: alp.id, + name: "AP", + nameFolded: "ap", + source: "MANUAL", + createdAt: new Date("2026-05-03T10:00:00.000Z"), + }, + { + userId: OWNER, + biomarkerId: bili.id, + name: "Bilirubin ges.", + nameFolded: "bilirubin ges.", + source: "IMPORT_CHOICE", + createdAt: new Date("2026-05-04T10:00:00.000Z"), + }, + ], + }); + await prisma.biomarkerMerge.createMany({ + data: [ + { + userId: OWNER, + targetBiomarkerId: alp.id, + sourceBiomarkerId: "merged-alk-p", + sourceName: "Alk.P", + readingCount: 12, + mergedAt: new Date("2026-05-02T10:00:00.000Z"), + }, + { + userId: OWNER, + targetBiomarkerId: bili.id, + sourceBiomarkerId: "merged-t-bili", + sourceName: "T.Bili", + readingCount: 0, + mergedAt: new Date("2026-05-05T10:00:00.000Z"), + }, + ], + }); + return { alp, bili }; +} + +async function snapshot() { + const prisma = getPrismaClient(); + const markers = await prisma.biomarker.findMany({ + where: { userId: OWNER }, + select: { id: true, name: true }, + }); + const nameOf = new Map(markers.map((m) => [m.id, m.name])); + const aliases = ( + await prisma.biomarkerAlias.findMany({ where: { userId: OWNER } }) + ) + .map((a) => ({ ...a, marker: nameOf.get(a.biomarkerId) })) + .sort((a, b) => a.nameFolded.localeCompare(b.nameFolded)); + const merges = ( + await prisma.biomarkerMerge.findMany({ where: { userId: OWNER } }) + ) + .map((m) => ({ ...m, target: nameOf.get(m.targetBiomarkerId) })) + .sort((a, b) => a.sourceBiomarkerId.localeCompare(b.sourceBiomarkerId)); + return { aliases, merges }; +} + +/** Empty the account, store `data` as a backup row, and restore it. */ +async function restore( + data: Awaited> | string, + type: string, +) { + const prisma = getPrismaClient(); + await prisma.user.delete({ where: { id: OWNER } }); + await createOwner(); + const backup = await prisma.dataBackup.create({ + data: { userId: OWNER, type, data }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await seedAdmin(); + await createOwner(); +}); + +describe("biomarker aliases and merges in a backup", () => { + it("come back column for column from a disaster-recovery file", async () => { + const prisma = getPrismaClient(); + await seed(); + const before = await snapshot(); + expect(before.aliases).toHaveLength(3); + expect(before.merges).toHaveLength(2); + + let json = ""; + await streamFullBackupJson( + prisma, + OWNER, + (chunk) => { + json += chunk; + }, + { purpose: "disaster-recovery", exportedAt: new Date() }, + ); + await restore( + await legacyStreamedBlobFrom(async (write) => { + await write(json); + }), + "BIOMARKER_DR_ROUND_TRIP", + ); + + // Every column, ids and stamps included. + expect(await snapshot()).toEqual(before); + }); + + it("come back from a portable file with every value the resolver and the 404 read", async () => { + const prisma = getPrismaClient(); + const { alp, bili } = await seed(); + const before = await snapshot(); + + const { payload } = await buildFullBackupPayload(prisma, OWNER); + const parsed = parseBackupPayload(JSON.stringify(payload)); + expect( + parsed.biomarkers.find((b) => b.name === "Alkaline Phosphatase")?.aliases, + ).toHaveLength(2); + expect(parsed.biomarkerMerges).toHaveLength(2); + // Stored the way an uploaded portable file is: encrypted JSON. + await restore( + encrypt(JSON.stringify(parsed)), + "BIOMARKER_PORTABLE_ROUND_TRIP", + ); + + const after = await snapshot(); + const portable = (s: typeof before) => ({ + aliases: s.aliases.map((a) => ({ + name: a.name, + nameFolded: a.nameFolded, + source: a.source, + marker: a.marker, + userId: a.userId, + })), + merges: s.merges.map((m) => ({ + sourceBiomarkerId: m.sourceBiomarkerId, + sourceName: m.sourceName, + readingCount: m.readingCount, + mergedAt: m.mergedAt, + target: m.target, + userId: m.userId, + })), + }); + expect(portable(after)).toEqual(portable(before)); + + // What the values are for: the spellings resolve again, and the merged + // ids still name the marker they went into. + const index = await loadMarkerIndex(prisma, OWNER); + const restoredAlp = index.markers.find((m) => m.name === alp.name)!; + const restoredBili = index.markers.find((m) => m.name === bili.name)!; + expect(resolveInIndex(index, "AP")).toEqual({ + marker: restoredAlp, + matchKind: "alias", + }); + expect(resolveInIndex(index, "bilirubin ges.")?.marker).toEqual( + restoredBili, + ); + expect(await mergedInto(OWNER, "merged-t-bili")).toBe(restoredBili.id); + }); +}); diff --git a/tests/integration/biomarker-merge.test.ts b/tests/integration/biomarker-merge.test.ts new file mode 100644 index 000000000..f434aab7f --- /dev/null +++ b/tests/integration/biomarker-merge.test.ts @@ -0,0 +1,722 @@ +/** + * Merging markers, aliases and catalog shorthand against real SQL (D1223). + * + * "Alk.P" and "Alkaline Phosphatase" written by two labs used to be two + * markers with half a history each. These tests drive the shipped route + * exports and readers: a merge moves every reading and every reader then + * shows one series, a dry run answers the same and writes nothing, readings + * in another unit are converted with what the report printed kept, a pair + * that cannot be converted refuses the whole merge, and a spelling chosen once + * on a scan or a document resolves on its own the next time. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER_ID = "user-biomarker-merge"; +const OTHER_ID = "user-biomarker-merge-other"; +const DAY_MS = 86_400_000; +const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + await prisma.user.createMany({ + data: [ + { id: USER_ID, username: "bm-merge", email: "bm-merge@example.test" }, + { id: OTHER_ID, username: "bm-other", email: "bm-other@example.test" }, + ], + }); + const session = await prisma.session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +async function seedMarker( + name: string, + unit: string, + readings: number, + options: { userId?: string; firstDay?: number; value?: number } = {}, +) { + const prisma = getPrismaClient(); + const userId = options.userId ?? USER_ID; + const marker = await prisma.biomarker.create({ + data: { userId, name, unit }, + }); + await prisma.labResult.createMany({ + data: Array.from({ length: readings }, (_, i) => ({ + userId, + biomarkerId: marker.id, + analyte: name, + value: (options.value ?? 80) + (i % 7), + unit, + takenAt: daysAgo((options.firstDay ?? 10) + i * 3), + })), + }); + return marker; +} + +function json(body: unknown, url: string, method = "POST") { + return new NextRequest(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function merge( + targetId: string, + body: { sourceIds: string[]; dryRun?: boolean }, +) { + const { POST } = await import("@/app/api/biomarkers/[id]/merge/route"); + return POST( + json(body, `http://localhost/api/biomarkers/${targetId}/merge`), + { params: Promise.resolve({ id: targetId }) }, + ); +} + +async function tableCounts() { + const prisma = getPrismaClient(); + return { + markers: await prisma.biomarker.count(), + readings: await prisma.labResult.count(), + linked: await prisma.labResult.groupBy({ + by: ["biomarkerId"], + _count: true, + orderBy: { biomarkerId: "asc" }, + }), + aliases: await prisma.biomarkerAlias.count(), + merges: await prisma.biomarkerMerge.count(), + values: await prisma.labResult.findMany({ + select: { id: true, value: true, unit: true, sourceValue: true }, + orderBy: { id: "asc" }, + }), + }; +} + +describe("POST /api/biomarkers/{id}/merge", () => { + it("merges 30 and 12 readings into one series every reader shows as 42", async () => { + const target = await seedMarker("Alkaline Phosphatase", "U/L", 30); + const source = await seedMarker("Alk.P", "U/L", 12, { firstDay: 400 }); + + const res = await merge(target.id, { sourceIds: [source.id] }); + expect(res.status).toBe(200); + const { data } = await res.json(); + expect(data).toMatchObject({ + dryRun: false, + movedReadings: 12, + convertedReadings: 0, + aliasesAdded: ["Alk.P"], + sources: [ + { + id: source.id, + name: "Alk.P", + unit: "U/L", + readingCount: 12, + conversion: "none", + refusal: null, + }, + ], + }); + expect(data.mergeId).toEqual(expect.any(String)); + expect(data.target.aliases).toEqual(["Alk.P"]); + + // The overview: one marker, 42 readings. + const { GET: overview } = await import("@/app/api/labs/overview/route"); + const ov = await ( + await overview(new NextRequest("http://localhost/api/labs/overview")) + ).json(); + expect(ov.data.markers).toHaveLength(1); + expect(ov.data.markers[0]).toMatchObject({ + key: `bm:${target.id}`, + count: 42, + }); + + // The marker detail's own list. + const { GET: list } = await import("@/app/api/labs/route"); + const detail = await ( + await list( + new NextRequest( + `http://localhost/api/labs?biomarkerId=${target.id}&limit=100`, + ), + ) + ).json(); + expect(detail.data.meta.total).toBe(42); + + // The doctor report: one line, counted 42, under the marker's name. + const { loadLabResults } = await import( + "@/lib/doctor-report/clinical-records" + ); + const report = await loadLabResults(USER_ID, daysAgo(1000), new Date()); + expect(report).toHaveLength(1); + expect(report![0]).toMatchObject({ + analyte: "Alkaline Phosphatase", + count: 42, + }); + + // FHIR: one Observation for the marker, named after it. + const { labObservations } = await import("@/lib/fhir/resources/labs"); + let n = 0; + const observations = labObservations( + { labResults: report } as Parameters[0], + () => `obs-${++n}`, + ); + expect(observations).toHaveLength(1); + expect(observations[0].code.text).toBe("Alkaline Phosphatase"); + + // MCP history: the printed name, the marker's name and the alias all read + // the whole series. + const { getLabHistory } = await import("@/lib/mcp/rich-reads"); + for (const analyte of ["Alk.P", "alkaline phosphatase", "ALP"]) { + const total: number[] = []; + let offset = 0; + for (;;) { + const page = await getLabHistory(USER_ID, { analyte, offset }); + expect(page.present, analyte).toBe(true); + if (!page.present) break; + total.push(page.readings!.length); + if (!page.nextCursor) break; + offset += page.readings!.length; + } + expect(total.reduce((a, b) => a + b, 0), analyte).toBe(42); + } + + // The source is gone, its old id leads to the target, and the reading's + // printed name survived. + const prisma = getPrismaClient(); + expect(await prisma.biomarker.findUnique({ where: { id: source.id } })) + .toBeNull(); + const { GET: getMarker } = await import("@/app/api/biomarkers/[id]/route"); + const stale = await getMarker( + new NextRequest(`http://localhost/api/biomarkers/${source.id}`), + { params: Promise.resolve({ id: source.id }) }, + ); + expect(stale.status).toBe(404); + const staleBody = await stale.json(); + expect(staleBody.meta).toMatchObject({ + errorCode: "labs.biomarker.notFound", + mergedInto: target.id, + }); + expect( + await prisma.labResult.count({ + where: { biomarkerId: target.id, analyte: "Alk.P" }, + }), + ).toBe(12); + const ledger = await prisma.biomarkerMerge.findMany({}); + expect(ledger).toEqual([ + expect.objectContaining({ + userId: USER_ID, + targetBiomarkerId: target.id, + sourceBiomarkerId: source.id, + sourceName: "Alk.P", + readingCount: 12, + }), + ]); + const audit = await prisma.auditLog.findFirstOrThrow({ + where: { action: "labs.biomarker.merge" }, + }); + expect(audit.details).not.toContain("Alk.P"); + expect(audit.details).toContain(source.id); + }); + + it("answers a dry run exactly like the merge and writes nothing", async () => { + const target = await seedMarker("Glucose", "mg/dL", 5, { value: 90 }); + const sameUnit = await seedMarker("Blutzucker", "mg/dL", 3); + const molar = await seedMarker("Glukose", "mmol/L", 4, { value: 5 }); + const prisma = getPrismaClient(); + await prisma.biomarkerAlias.create({ + data: { + userId: USER_ID, + biomarkerId: molar.id, + name: "GLU", + nameFolded: "glu", + source: "MANUAL", + }, + }); + + const before = await tableCounts(); + const preview = await merge(target.id, { + sourceIds: [sameUnit.id, molar.id], + dryRun: true, + }); + expect(preview.status).toBe(200); + const dry = (await preview.json()).data; + expect(await tableCounts()).toEqual(before); + expect(dry.dryRun).toBe(true); + expect(dry.mergeId).toBeNull(); + expect(dry.target.aliases).toEqual([]); + + const applied = ( + await ( + await merge(target.id, { sourceIds: [sameUnit.id, molar.id] }) + ).json() + ).data; + const comparable = (r: Record) => { + const rest = { ...r }; + delete rest.dryRun; + delete rest.mergeId; + delete rest.target; + return rest; + }; + expect(comparable(applied)).toEqual(comparable(dry)); + expect(applied).toMatchObject({ + movedReadings: 7, + convertedReadings: 4, + aliasesAdded: ["Blutzucker", "Glukose", "GLU"], + }); + expect(applied.sources[1]).toMatchObject({ + id: molar.id, + conversion: "converted", + }); + expect([...applied.target.aliases].sort()).toEqual( + ["Blutzucker", "GLU", "Glukose"].sort(), + ); + }); + + it("converts readings in another unit and keeps what the report printed", async () => { + const target = await seedMarker("Glucose", "mg/dL", 1, { value: 90 }); + const molar = await seedMarker("Glukose", "mmol/L", 0); + const prisma = getPrismaClient(); + const fresh = await prisma.labResult.create({ + data: { + userId: USER_ID, + biomarkerId: molar.id, + analyte: "Glukose", + value: 5.5, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 5.6, + takenAt: daysAgo(40), + }, + }); + // A reading converted on its way in already: its original pair stays. + const twice = await prisma.labResult.create({ + data: { + userId: USER_ID, + biomarkerId: molar.id, + analyte: "Glukose", + value: 6, + unit: "mmol/L", + sourceValue: 6000, + sourceUnit: "µmol/L", + takenAt: daysAgo(50), + }, + }); + + const res = await merge(target.id, { sourceIds: [molar.id] }); + expect(res.status).toBe(200); + + const moved = await prisma.labResult.findUniqueOrThrow({ + where: { id: fresh.id }, + }); + expect(moved.biomarkerId).toBe(target.id); + expect(moved.unit).toBe("mg/dL"); + expect(moved.value).toBeCloseTo(99.09, 1); + expect(moved.sourceValue).toBe(5.5); + expect(moved.sourceUnit).toBe("mmol/L"); + expect(moved.sourceReferenceLow).toBeCloseTo(70.26, 1); + expect(moved.sourceReferenceHigh).toBeCloseTo(100.89, 1); + expect(moved.analyte).toBe("Glukose"); + + const kept = await prisma.labResult.findUniqueOrThrow({ + where: { id: twice.id }, + }); + expect(kept.value).toBeCloseTo(108.09, 1); + expect(kept.sourceValue).toBe(6000); + expect(kept.sourceUnit).toBe("µmol/L"); + }); + + it("refuses the whole merge when one source cannot be converted", async () => { + const target = await seedMarker("ALT", "U/L", 3); + const convertible = await seedMarker("GPT", "U/L", 2); + const enzymeSi = await seedMarker("ALAT", "µkat/L", 2, { value: 1 }); + + const before = await tableCounts(); + const res = await merge(target.id, { + sourceIds: [convertible.id, enzymeSi.id], + }); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta).toMatchObject({ + errorCode: "labs.biomarker.merge.unitMismatch", + sources: [ + { + id: enzymeSi.id, + unit: "µkat/L", + targetUnit: "U/L", + reason: expect.any(String), + }, + ], + }); + // Nothing moved, not even the source that would have converted. + expect(await tableCounts()).toEqual(before); + + // The preview names the refusal instead of failing. + const preview = await merge(target.id, { + sourceIds: [convertible.id, enzymeSi.id], + dryRun: true, + }); + expect(preview.status).toBe(200); + const dry = (await preview.json()).data; + expect(dry.sources[0].conversion).toBe("none"); + expect(dry.sources[1]).toMatchObject({ + conversion: "refused", + refusal: expect.stringMatching(/unknown_(analyte|unit)/), + }); + }); + + it("names every missing id, refuses a target among the sources, and never reaches another account", async () => { + const target = await seedMarker("TSH", "mIU/L", 1); + const foreign = await seedMarker("TSH basal", "mIU/L", 1, { + userId: OTHER_ID, + }); + + const missing = await merge(target.id, { + sourceIds: [foreign.id, "nope"], + }); + expect(missing.status).toBe(404); + expect((await missing.json()).meta).toMatchObject({ + errorCode: "labs.biomarker.notFound", + ids: [foreign.id, "nope"], + }); + + const self = await merge(target.id, { sourceIds: [target.id] }); + expect(self.status).toBe(422); + expect((await self.json()).meta.errorCode).toBe( + "labs.biomarker.merge.invalid", + ); + + expect( + await getPrismaClient().biomarker.count({ where: { userId: OTHER_ID } }), + ).toBe(1); + }); + + it("follows a marker merged twice to where it ended up", async () => { + const a = await seedMarker("Ferritin (Serum)", "ng/mL", 1); + const b = await seedMarker("Ferr", "ng/mL", 1); + const c = await seedMarker("Ferritin", "ng/mL", 1); + expect((await merge(b.id, { sourceIds: [a.id] })).status).toBe(200); + expect((await merge(c.id, { sourceIds: [b.id] })).status).toBe(200); + + const { GET } = await import("@/app/api/biomarkers/[id]/route"); + for (const id of [a.id, b.id]) { + const res = await GET( + new NextRequest(`http://localhost/api/biomarkers/${id}`), + { params: Promise.resolve({ id }) }, + ); + expect((await res.json()).meta.mergedInto).toBe(c.id); + } + const aliases = await getPrismaClient().biomarkerAlias.findMany({ + where: { biomarkerId: c.id }, + orderBy: { name: "asc" }, + }); + expect(aliases.map((alias) => alias.name)).toEqual([ + "Ferr", + "Ferritin (Serum)", + ]); + expect(await getPrismaClient().labResult.count({ + where: { biomarkerId: c.id }, + })).toBe(3); + }); +}); + +describe("aliases on the marker", () => { + async function put(id: string, body: unknown) { + const { PUT } = await import("@/app/api/biomarkers/[id]/route"); + return PUT(json(body, `http://localhost/api/biomarkers/${id}`, "PUT"), { + params: Promise.resolve({ id }), + }); + } + + it("replaces the set, keeps where a kept spelling came from, and leaves it alone when omitted", async () => { + const alp = await seedMarker("Alkaline Phosphatase", "U/L", 0); + const prisma = getPrismaClient(); + await prisma.biomarkerAlias.create({ + data: { + userId: USER_ID, + biomarkerId: alp.id, + name: "Alk.P", + nameFolded: "alk.p", + source: "IMPORT_CHOICE", + }, + }); + + const res = await put(alp.id, { aliases: ["Alk.P", "AP", "alkaline phosphatase"] }); + expect(res.status).toBe(200); + expect((await res.json()).data.aliases).toEqual(["Alk.P", "AP"]); + const rows = await prisma.biomarkerAlias.findMany({ + where: { biomarkerId: alp.id }, + orderBy: { nameFolded: "asc" }, + }); + expect(rows.map((r) => [r.name, r.source])).toEqual([ + ["Alk.P", "IMPORT_CHOICE"], + ["AP", "MANUAL"], + ]); + + // An older client's PUT without the field keeps them. + const untouched = await put(alp.id, { lowerBound: 40 }); + expect((await untouched.json()).data.aliases).toEqual(["Alk.P", "AP"]); + + const cleared = await put(alp.id, { aliases: [] }); + expect((await cleared.json()).data.aliases).toEqual([]); + }); + + it("refuses a spelling another marker already answers to", async () => { + const alt = await seedMarker("ALT", "U/L", 0); + const ast = await seedMarker("AST", "U/L", 0); + await getPrismaClient().biomarkerAlias.create({ + data: { + userId: USER_ID, + biomarkerId: ast.id, + name: "GOT", + nameFolded: "got", + source: "MANUAL", + }, + }); + + for (const [alias, owner] of [ + ["ast", ast.id], + ["got", ast.id], + ] as const) { + const res = await put(alt.id, { aliases: ["GPT", alias] }); + expect(res.status).toBe(409); + expect((await res.json()).meta).toMatchObject({ + errorCode: "labs.biomarker.alias.taken", + alias, + biomarkerId: owner, + }); + } + expect( + await getPrismaClient().biomarkerAlias.count({ + where: { biomarkerId: alt.id }, + }), + ).toBe(0); + }); +}); + +describe("a spelling resolves on its own once chosen", () => { + it("maps T.Bili to the chosen marker on the second scan and on manual entry", async () => { + const bili = await seedMarker("Bilirubin (Total)", "mg/dL", 2, { + value: 1, + }); + const { annotateRow } = await import("@/lib/labs/ocr-extract"); + const scanned = { + analyte: "Bilirubin ges.", + value: 0.8, + valueText: null, + unit: "mg/dL", + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: "2026-06-01", + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + }; + + // First scan: a name nothing resolves; the review offers the marker. + const first = await annotateRow(USER_ID, scanned, null, "UTC"); + expect(first.biomarkerId).toBeNull(); + expect(first.biomarkerMatch).toBe("new"); + expect(first.suggestions[0]).toMatchObject({ + biomarkerId: bili.id, + matchKind: "similar", + }); + + // The catalog's shorthand resolves without a choice. + const shorthand = await annotateRow( + USER_ID, + { ...scanned, analyte: "T.Bili" }, + null, + "UTC", + ); + expect(shorthand).toMatchObject({ + biomarkerId: bili.id, + matchKind: "catalog", + biomarkerMatch: "existing", + suggestions: [], + }); + + // The person picks the marker for "Bilirubin ges." and commits. + const { POST: commit } = await import("@/app/api/labs/ocr/commit/route"); + const committed = await commit( + json( + { + rows: [ + { + analyte: "Bilirubin ges.", + value: 0.8, + unit: "mg/dL", + takenAt: "2026-06-01T08:00:00.000Z", + biomarkerId: bili.id, + }, + ], + }, + "http://localhost/api/labs/ocr/commit", + ), + ); + expect(committed.status).toBe(200); + const prisma = getPrismaClient(); + expect( + await prisma.labResult.count({ where: { biomarkerId: bili.id } }), + ).toBe(3); + expect( + await prisma.biomarkerAlias.findFirst({ + where: { biomarkerId: bili.id }, + select: { name: true, source: true }, + }), + ).toEqual({ name: "Bilirubin ges.", source: "IMPORT_CHOICE" }); + + // The second scan of the same lab matches without asking. + const second = await annotateRow( + USER_ID, + { ...scanned, takenAt: "2026-07-01" }, + null, + "UTC", + ); + expect(second).toMatchObject({ + biomarkerId: bili.id, + matchKind: "alias", + suggestions: [], + }); + + // Manual entry with the alias lands on the marker, no new marker. + const { POST: create } = await import("@/app/api/labs/route"); + const typed = await create( + json( + { + analyte: "bilirubin ges.", + value: 0.9, + unit: "mg/dL", + takenAt: new Date().toISOString(), + }, + "http://localhost/api/labs", + ), + ); + expect(typed.status).toBe(201); + expect((await typed.json()).data.biomarkerId).toBe(bili.id); + expect(await prisma.biomarker.count({ where: { userId: USER_ID } })).toBe( + 1, + ); + }); + + it("skips a row naming a marker the record does not have", async () => { + const foreign = await seedMarker("Ferritin", "ng/mL", 0, { + userId: OTHER_ID, + }); + const { POST: commit } = await import("@/app/api/labs/ocr/commit/route"); + const res = await commit( + json( + { + rows: [ + { + analyte: "Ferritin", + value: 50, + unit: "ng/mL", + takenAt: "2026-06-01T08:00:00.000Z", + biomarkerId: foreign.id, + }, + ], + }, + "http://localhost/api/labs/ocr/commit", + ), + ); + expect(res.status).toBe(200); + expect((await res.json()).data.skipped).toEqual([ + { analyte: "Ferritin", reason: "unknown_biomarker" }, + ]); + expect(await getPrismaClient().labResult.count()).toBe(0); + }); + + it("files a confirmed document value under the chosen marker and remembers the label", async () => { + const alp = await seedMarker("Alkaline Phosphatase", "U/L", 0); + const prisma = getPrismaClient(); + const { encryptFactData, encryptFactProvenance, serialiseFact } = + await import("@/lib/documents/store"); + const document = await prisma.inboundDocument.create({ + data: { + userId: USER_ID, + kind: "LAB_RESULT", + filename: "report.pdf", + mimeType: "application/pdf", + byteSize: 10, + contentEncrypted: new Uint8Array([1, 2, 3]), + }, + }); + const fact = await prisma.extractedFact.create({ + data: { + userId: USER_ID, + documentId: document.id, + factType: "OBSERVATION", + status: "PENDING", + confidence: 0.95, + needsReview: false, + dataEncrypted: encryptFactData({ + label: "Alk. Phosph.", + code: null, + codeSystem: null, + value: 70, + valueText: null, + unit: "U/L", + referenceLow: null, + referenceHigh: null, + referenceText: null, + effectiveDate: "2026-05-02", + }), + provenanceEncrypted: encryptFactProvenance({ + sourceText: "Alk. Phosph. 70 U/L", + anchored: true, + sourceOffset: 0, + page: 1, + confidence: 0.95, + }), + }, + }); + + const { loadMarkerIndex } = await import("@/lib/labs/biomarker-resolve"); + const dto = serialiseFact(fact, await loadMarkerIndex(prisma, USER_ID)); + expect(dto.biomarkerId).toBeNull(); + expect(dto.suggestions?.[0]?.biomarkerId).toBe(alp.id); + + const { commitApprovedFact } = await import("@/lib/documents/commit"); + const ref = await commitApprovedFact(USER_ID, fact, { + biomarkerId: alp.id, + }); + const reading = await prisma.labResult.findUniqueOrThrow({ + where: { id: ref.recordId }, + }); + expect(reading.biomarkerId).toBe(alp.id); + expect( + (await prisma.biomarkerAlias.findFirstOrThrow({})).name, + ).toBe("Alk. Phosph."); + + const again = serialiseFact(fact, await loadMarkerIndex(prisma, USER_ID)); + expect(again).toMatchObject({ biomarkerId: alp.id, matchKind: "alias" }); + }); +}); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index e373e8131..f5ac7e5bb 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(88); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(89); }); }); @@ -1124,6 +1124,51 @@ manageContract("DELETE /api/biomarkers/[id]", { })) === null, }); +manageContract("POST /api/biomarkers/[id]/merge", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => entry.route === "/api/biomarkers/[id]/merge", + )!, + prepare: async (userId) => { + const prisma = getPrismaClient(); + const target = await prisma.biomarker.create({ + data: { userId, name: "Alkaline Phosphatase", unit: "U/L" }, + }); + const source = await prisma.biomarker.create({ + data: { userId, name: "Alk.P", unit: "U/L" }, + }); + await prisma.labResult.create({ + data: { + userId, + biomarkerId: source.id, + analyte: "Alk.P", + value: 81, + unit: "U/L", + takenAt: new Date("2026-05-01T08:00:00.000Z"), + }, + }); + return { id: target.id, sourceId: source.id }; + }, + act: async (row) => { + const { POST } = await import("@/app/api/biomarkers/[id]/merge/route"); + return call( + POST as Handler, + "POST", + `/api/biomarkers/${row.id}/merge`, + { sourceIds: [row.sourceId] }, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "labs.biomarker.merge", + applied: async (row) => + (await getPrismaClient().biomarker.findUnique({ + where: { id: row.sourceId }, + })) === null && + (await getPrismaClient().labResult.count({ + where: { biomarkerId: row.id }, + })) === 1, +}); + manageContract("POST /api/allergies", { contract: ADMITTED_MUTATING_HANDLERS.find( (entry) => entry.route === "/api/allergies" && entry.action === "POST", @@ -2878,13 +2923,13 @@ describe("the conditions the admissions were granted on", () => { describe("the complete MANAGE handler matrix", () => { it("registers one strict actor-and-effect driver for every admission", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(STRICT_DRIVER_KEYS.size).toBe(88); + expect(STRICT_DRIVER_KEYS.size).toBe(89); expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected); }); it("executes every registered driver through its owned effect and actor audit", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(REAL_EFFECT_KEYS.size).toBe(88); + expect(REAL_EFFECT_KEYS.size).toBe(89); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); }); From f7386742b4a5f1fa76aa959a4b4481f397172631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:31:51 +0200 Subject: [PATCH 019/127] feat(web): show the error id on server failures and add the support info and report surfaces --- messages/de.json | 28 +++- messages/en.json | 28 +++- messages/es.json | 28 +++- messages/fr.json | 28 +++- messages/it.json | 28 +++- messages/ko.json | 28 +++- messages/pl.json | 28 +++- .../error-display-helper-guard.test.ts | 103 +++++++++++++ src/app/admin/[section]/renderer.tsx | 3 + src/app/global-error.tsx | 51 ++++++ src/components/admin/_shared.tsx | 4 +- .../admin/ai-server-key-section.tsx | 4 +- src/components/admin/assistant-section.tsx | 4 +- src/components/admin/backups-section.tsx | 19 ++- .../admin/central-codex-section.tsx | 10 +- src/components/admin/encryption-section.tsx | 6 +- src/components/admin/glitchtip-section.tsx | 4 +- .../admin/invite-tokens-section.tsx | 13 +- src/components/admin/key-backup-card.tsx | 11 +- .../admin/login-overview-section.tsx | 5 +- .../admin/measurement-maintenance-card.tsx | 6 +- .../admin/module-availability-section.tsx | 4 +- src/components/admin/reminders-section.tsx | 9 +- src/components/admin/support-report-card.tsx | 128 ++++++++++++++++ src/components/admin/umami-section.tsx | 4 +- .../admin/user-management-section.tsx | 7 +- .../admin/web-push-vapid-section.tsx | 7 +- .../custom-metrics/custom-metric-detail.tsx | 4 +- .../custom-metric-history-list.tsx | 8 +- src/components/cycle/use-cycle.ts | 13 +- .../documents/document-detail-sheet.tsx | 17 +- .../documents/document-facts-review.tsx | 4 +- .../documents/document-link-picker.tsx | 6 +- src/components/documents/documents-view.tsx | 23 ++- src/components/encounters/encounter-sheet.tsx | 8 +- src/components/error-details.tsx | 8 +- src/components/errors/error-id-line.tsx | 73 +++++++++ src/components/illness/episode-menu.tsx | 4 +- .../coach-panel/coach-conversation.tsx | 19 ++- .../coach-panel/copy-table-button.tsx | 5 +- .../insights/coach-panel/memory-note.tsx | 3 +- .../insights/coach-panel/message-actions.tsx | 11 +- .../coach-panel/reminder-suggestion-card.tsx | 5 +- .../insights/coach-panel/sources-rail.tsx | 4 +- .../coach-panel/suggested-action-card.tsx | 4 +- .../insights/coach-panel/use-coach.ts | 5 +- .../insights/insights-edit-mode.tsx | 6 +- .../insights/insights-tab-strip.tsx | 3 +- .../insights/pattern-dismiss-action.tsx | 9 +- src/components/labs/biomarker-manager.tsx | 7 +- src/components/labs/lab-biomarker-detail.tsx | 4 +- src/components/labs/lab-history-list.tsx | 8 +- src/components/labs/ocr-review-dialog.tsx | 11 +- .../measurements/measurement-list.tsx | 13 +- .../detail/efficacy/efficacy-tab.tsx | 7 +- .../medications/dose-history-add-dialog.tsx | 8 +- .../medications/dose-history-ledger.tsx | 24 ++- .../medications/glp1-medication-card.tsx | 5 +- .../medications/intake-edit-dialog.tsx | 7 +- .../medications/intake-history-editable.tsx | 13 +- .../medications/log-intake-dialog.tsx | 3 +- .../medications/medication-card.tsx | 5 +- .../medication-categories-sheet.tsx | 19 ++- .../scheduling/schedule-history-timeline.tsx | 12 +- .../scheduling/schedule-times-editor.tsx | 7 +- .../medications/sections/api-tokens-row.tsx | 10 +- .../sections/auto-mark-taken-section.tsx | 7 +- .../medications/sections/courses-section.tsx | 5 +- .../sections/destructive-zone-section.tsx | 25 ++- .../sections/intake-tracking-section.tsx | 7 +- .../sections/inventory-dialogs.tsx | 19 ++- .../sections/inventory-section.tsx | 7 +- .../sections/notifications-section.tsx | 7 +- .../sections/phase-config-sheet.tsx | 4 +- .../medications/sections/settings-section.tsx | 13 +- .../medications/side-effects-section.tsx | 7 +- src/components/medications/take-all-due.ts | 5 +- .../medications/use-medication-intake.ts | 19 ++- .../mood/__tests__/mood-qol-fixes.test.tsx | 6 +- .../mood/manage/archived-tags-card.tsx | 13 +- .../mood/manage/tag-editor-sheet.tsx | 8 +- .../mood/manage/tag-groups-card.tsx | 16 +- .../mood/manage/tag-manager-card.tsx | 16 +- src/components/mood/mood-list.tsx | 13 +- src/components/onboarding/baseline-form.tsx | 5 +- src/components/onboarding/confirm-screen.tsx | 5 +- src/components/onboarding/done-screen.tsx | 5 +- .../onboarding/first-result-screen.tsx | 9 +- src/components/onboarding/question-screen.tsx | 5 +- src/components/onboarding/units-screen.tsx | 5 +- src/components/onboarding/welcome-screen.tsx | 5 +- src/components/outcome/outcome-toast.ts | 3 +- .../practitioners/practitioner-list.tsx | 6 +- .../practitioners/practitioner-sheet.tsx | 3 +- src/components/providers.tsx | 3 +- .../records/about-me-note-manager.tsx | 3 +- .../records/ai-profile-inclusion-manager.tsx | 3 +- .../records/allergy-free-text-note.tsx | 3 +- src/components/records/allergy-manager.tsx | 4 +- src/components/records/conditions-manager.tsx | 5 +- .../records/emergency-profile-manager.tsx | 4 +- .../records/family-history-manager.tsx | 4 +- .../records/health-profile-facts-manager.tsx | 6 +- src/components/settings/about-section.tsx | 4 + .../access/managed-profile-handover.tsx | 7 +- .../settings/account-section/setup-card.tsx | 5 +- .../settings/ai/runtime-actions-row.tsx | 5 +- src/components/settings/api-section.tsx | 11 +- .../settings/coach-memory-section.tsx | 11 +- .../settings/dashboard-layout-section.tsx | 3 +- .../settings/environment-section.tsx | 38 +++-- .../settings/insights-pill-order-section.tsx | 3 +- .../integrations/connections-panel.tsx | 5 +- src/components/settings/mcp-section.tsx | 24 +-- src/components/settings/score-section.tsx | 15 +- src/components/settings/sources-section.tsx | 6 +- src/components/settings/support-info-card.tsx | 123 +++++++++++++++ .../settings/thresholds-editor-section.tsx | 7 +- src/components/targets/target-edit-sheet.tsx | 7 +- .../vaccinations/booster-mint-prompt.tsx | 4 +- .../vaccinations/custom-vaccine-sheet.tsx | 9 +- .../vaccination-document-suggestion.tsx | 6 +- .../vaccinations/vaccination-sheet.tsx | 9 +- src/hooks/use-account-switch.ts | 4 +- src/hooks/use-auth.ts | 4 +- src/hooks/use-module-toggle.ts | 3 +- src/lib/api/__tests__/api-fetch.test.ts | 48 ++++++ src/lib/api/api-fetch.ts | 30 +++- src/lib/errors/__tests__/error-id.test.ts | 98 ++++++++++++ src/lib/errors/__tests__/error-toast.test.tsx | 48 ++++++ src/lib/errors/error-id.ts | 129 ++++++++++++++++ src/lib/errors/error-toast.tsx | 64 ++++++++ src/lib/queries/use-documents-layout.ts | 5 +- src/lib/queries/use-medication-list-layout.ts | 5 +- .../support/__tests__/support-info.test.ts | 123 +++++++++++++++ src/lib/support/redact.ts | 3 +- src/lib/support/support-info.ts | 145 ++++++++++++++++++ .../integration/admin-support-report.test.ts | 3 +- 138 files changed, 1982 insertions(+), 318 deletions(-) create mode 100644 src/__tests__/error-display-helper-guard.test.ts create mode 100644 src/components/admin/support-report-card.tsx create mode 100644 src/components/errors/error-id-line.tsx create mode 100644 src/components/settings/support-info-card.tsx create mode 100644 src/lib/errors/__tests__/error-id.test.ts create mode 100644 src/lib/errors/__tests__/error-toast.test.tsx create mode 100644 src/lib/errors/error-id.ts create mode 100644 src/lib/errors/error-toast.tsx create mode 100644 src/lib/support/__tests__/support-info.test.ts create mode 100644 src/lib/support/support-info.ts diff --git a/messages/de.json b/messages/de.json index 5e17d1bf6..f467801d0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} Std. {minutes} Min.", "durationMinutes": "{minutes} Min.", "durationClockMinutes": "{time} Min.", - "durationClockHours": "{time} Std." + "durationClockHours": "{time} Std.", + "errorIdLabel": "Fehler-ID", + "errorIdCopy": "Fehler-ID kopieren", + "errorIdCopied": "Fehler-ID kopiert" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Dokumentation", "credits": "Credits & Datenquellen", "linksHeading": "Quellen & Dokumentation", - "newerAvailable": "Neue Version: {tag}" + "newerAvailable": "Neue Version: {tag}", + "support": { + "title": "Support", + "description": "Eine Zusammenfassung für eine Fehlermeldung, ohne Namen, E-Mail oder Gesundheitswerte.", + "copy": "Support-Info kopieren", + "copied": "Kopiert", + "copyFailed": "Die Support-Info konnte nicht zusammengestellt werden." + } }, "testConnection": { "test": "Verbindung testen", @@ -7753,7 +7763,8 @@ "eventDetails": "Event-Details", "closeDetails": "Details schließen", "processDetail": "Der Puffer ist flüchtig und auf 500 Einträge begrenzt.", - "cardTitle": "Letzte Ereignisse" + "cardTitle": "Letzte Ereignisse", + "filterErrorId": "Fehler-ID" }, "backups": { "title": "Backups", @@ -8120,7 +8131,16 @@ "codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}." }, "loginOverviewCardTitle": "Anmeldeereignisse", - "userColumn": "Nutzer" + "userColumn": "Nutzer", + "supportReport": { + "title": "Support-Bericht", + "description": "Eine Zusammenfassung dieser Instanz für eine Fehlermeldung, ohne E-Mails, IDs oder Werte.", + "body": "Konten erscheinen als „Account 1“, „Account 2“ und so weiter. Lies den Bericht, bevor du ihn teilst.", + "copy": "Kopieren", + "copied": "Kopiert", + "download": "Herunterladen", + "loadError": "Der Support-Bericht konnte nicht erstellt werden." + } }, "achievements": { "title": "Erfolge", diff --git a/messages/en.json b/messages/en.json index b08ee23b9..6afd60c39 100644 --- a/messages/en.json +++ b/messages/en.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours}h {minutes}m", "durationMinutes": "{minutes}m", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "Error ID", + "errorIdCopy": "Copy error ID", + "errorIdCopied": "Error ID copied" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentation", "credits": "Credits & data attributions", "linksHeading": "Sources & docs", - "newerAvailable": "Newer version: {tag}" + "newerAvailable": "Newer version: {tag}", + "support": { + "title": "Support", + "description": "A summary for a bug report, without your name, email or health values.", + "copy": "Copy support info", + "copied": "Copied", + "copyFailed": "The support info could not be put together." + } }, "testConnection": { "test": "Test connection", @@ -7753,7 +7763,8 @@ "eventDetails": "Event details", "closeDetails": "Close details", "processDetail": "Buffer is volatile and capped at 500 entries.", - "cardTitle": "Recent events" + "cardTitle": "Recent events", + "filterErrorId": "Error ID" }, "backups": { "title": "Backups", @@ -8120,7 +8131,16 @@ "codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}." }, "loginOverviewCardTitle": "Sign-in events", - "userColumn": "User" + "userColumn": "User", + "supportReport": { + "title": "Support report", + "description": "A summary of this instance for a bug report, without emails, IDs or values.", + "body": "Accounts appear as “Account 1”, “Account 2” and so on. Read the report before you share it.", + "copy": "Copy", + "copied": "Copied", + "download": "Download", + "loadError": "The support report could not be created." + } }, "achievements": { "title": "Achievements", diff --git a/messages/es.json b/messages/es.json index 9bf9001d7..8a5ba0bf7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} h {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "ID de error", + "errorIdCopy": "Copiar ID de error", + "errorIdCopied": "ID de error copiado" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentación", "credits": "Créditos y atribuciones de datos", "linksHeading": "Fuentes y documentación", - "newerAvailable": "Nueva versión: {tag}" + "newerAvailable": "Nueva versión: {tag}", + "support": { + "title": "Soporte", + "description": "Un resumen para un informe de error, sin tu nombre, correo ni valores de salud.", + "copy": "Copiar información de soporte", + "copied": "Copiado", + "copyFailed": "No se pudo reunir la información de soporte." + } }, "testConnection": { "test": "Probar conexión", @@ -7753,7 +7763,8 @@ "eventDetails": "Detalles del evento", "closeDetails": "Cerrar detalles", "processDetail": "El búfer es volátil y está limitado a 500 entradas.", - "cardTitle": "Eventos recientes" + "cardTitle": "Eventos recientes", + "filterErrorId": "ID de error" }, "backups": { "title": "Copias de seguridad", @@ -8120,7 +8131,16 @@ "codes": "Para el administrador: las peticiones a la API responden 503 con el código de error {code}, y /api/health indica el motivo {reason}." }, "loginOverviewCardTitle": "Eventos de inicio de sesión", - "userColumn": "Usuario" + "userColumn": "Usuario", + "supportReport": { + "title": "Informe de soporte", + "description": "Un resumen de esta instancia para un informe de error, sin correos, ID ni valores.", + "body": "Las cuentas aparecen como «Account 1», «Account 2», etc. Lee el informe antes de compartirlo.", + "copy": "Copiar", + "copied": "Copiado", + "download": "Descargar", + "loadError": "No se pudo crear el informe de soporte." + } }, "achievements": { "title": "Logros", diff --git a/messages/fr.json b/messages/fr.json index 878a0384b..5d3bd5fd1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} h {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "ID d'erreur", + "errorIdCopy": "Copier l'ID d'erreur", + "errorIdCopied": "ID d'erreur copié" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentation", "credits": "Crédits et attributions de données", "linksHeading": "Sources et documentation", - "newerAvailable": "Nouvelle version : {tag}" + "newerAvailable": "Nouvelle version : {tag}", + "support": { + "title": "Assistance", + "description": "Un résumé pour un signalement de bug, sans votre nom, votre e-mail ni vos valeurs de santé.", + "copy": "Copier les infos d'assistance", + "copied": "Copié", + "copyFailed": "Les infos d'assistance n'ont pas pu être rassemblées." + } }, "testConnection": { "test": "Tester la connexion", @@ -7753,7 +7763,8 @@ "eventDetails": "Détails de l'événement", "closeDetails": "Fermer les détails", "processDetail": "Le tampon est volatil et limité à 500 entrées.", - "cardTitle": "Événements récents" + "cardTitle": "Événements récents", + "filterErrorId": "ID d'erreur" }, "backups": { "title": "Sauvegardes", @@ -8120,7 +8131,16 @@ "codes": "Pour l'administrateur : les requêtes API répondent 503 avec le code d'erreur {code}, et /api/health indique la raison {reason}." }, "loginOverviewCardTitle": "Événements de connexion", - "userColumn": "Utilisateur" + "userColumn": "Utilisateur", + "supportReport": { + "title": "Rapport d'assistance", + "description": "Un résumé de cette instance pour un signalement de bug, sans e-mails, identifiants ni valeurs.", + "body": "Les comptes apparaissent comme « Account 1 », « Account 2 », etc. Lisez le rapport avant de le partager.", + "copy": "Copier", + "copied": "Copié", + "download": "Télécharger", + "loadError": "Le rapport d'assistance n'a pas pu être créé." + } }, "achievements": { "title": "Réussites", diff --git a/messages/it.json b/messages/it.json index 2c3c7df29..adaf75f83 100644 --- a/messages/it.json +++ b/messages/it.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} h {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "ID errore", + "errorIdCopy": "Copia ID errore", + "errorIdCopied": "ID errore copiato" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentazione", "credits": "Crediti e attribuzioni dei dati", "linksHeading": "Fonti e documentazione", - "newerAvailable": "Nuova versione: {tag}" + "newerAvailable": "Nuova versione: {tag}", + "support": { + "title": "Assistenza", + "description": "Un riepilogo per una segnalazione di errore, senza nome, email o valori di salute.", + "copy": "Copia le info di assistenza", + "copied": "Copiato", + "copyFailed": "Non è stato possibile raccogliere le info di assistenza." + } }, "testConnection": { "test": "Prova connessione", @@ -7753,7 +7763,8 @@ "eventDetails": "Dettagli dell'evento", "closeDetails": "Chiudi dettagli", "processDetail": "Il buffer è volatile e limitato a 500 voci.", - "cardTitle": "Eventi recenti" + "cardTitle": "Eventi recenti", + "filterErrorId": "ID errore" }, "backups": { "title": "Backup", @@ -8120,7 +8131,16 @@ "codes": "Per chi gestisce il server: le richieste API rispondono 503 con il codice di errore {code}, e /api/health indica il motivo {reason}." }, "loginOverviewCardTitle": "Eventi di accesso", - "userColumn": "Utente" + "userColumn": "Utente", + "supportReport": { + "title": "Rapporto di assistenza", + "description": "Un riepilogo di questa istanza per una segnalazione di errore, senza email, ID o valori.", + "body": "Gli account compaiono come “Account 1”, “Account 2” e così via. Leggi il rapporto prima di condividerlo.", + "copy": "Copia", + "copied": "Copiato", + "download": "Scarica", + "loadError": "Non è stato possibile creare il rapporto di assistenza." + } }, "achievements": { "title": "Obiettivi", diff --git a/messages/ko.json b/messages/ko.json index 1d65cae91..e958a666a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours}시간 {minutes}분", "durationMinutes": "{minutes}분", "durationClockMinutes": "{time}분", - "durationClockHours": "{time}시간" + "durationClockHours": "{time}시간", + "errorIdLabel": "오류 ID", + "errorIdCopy": "오류 ID 복사", + "errorIdCopied": "오류 ID 복사됨" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "문서", "credits": "크레딧 및 데이터 출처", "linksHeading": "출처 및 문서", - "newerAvailable": "새 버전: {tag}" + "newerAvailable": "새 버전: {tag}", + "support": { + "title": "지원", + "description": "버그 신고용 요약이며, 이름, 이메일, 건강 수치는 포함되지 않습니다.", + "copy": "지원 정보 복사", + "copied": "복사됨", + "copyFailed": "지원 정보를 만들 수 없습니다." + } }, "testConnection": { "test": "연결 테스트", @@ -7729,7 +7739,8 @@ "eventDetails": "이벤트 상세", "closeDetails": "상세 닫기", "processDetail": "버퍼는 휘발성이고 최대 500개까지만 담아요.", - "cardTitle": "최근 이벤트" + "cardTitle": "최근 이벤트", + "filterErrorId": "오류 ID" }, "backups": { "title": "백업", @@ -8120,7 +8131,16 @@ "codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다." }, "loginOverviewCardTitle": "로그인 이벤트", - "userColumn": "사용자" + "userColumn": "사용자", + "supportReport": { + "title": "지원 보고서", + "description": "버그 신고용 이 인스턴스의 요약이며, 이메일, ID, 수치는 포함되지 않습니다.", + "body": "계정은 “Account 1”, “Account 2”처럼 표시됩니다. 공유하기 전에 보고서를 확인하세요.", + "copy": "복사", + "copied": "복사됨", + "download": "다운로드", + "loadError": "지원 보고서를 만들 수 없습니다." + } }, "achievements": { "title": "업적", diff --git a/messages/pl.json b/messages/pl.json index 6093866f9..4cf8b75a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} godz. {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} godz." + "durationClockHours": "{time} godz.", + "errorIdLabel": "ID błędu", + "errorIdCopy": "Kopiuj ID błędu", + "errorIdCopied": "Skopiowano ID błędu" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Dokumentacja", "credits": "Autorzy i źródła danych", "linksHeading": "Źródła i dokumentacja", - "newerAvailable": "Nowa wersja: {tag}" + "newerAvailable": "Nowa wersja: {tag}", + "support": { + "title": "Pomoc", + "description": "Podsumowanie do zgłoszenia błędu, bez imienia, adresu e-mail i wartości zdrowotnych.", + "copy": "Kopiuj informacje dla pomocy", + "copied": "Skopiowano", + "copyFailed": "Nie udało się zebrać informacji dla pomocy." + } }, "testConnection": { "test": "Testuj połączenie", @@ -7753,7 +7763,8 @@ "eventDetails": "Szczegóły zdarzenia", "closeDetails": "Zamknij szczegóły", "processDetail": "Bufor jest ulotny i ograniczony do 500 wpisów.", - "cardTitle": "Ostatnie zdarzenia" + "cardTitle": "Ostatnie zdarzenia", + "filterErrorId": "ID błędu" }, "backups": { "title": "Kopie zapasowe", @@ -8120,7 +8131,16 @@ "codes": "Dla administratora: żądania API odpowiadają 503 z kodem błędu {code}, a /api/health podaje powód {reason}." }, "loginOverviewCardTitle": "Zdarzenia logowania", - "userColumn": "Użytkownik" + "userColumn": "Użytkownik", + "supportReport": { + "title": "Raport dla pomocy", + "description": "Podsumowanie tej instancji do zgłoszenia błędu, bez adresów e-mail, identyfikatorów i wartości.", + "body": "Konta pojawiają się jako „Account 1”, „Account 2” itd. Przeczytaj raport, zanim go udostępnisz.", + "copy": "Kopiuj", + "copied": "Skopiowano", + "download": "Pobierz", + "loadError": "Nie udało się utworzyć raportu dla pomocy." + } }, "achievements": { "title": "Osiągnięcia", diff --git a/src/__tests__/error-display-helper-guard.test.ts b/src/__tests__/error-display-helper-guard.test.ts new file mode 100644 index 000000000..bb1f2b20c --- /dev/null +++ b/src/__tests__/error-display-helper-guard.test.ts @@ -0,0 +1,103 @@ +/** + * Every error display goes through the one helper that shows an error id. + * + * A self-hoster reporting a failure needs the id the operator finds it by. The + * web shows it on a server failure with a copy button, through two doors only: + * + * - toasts: `showErrorToast` (`src/lib/errors/error-toast.tsx`), which adds + * the id when its `cause` is a 5xx and is otherwise exactly `toast.error`; + * - error boundaries: `ErrorDetails` / `ErrorIdLine`, or `errorIdOf` for the + * root boundary that renders without any provider. + * + * A raw `toast.error(` anywhere else is a toast that can never carry the id, + * and a route boundary that skips the helper is a crash screen without it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); +const HELPER = "lib/errors/error-toast.tsx"; + +/** Whitespace-tolerant: `toast.error(`, `toast .error (`, `toast\n.error(`. */ +const RAW_TOAST_ERROR = /\btoast\s*\.\s*error\s*\(/g; +const HELPER_CALL = /\bshowErrorToast\s*\(/g; + +/** + * Route boundaries that deliberately show no error id, with the reason. Each + * entry is a decision, not a convenience. + */ +const BOUNDARY_EXEMPT: Readonly> = { + // The clinician share view is opened by someone outside the instance, with + // no account and no way to reach its operator; it shows a retry and nothing + // a probe could learn from, by design (see the file's header). + "app/c/error.tsx": "public share view, no operator relationship", +}; + +const files = walkSourceFiles(SRC, { floor: 2500 }).filter( + (rel) => + !rel.includes("__tests__/") && + !/\.test\.tsx?$/.test(rel) && + !rel.startsWith("generated/"), +); + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +describe("error display helper guard", () => { + it("finds the helper's own toast.error, so the matcher works", () => { + const own = read(HELPER).match(RAW_TOAST_ERROR) ?? []; + expect(own.length).toBeGreaterThan(0); + }); + + it("allows no toast.error outside the helper", () => { + const offenders = files + .filter((rel) => rel !== HELPER) + .filter((rel) => (read(rel).match(RAW_TOAST_ERROR) ?? []).length > 0) + .sort(); + expect(offenders).toEqual([]); + }); + + it("routes the app's error toasts through showErrorToast", () => { + const calls = files.reduce( + (n, rel) => n + (read(rel).match(HELPER_CALL) ?? []).length, + 0, + ); + // 200 call sites when the helper landed; a count far below it means the + // matcher or the walk stopped seeing them. + expect(calls).toBeGreaterThan(180); + }); + + it("renders every route error boundary through the error id helper", () => { + const boundaries = files.filter((rel) => + /^app\/(.*\/)?(error|global-error)\.tsx$/.test(rel), + ); + expect(boundaries.length).toBeGreaterThanOrEqual(3); + const missing = boundaries + .filter((rel) => !(rel in BOUNDARY_EXEMPT)) + .filter((rel) => { + const source = read(rel); + return !( + / { + const source = read("components/error-details.tsx"); + expect(source).toMatch(/ + {/* v1.43 — the redacted report for a bug report. */} + {/* v1.42 — the table maintenance after the tombstone purge. */} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 536dbe0aa..0bcb37ce8 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; +import { errorIdOf, recordErrorId, shortErrorId } from "@/lib/errors/error-id"; import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload"; /** @@ -22,12 +23,29 @@ export default function GlobalError({ reloadOnceForChunkError(error); }, [error]); + // The id the operator finds this failure by. No i18n provider exists at + // this level, so the line is the same static bilingual pair as the heading. + const errorId = errorIdOf(error, { unexpected: true }); + useEffect(() => { + if (errorId) recordErrorId(errorId); + }, [errorId]); + + async function handleCopyId() { + if (!errorId) return; + try { + await navigator.clipboard.writeText(errorId); + } catch { + window.prompt("Error ID", errorId); + } + } + async function handleCopy() { // Strip query string — OAuth callback routes carry sensitive tokens. const loc = typeof window !== "undefined" ? window.location : null; const payload = { message: error.message, digest: error.digest, + errorId, name: error.name, stack: error.stack?.split("\n").slice(0, 10).join("\n"), urlPath: loc?.pathname ?? null, @@ -91,6 +109,39 @@ export default function GlobalError({ > {error.message || "Unknown error"} + {errorId && ( +

+ Fehler-ID / Error ID + {shortErrorId(errorId)} + +

+ )}
+ + + + ); +} diff --git a/src/components/admin/umami-section.tsx b/src/components/admin/umami-section.tsx index b1b109739..7be6ec909 100644 --- a/src/components/admin/umami-section.tsx +++ b/src/components/admin/umami-section.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { Activity, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -50,10 +51,11 @@ export function UmamiSection() { toast.success(message); }, onError: (error) => { - toast.error( + showErrorToast( error instanceof Error ? error.message : t("admin.monitoringTestFailed"), + { cause: error }, ); }, }); diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index e6b0ff13a..9fa1176e5 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -12,6 +12,7 @@ import { Users, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Badge } from "@/components/ui/badge"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; @@ -99,10 +100,11 @@ export function UserManagementSection() { toast.success(t("common.saved")); }, onError: (err) => { - toast.error( + showErrorToast( err instanceof Error && err.message ? err.message : t("admin.settingsSaveError"), + { cause: err }, ); }, }); @@ -144,10 +146,11 @@ export function UserManagementSection() { setLogoutTarget(null); }, onError: (err) => { - toast.error( + showErrorToast( err instanceof Error && err.message ? err.message : t("admin.section.users.forceLogoutFailed"), + { cause: err }, ); }, }); diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx index e56efc591..6de2dc105 100644 --- a/src/components/admin/web-push-vapid-section.tsx +++ b/src/components/admin/web-push-vapid-section.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BellRing, KeyRound, Loader2 } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -96,7 +97,7 @@ export function WebPushVapidSection() { } if (!res.ok) { - toast.error(t("admin.webPushVapidGenerateFailed")); + showErrorToast(t("admin.webPushVapidGenerateFailed"), { cause: res }); return; } @@ -113,8 +114,8 @@ export function WebPushVapidSection() { queryKey: queryKeys.adminSettings(), }); toast.success(t("admin.webPushVapidGenerated")); - } catch { - toast.error(t("admin.webPushVapidGenerateFailed")); + } catch (failure) { + showErrorToast(t("admin.webPushVapidGenerateFailed"), { cause: failure }); } finally { setGenerating(false); } diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index 485f4dde3..c31201cf9 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -11,6 +11,7 @@ import { } from "@tanstack/react-query"; import { Gauge, ListOrdered, Pencil, Plus } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -162,7 +163,8 @@ export function CustomMetricDetail({ queryClient.invalidateQueries({ queryKey: queryKeys.customMetrics() }); router.push("/measurements"); }, - onError: () => toast.error(t("customMetrics.deleteError")), + onError: (failure) => + showErrorToast(t("customMetrics.deleteError"), { cause: failure }), }); const description = metric?.description?.trim() diff --git a/src/components/custom-metrics/custom-metric-history-list.tsx b/src/components/custom-metrics/custom-metric-history-list.tsx index d71ab8982..aadfe0b95 100644 --- a/src/components/custom-metrics/custom-metric-history-list.tsx +++ b/src/components/custom-metrics/custom-metric-history-list.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton, SortableHead } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -96,8 +97,8 @@ export function CustomMetricHistoryList({ }); invalidate(); toast.success(t("customMetrics.entry.restoredToast")); - } catch { - toast.error(t("customMetrics.entry.restoreError")); + } catch (failure) { + showErrorToast(t("customMetrics.entry.restoreError"), { cause: failure }); } } @@ -113,7 +114,8 @@ export function CustomMetricHistoryList({ }, }); }, - onError: () => toast.error(t("customMetrics.entry.deleteError")), + onError: (failure) => + showErrorToast(t("customMetrics.entry.deleteError"), { cause: failure }), }); const updateMutation = useMutation({ diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 1377d3ffd..213a2b193 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -12,6 +12,7 @@ import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ApiError, @@ -233,7 +234,8 @@ export function useDeleteCustomSymptom() { onSuccess: () => invalidateKeys(qc, cycleDependentKeys), // v1.16.4 — a failed delete used to fail silently (the sheet clears the // chip optimistically), so surface the rejection. - onError: () => toast.error(t("cycle.deleteError")), + onError: (failure) => + showErrorToast(t("cycle.deleteError"), { cause: failure }), }); } @@ -355,7 +357,8 @@ export function useStartPeriod() { }, // v1.16.4 — period boundaries had no failure signal at all (the sheet // simply stayed open); a toast names the rejection. - onError: () => toast.error(t("cycle.saveError")), + onError: (failure) => + showErrorToast(t("cycle.saveError"), { cause: failure }), }); } @@ -375,7 +378,8 @@ export function useEndPeriod() { void invalidateKeys(qc, cycleDependentKeys); toastWrittenOutcome("success", t("cycle.periodEndSaved")); }, - onError: () => toast.error(t("cycle.saveError")), + onError: (failure) => + showErrorToast(t("cycle.saveError"), { cause: failure }), }); } @@ -391,7 +395,8 @@ export function useDeleteDayLog() { void invalidateKeys(qc, cycleDependentKeys); toastWrittenOutcome("success", t("cycle.deleteSuccess")); }, - onError: () => toast.error(t("cycle.deleteError")), + onError: (failure) => + showErrorToast(t("cycle.deleteError"), { cause: failure }), }); } diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 5ac47bbc9..56052eb88 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -35,6 +35,7 @@ import { import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useIllnessEpisodes } from "@/components/illness/use-illness"; import { useAuth } from "@/hooks/use-auth"; @@ -567,11 +568,11 @@ export function DocumentDetailSheet({ ? error.meta.reason : ""; if (reason === "purged") { - toast.error(t("documents.error.purged")); + showErrorToast(t("documents.error.purged"), { cause: error }); } else if (reason === "duplicateExists") { - toast.error(t("documents.error.duplicateExists")); + showErrorToast(t("documents.error.duplicateExists"), { cause: error }); } else { - toast.error(t("documents.toast.restoreFailed")); + showErrorToast(t("documents.toast.restoreFailed"), { cause: error }); } }, }); @@ -681,16 +682,17 @@ export function DocumentDetailSheet({ onSuccess: (result) => { if ("summary" in result) { if (result.persistence === "withheld") { - toast.error(result.summary); + showErrorToast(result.summary); } else if (result.persistence === "failed") { - toast.error(t("documents.detail.saveError")); + showErrorToast(t("documents.detail.saveError")); } } void queryClient.invalidateQueries({ queryKey: queryKeys.inboundDocument(documentId), }); }, - onError: (error) => toast.error(aiErrorText(error)), + onError: (error) => + showErrorToast(aiErrorText(error), { cause: error }), }, ); }; @@ -709,7 +711,8 @@ export function DocumentDetailSheet({ ? tCount("documents.ai.readStaged", result.labFactsStaged) : t("documents.ai.readDone"), ), - onError: (error) => toast.error(aiErrorText(error)), + onError: (error) => + showErrorToast(aiErrorText(error), { cause: error }), }, ); }; diff --git a/src/components/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx index c17019dad..2bf29dc82 100644 --- a/src/components/documents/document-facts-review.tsx +++ b/src/components/documents/document-facts-review.tsx @@ -28,6 +28,7 @@ import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useState } from "react"; import { FlaskConical, Loader2, ScanSearch } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Button } from "@/components/ui/button"; @@ -635,7 +636,8 @@ export function DocumentFactsSection({ toast.info(t("documents.review.extractEmpty")); } }, - onError: (error) => toast.error(aiErrorText(error)), + onError: (error) => + showErrorToast(aiErrorText(error), { cause: error }), }, ) } diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx index 3cc1edcc4..59076672f 100644 --- a/src/components/documents/document-link-picker.tsx +++ b/src/components/documents/document-link-picker.tsx @@ -17,6 +17,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Input } from "@/components/ui/input"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -92,10 +93,11 @@ export function DocumentLinkPicker({ ), onSuccess: (data) => { if (data.results.some((r) => !r.ok)) { - toast.error(t("documents.bulk.failed")); + showErrorToast(t("documents.bulk.failed")); } }, - onError: () => toast.error(t("documents.bulk.failed")), + onError: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), onSettled: () => { void invalidateKeys(queryClient, [queryKeys.documents()]); }, diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 8959f6888..b313056cc 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -33,6 +33,7 @@ import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useIllnessEpisodes } from "@/components/illness/use-illness"; import { Button } from "@/components/ui/button"; @@ -488,7 +489,10 @@ export function DocumentsView() { }) : t("documents.contentIndex.indexAllNothing"), ), - onError: () => toast.error(t("documents.contentIndex.indexAllError")), + onError: (failure) => + showErrorToast(t("documents.contentIndex.indexAllError"), { + cause: failure, + }), }); }, [reindexAll, t]); @@ -665,7 +669,7 @@ export function DocumentsView() { (results: DocumentBulkResultDto[], successMessage: string) => { const failed = results.filter((r) => !r.ok).length; if (failed > 0) { - toast.error( + showErrorToast( t("documents.bulk.partialFailure", { failed, total: results.length, @@ -700,7 +704,8 @@ export function DocumentsView() { ); clearSelection(); }, - onError: () => toast.error(t("documents.bulk.failed")), + onError: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), }, ); }, @@ -719,7 +724,10 @@ export function DocumentsView() { count: results.filter((r) => r.ok).length, }), ), - onError: () => toast.error(t("documents.toast.restoreFailed")), + onError: (failure) => + showErrorToast(t("documents.toast.restoreFailed"), { + cause: failure, + }), }, ); }, @@ -735,7 +743,7 @@ export function DocumentsView() { const okIds = results.filter((r) => r.ok).map((r) => r.id); const failed = results.length - okIds.length; if (failed > 0) { - toast.error( + showErrorToast( t("documents.bulk.partialFailure", { failed, total: results.length, @@ -756,7 +764,8 @@ export function DocumentsView() { } clearSelection(); }, - onError: () => toast.error(t("documents.bulk.failed")), + onError: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), }, ); }, @@ -794,7 +803,7 @@ export function DocumentsView() { t("documents.card.untitled"), ); if (resolved.overCap) { - toast.error( + showErrorToast( t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }), ); return; diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 2f2a3a21a..463442272 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -34,6 +34,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -130,7 +131,7 @@ export function EncounterSheet({ const message = err instanceof Error ? err.message : t("encounters.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; @@ -205,7 +206,10 @@ export function EncounterSheet({ onClick={() => { remove.mutate(encounter.id, { onSuccess: () => onOpenChange(false), - onError: () => toast.error(t("encounters.deleteFailed")), + onError: (failure) => + showErrorToast(t("encounters.deleteFailed"), { + cause: failure, + }), }); }} > diff --git a/src/components/error-details.tsx b/src/components/error-details.tsx index f15466ad0..2de077ddb 100644 --- a/src/components/error-details.tsx +++ b/src/components/error-details.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import { Copy, CheckCircle2, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { ErrorIdLine } from "@/components/errors/error-id-line"; +import { errorIdOf } from "@/lib/errors/error-id"; import { useTranslations } from "@/lib/i18n/context"; export interface ErrorDetailsProps { @@ -29,6 +31,9 @@ export function ErrorDetails({ }: ErrorDetailsProps) { const { t, locale } = useTranslations(); const [copied, setCopied] = useState(false); + // The id the operator finds this failure by: the request id of an API + // failure that reached the boundary, else the server render's digest. + const errorId = requestId ?? errorIdOf(error, { unexpected: true }); async function handleCopy() { // Only capture the pathname — some flows carry OAuth codes / state tokens @@ -47,7 +52,7 @@ export function ErrorDetails({ digest: error.digest, name: error.name, stack: error.stack?.split("\n").slice(0, 10).join("\n"), - requestId: requestId ?? null, + requestId: errorId, urlPath, searchParamCount, userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null, @@ -81,6 +86,7 @@ export function ErrorDetails({
{error.message || t("common.unknownError")}
+ {errorId && }
{reset && ( + + ); +} diff --git a/src/components/illness/episode-menu.tsx b/src/components/illness/episode-menu.tsx index 6cfa233c5..1625f820e 100644 --- a/src/components/illness/episode-menu.tsx +++ b/src/components/illness/episode-menu.tsx @@ -10,6 +10,7 @@ import { useState } from "react"; import { MoreVertical, Pencil, CheckCircle2, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { @@ -69,7 +70,8 @@ export function EpisodeMenu({ label: t("common.undo"), onClick: () => { restore.mutate(episode.id, { - onError: () => toast.error(t("illness.restoreError")), + onError: (failure) => + showErrorToast(t("illness.restoreError"), { cause: failure }), }); }, }, diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 51302a748..0ac00a043 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -9,6 +9,7 @@ import { } from "@tanstack/react-query"; import { Paperclip, Sparkles } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; @@ -335,8 +336,10 @@ export function CoachConversation({ onSuccess: (next) => { queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next); }, - onError: () => { - toast.error(t("insights.coach.guided.dismissError")); + onError: (failure) => { + showErrorToast(t("insights.coach.guided.dismissError"), { + cause: failure, + }); }, }); @@ -451,7 +454,7 @@ export function CoachConversation({ } useEffect(() => { if (!invalidToastKey) return; - toast.error(t("insights.coach.attach.notFoundError")); + showErrorToast(t("insights.coach.attach.notFoundError")); }, [invalidToastKey, t]); // The pills row above the composer. On a fresh chat every pill is a staged id; @@ -559,8 +562,10 @@ export function CoachConversation({ headers: { "Idempotency-Key": randomId() }, body: form, }); - } catch { - toast.error(t("insights.coach.attach.uploadError")); + } catch (failure) { + showErrorToast(t("insights.coach.attach.uploadError"), { + cause: failure, + }); continue; } const parsed = parseUploadResponse( @@ -568,7 +573,9 @@ export function CoachConversation({ await response.text(), ); if (!parsed.ok) { - toast.error(t("insights.coach.attach.uploadError")); + showErrorToast(t("insights.coach.attach.uploadError"), { + cause: response, + }); continue; } const doc = parsed.document; diff --git a/src/components/insights/coach-panel/copy-table-button.tsx b/src/components/insights/coach-panel/copy-table-button.tsx index 29758f854..da1995278 100644 --- a/src/components/insights/coach-panel/copy-table-button.tsx +++ b/src/components/insights/coach-panel/copy-table-button.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Check, Copy } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DropdownMenu, @@ -61,8 +62,8 @@ export function CopyTableButton({ grid, caption }: CopyTableButtonProps) { setCopied(false); timer.current = null; }, COPY_CONFIRM_MS); - } catch { - toast.error(t(COACH_RESULT_UI_KEYS.copyFailed)); + } catch (failure) { + showErrorToast(t(COACH_RESULT_UI_KEYS.copyFailed), { cause: failure }); } }, [grid, caption, t], diff --git a/src/components/insights/coach-panel/memory-note.tsx b/src/components/insights/coach-panel/memory-note.tsx index 9b0f87fba..24b04f4b0 100644 --- a/src/components/insights/coach-panel/memory-note.tsx +++ b/src/components/insights/coach-panel/memory-note.tsx @@ -17,6 +17,7 @@ */ import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -83,7 +84,7 @@ export function CoachMemoryNote({ const facts = useCoachFacts({ enabled: saved && !liveFact && !undone }); const forget = useForgetCoachFact({ onSuccess: () => setUndone(true), - onError: () => toast.error(t("settings.ai.coachMemory.forgotError")), + onError: () => showErrorToast(t("settings.ai.coachMemory.forgotError")), }); if (!saved || undone || !note.factId) return null; const fact = diff --git a/src/components/insights/coach-panel/message-actions.tsx b/src/components/insights/coach-panel/message-actions.tsx index eba8cb999..31354db88 100644 --- a/src/components/insights/coach-panel/message-actions.tsx +++ b/src/components/insights/coach-panel/message-actions.tsx @@ -18,6 +18,7 @@ import { useCallback, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BookmarkCheck, BookmarkPlus, @@ -139,8 +140,8 @@ function CopyMessageButton({ await navigator.clipboard.writeText(text); setCopied(true); window.setTimeout(() => setCopied(false), 1500); - } catch { - toast.error(t("insights.coach.copyMessageError")); + } catch (failure) { + showErrorToast(t("insights.coach.copyMessageError"), { cause: failure }); } }, [content, strip, t]); // Absent where the Clipboard API is (plain-HTTP self-hosts) rather than @@ -309,8 +310,10 @@ function RememberMessageButton({ messageId }: { messageId: string }) { ), ); }, - onError: () => { - toast.error(t("insights.coach.rememberMessage.failed")); + onError: (failure) => { + showErrorToast(t("insights.coach.rememberMessage.failed"), { + cause: failure, + }); }, }); diff --git a/src/components/insights/coach-panel/reminder-suggestion-card.tsx b/src/components/insights/coach-panel/reminder-suggestion-card.tsx index 789ebbba7..476c6277a 100644 --- a/src/components/insights/coach-panel/reminder-suggestion-card.tsx +++ b/src/components/insights/coach-panel/reminder-suggestion-card.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BellPlus, Check, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -60,8 +61,8 @@ export function ReminderSuggestionCard({ : "dismissed", ); }, - onError: () => { - toast.error(t("coach.reminderSuggestion.failed")); + onError: (failure) => { + showErrorToast(t("coach.reminderSuggestion.failed"), { cause: failure }); }, }); diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index 404da80a7..a55fbbd20 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Eye, Loader2, Target } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -98,7 +99,8 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { onSettled: () => setPending(null), // v1.16.4 — a failed save used to revert the switch silently once the // spinner cleared; a toast names the rejection. - onError: () => toast.error(t("insights.coach.prefsSaveError")), + onError: (failure) => + showErrorToast(t("insights.coach.prefsSaveError"), { cause: failure }), }); } diff --git a/src/components/insights/coach-panel/suggested-action-card.tsx b/src/components/insights/coach-panel/suggested-action-card.tsx index fa5694bd6..5ba5cf205 100644 --- a/src/components/insights/coach-panel/suggested-action-card.tsx +++ b/src/components/insights/coach-panel/suggested-action-card.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Check, Loader2, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -53,7 +54,8 @@ export function SuggestedActionCard({ } setSettled("applied"); }, - onError: () => toast.error(t("coach.suggestedAction.failed")), + onError: (failure) => + showErrorToast(t("coach.suggestedAction.failed"), { cause: failure }), }); if (settled) { diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 3aa90e76a..c0c77d30a 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -16,6 +16,7 @@ import { } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import type { CoachActivity, @@ -521,7 +522,7 @@ export function useDeleteCoachConversationWithUndo() { const failedMessage = t("insights.coach.historyDeleteFailed"); commitRef.current = (id) => commitCoachConversationDelete(queryClient, id, owner, () => - toast.error(failedMessage), + showErrorToast(failedMessage), ); }); @@ -596,7 +597,7 @@ export function useRenameCoachConversation() { }), onError: (_error, _input, snapshot) => { if (snapshot) restoreCoachConversationRename(queryClient, snapshot); - toast.error(t("insights.coach.rename.error")); + showErrorToast(t("insights.coach.rename.error"), { cause: _error }); }, onSettled: (_data, _error, input) => { void invalidateCoachConversationRename(queryClient, input.id); diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index 3aa6bb8b9..ecb985c3b 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Eye, EyeOff, @@ -195,7 +196,7 @@ export function InsightsEditMode({ toast.message(t("common.conflictReloaded")); return; } - toast.error(t("insights.editMode.saveError")); + showErrorToast(t("insights.editMode.saveError"), { cause: err }); }, }); @@ -212,7 +213,8 @@ export function InsightsEditMode({ setDraftState(seedArrangeDraft(saved.sections)); toast.success(t("insights.editMode.resetSuccess")); }, - onError: () => toast.error(t("insights.editMode.saveError")), + onError: (failure) => + showErrorToast(t("insights.editMode.saveError"), { cause: failure }), }); const busy = saveMutation.isPending || resetMutation.isPending; diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 3e8c9f920..7719277d0 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -12,6 +12,7 @@ import { Wrench, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Popover, @@ -724,7 +725,7 @@ function InsightsTabStripImpl({ const decision = regenerateToastKind(regenerateOutcome); if (decision) { if (decision.kind === "error") { - toast.error(t(decision.messageKey)); + showErrorToast(t(decision.messageKey)); } else { toast.success(t(decision.messageKey)); } diff --git a/src/components/insights/pattern-dismiss-action.tsx b/src/components/insights/pattern-dismiss-action.tsx index 83118062c..9b00478ab 100644 --- a/src/components/insights/pattern-dismiss-action.tsx +++ b/src/components/insights/pattern-dismiss-action.tsx @@ -4,6 +4,7 @@ import { QueryClientContext, type QueryClient } from "@tanstack/react-query"; import { EyeOff, RotateCcw, X } from "lucide-react"; import { useContext, useReducer, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { apiPatch } from "@/lib/api/api-fetch"; @@ -138,8 +139,8 @@ export function PatternDismissButton({ { dismissed: true }, ); await applyPatternDecisionSuccess(queryClient, onDismissed, onSettled); - } catch { - toast.error(t("insights.pattern.dismissError")); + } catch (failure) { + showErrorToast(t("insights.pattern.dismissError"), { cause: failure }); } finally { setPending(false); } @@ -184,8 +185,8 @@ export function PatternDismissedNotice({ { dismissed: false }, ); await applyPatternDecisionSuccess(queryClient, onRestored, onSettled); - } catch { - toast.error(t("insights.pattern.restoreError")); + } catch (failure) { + showErrorToast(t("insights.pattern.restoreError"), { cause: failure }); } finally { setPending(false); } diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index 18cc9f5a5..8bb1e00ce 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -61,7 +62,8 @@ export function BiomarkerManager() { queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); }, - onError: () => toast.error(t("labs.biomarker.deleteError")), + onError: (failure) => + showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }), }); const hiddenMutation = useMutation({ @@ -75,7 +77,8 @@ export function BiomarkerManager() { ); queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); }, - onError: () => toast.error(t("labs.biomarker.hideError")), + onError: (failure) => + showErrorToast(t("labs.biomarker.hideError"), { cause: failure }), }); function openNew() { diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 9518eb0b8..36573bea5 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -19,6 +19,7 @@ import { Plus, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useAuth } from "@/hooks/use-auth"; import { DeleteButton } from "@/components/data-list"; @@ -283,7 +284,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); router.push("/labs"); }, - onError: () => toast.error(t("labs.biomarker.deleteError")), + onError: (failure) => + showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }), }); // v1.25.1 — the per-marker description leads the page, mirroring the metric diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 109d32de6..efaf0d32e 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -6,6 +6,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton, SortableHead } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -156,8 +157,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { await apiPost("/api/labs/restore", { ids: [id] }); invalidate(); toast.success(t("labs.restoredToast")); - } catch { - toast.error(t("labs.restoreError")); + } catch (failure) { + showErrorToast(t("labs.restoreError"), { cause: failure }); } } @@ -172,7 +173,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { }, }); }, - onError: () => toast.error(t("labs.deleteError")), + onError: (failure) => + showErrorToast(t("labs.deleteError"), { cause: failure }), }); const updateMutation = useMutation({ diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index 46db94312..df911daac 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -14,6 +14,7 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; import { AlertCircle, Loader2, ScanLine, Upload } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Button } from "@/components/ui/button"; @@ -233,21 +234,22 @@ export function OcrReviewDialog({ onSuccess: (data) => { const seeded = seedReviewRows(data.rows, data.reportDate); if (seeded.length === 0) { - toast.error(t("labs.ocr.noRows")); + showErrorToast(t("labs.ocr.noRows")); return; } setRows(seeded); setPageCoverage(data.pageCoverage ?? null); setStage("review"); }, - onError: (err) => toast.error(extractErrorMessage(err, t)), + onError: (err) => + showErrorToast(extractErrorMessage(err, t), { cause: err }), }); } function onSave() { const plan = planSave(rows); if (plan.kind === "nothing-selected") { - toast.error(t("labs.ocr.nothingToSave")); + showErrorToast(t("labs.ocr.nothingToSave")); return; } if (plan.kind === "blocked") { @@ -296,7 +298,8 @@ export function OcrReviewDialog({ onCommitted(); handleClose(false); }, - onError: () => toast.error(t("labs.ocr.saveFailed")), + onError: (failure) => + showErrorToast(t("labs.ocr.saveFailed"), { cause: failure }), }, ); } diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 942909da7..e8b8b570b 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -65,6 +65,7 @@ import { useTableSort } from "@/hooks/use-table-sort"; import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; import { createPortal } from "react-dom"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { formatDateOrRelative, formatDateTime } from "@/lib/format"; import { DayLink, DayLinkAt } from "@/components/day/day-link"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -687,8 +688,8 @@ export function MeasurementList({ await invalidateKeys(queryClient, measurementDependentKeys); await refetchInactiveDailyReads(queryClient); toast.success(t("measurements.restoredToast")); - } catch { - toast.error(t("measurements.restoreError")); + } catch (failure) { + showErrorToast(t("measurements.restoreError"), { cause: failure }); } }, [queryClient, t], @@ -711,8 +712,8 @@ export function MeasurementList({ // v1.16.4 — a failed row delete used to fail silently (no onError // handler); the edit-dialog path surfaced its own inline error but // the row-level confirm had no feedback at all. - onError: () => { - toast.error(t("measurements.deleteError")); + onError: (failure) => { + showErrorToast(t("measurements.deleteError"), { cause: failure }); }, }); @@ -744,8 +745,8 @@ export function MeasurementList({ }, ); }, - onError: () => { - toast.error(t("measurements.bulkDeleteError")); + onError: (failure) => { + showErrorToast(t("measurements.bulkDeleteError"), { cause: failure }); }, }); diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index 9cbc2cc3b..cde93df12 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -14,6 +14,7 @@ import dynamic from "next/dynamic"; import { Activity, TrendingUp } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle"; @@ -434,12 +435,14 @@ function RetargetControl({ await apiPut(`/api/medications/${medicationId}/efficacy/target`, body); onChanged(); if (clear) setValue(""); - } catch { + } catch (failure) { // v1.30 UI audit (M9) — the save previously failed silently: the // buttons re-enabled with no toast, no inline alert, and the // rejection escaped as an unhandled promise. The user could not // tell the override was not saved. - toast.error(t("medications.efficacy.retarget.error")); + showErrorToast(t("medications.efficacy.retarget.error"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/dose-history-add-dialog.tsx b/src/components/medications/dose-history-add-dialog.tsx index 1e81fce04..04789ed39 100644 --- a/src/components/medications/dose-history-add-dialog.tsx +++ b/src/components/medications/dose-history-add-dialog.tsx @@ -21,6 +21,7 @@ import { useId, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -202,8 +203,11 @@ export function LedgerAddDialog({ await refetchInactiveDailyReads(queryClient); setNudgeOpen(false); onOpenChange(false); - } catch { - toast.error(t("medications.intakeToastFailed", { name: medicationName })); + } catch (failure) { + showErrorToast( + t("medications.intakeToastFailed", { name: medicationName }), + { cause: failure }, + ); } finally { setBusy(false); } diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 084bf9e2f..e52462fd2 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -61,6 +61,7 @@ import { Unlink, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -307,10 +308,11 @@ export function DoseHistoryLedger({ // reads (Today hero + dashboard) are unmounted behind the detail // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); - } catch { + } catch (failure) { if (prev) queryClient.setQueryData(queryKey, prev); - toast.error( + showErrorToast( t("medications.intakeToastFailed", { name: medicationName }), + { cause: failure }, ); } finally { setMarking(null); @@ -359,8 +361,10 @@ export function DoseHistoryLedger({ // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); toast.success(t("medications.detail.intake.edit.savedToast")); - } catch { - toast.error(t("medications.detail.intake.edit.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.edit.failed"), { + cause: failure, + }); } }, [medicationId, queryClient, queryKey, t], @@ -393,8 +397,10 @@ export function DoseHistoryLedger({ // reads (Today hero + dashboard) are unmounted behind the detail // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); - } catch { - toast.error(t("medications.detail.intake.edit.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.edit.failed"), { + cause: failure, + }); } }, [medicationId, queryClient, queryKey, t], @@ -412,8 +418,10 @@ export function DoseHistoryLedger({ // reads (Today hero + dashboard) are unmounted behind the detail // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); - } catch { - toast.error(t("medications.detail.intake.deleteRow.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.deleteRow.failed"), { + cause: failure, + }); } } diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 91cd2f779..06e430cb8 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -7,6 +7,7 @@ import { import { useEffect, useReducer, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; @@ -321,7 +322,9 @@ export function Glp1MedicationCard({ injectionSite: site, }); } catch (err) { - toast.error(t("medications.logInjectionSiteSaveFailed")); + showErrorToast(t("medications.logInjectionSiteSaveFailed"), { + cause: err, + }); throw err; } await invalidateKeys(queryClient, medicationDependentKeys); diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index f60d3d623..afe03c1ea 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -24,6 +24,7 @@ import { useId, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -135,8 +136,10 @@ function IntakeEditDialogBody({ await invalidateMedicationReads(queryClient); toast.success(t("medications.detail.intake.edit.savedToast")); onClose(); - } catch { - toast.error(t("medications.detail.intake.edit.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.edit.failed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/intake-history-editable.tsx b/src/components/medications/intake-history-editable.tsx index 04aa954e6..04d3f960a 100644 --- a/src/components/medications/intake-history-editable.tsx +++ b/src/components/medications/intake-history-editable.tsx @@ -22,6 +22,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -102,8 +103,10 @@ export function IntakeHistoryEditable({ toast.success(t("medications.detail.intake.bulkDelete.toast")); setBulkConfirmOpen(false); clearSelection(); - } catch { - toast.error(t("medications.detail.intake.bulkDelete.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.bulkDelete.failed"), { + cause: failure, + }); } finally { setBulkBusy(false); } @@ -126,8 +129,10 @@ export function IntakeHistoryEditable({ next.delete(id); return next; }); - } catch { - toast.error(t("medications.detail.intake.deleteRow.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.deleteRow.failed"), { + cause: failure, + }); } finally { setRowDeleteBusy(false); } diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx index 71f4d0162..bcc7d306e 100644 --- a/src/components/medications/log-intake-dialog.tsx +++ b/src/components/medications/log-intake-dialog.tsx @@ -24,6 +24,7 @@ import { useId, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -195,7 +196,7 @@ export function LogIntakeDialog({ if (slot !== NO_SLOT) { const iso = slotInstantIso(takenAt, slot); if (!iso) { - toast.error(t("medications.logIntake.slotError")); + showErrorToast(t("medications.logIntake.slotError")); return; } scheduledFor = iso; diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 3a3f37cfc..07e73bbf8 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -7,6 +7,7 @@ import { import { useState, useEffect, useReducer } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useMedicationIntake } from "@/components/medications/use-medication-intake"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; @@ -243,7 +244,9 @@ export function MedicationCard({ injectionSite: site, }); } catch (err) { - toast.error(t("medications.logInjectionSiteSaveFailed")); + showErrorToast(t("medications.logInjectionSiteSaveFailed"), { + cause: err, + }); throw err; } await invalidateKeys(queryClient, medicationDependentKeys); diff --git a/src/components/medications/medication-categories-sheet.tsx b/src/components/medications/medication-categories-sheet.tsx index 94d944111..fd6cdefe4 100644 --- a/src/components/medications/medication-categories-sheet.tsx +++ b/src/components/medications/medication-categories-sheet.tsx @@ -9,6 +9,7 @@ import { Eye, EyeOff, Loader2, Pencil, Tag, Trash2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -55,8 +56,10 @@ function CategoryRow({ try { await update.mutateAsync({ key: category.key, label: trimmed }); setEditing(false); - } catch { - toast.error(t("medications.category.custom.saveFailed")); + } catch (failure) { + showErrorToast(t("medications.category.custom.saveFailed"), { + cause: failure, + }); } } @@ -66,8 +69,10 @@ function CategoryRow({ key: category.key, isActive: !category.isActive, }); - } catch { - toast.error(t("medications.category.custom.saveFailed")); + } catch (failure) { + showErrorToast(t("medications.category.custom.saveFailed"), { + cause: failure, + }); } } @@ -221,8 +226,10 @@ export function MedicationCategoriesSheet({ count: String(result.movedCount), }), ); - } catch { - toast.error(t("medications.category.custom.saveFailed")); + } catch (failure) { + showErrorToast(t("medications.category.custom.saveFailed"), { + cause: failure, + }); } } diff --git a/src/components/medications/scheduling/schedule-history-timeline.tsx b/src/components/medications/scheduling/schedule-history-timeline.tsx index 73ab6f2e2..82d8ee2be 100644 --- a/src/components/medications/scheduling/schedule-history-timeline.tsx +++ b/src/components/medications/scheduling/schedule-history-timeline.tsx @@ -39,6 +39,7 @@ import { Trash2, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -143,8 +144,10 @@ export function ScheduleHistoryTimeline({ queryKey: queryKeys.medicationDetail(medicationId), }); }, - onError: () => { - toast.error(t("medications.detail.zeitplan.history.deleteError")); + onError: (failure) => { + showErrorToast(t("medications.detail.zeitplan.history.deleteError"), { + cause: failure, + }); }, }); @@ -468,11 +471,12 @@ function EraDialog({ reset(); onSaved(); }, - onError: () => { - toast.error( + onError: (failure) => { + showErrorToast( target ? t("medications.detail.zeitplan.history.updateError") : t("medications.detail.zeitplan.history.createError"), + { cause: failure }, ); }, }); diff --git a/src/components/medications/scheduling/schedule-times-editor.tsx b/src/components/medications/scheduling/schedule-times-editor.tsx index ce7eef19f..fac183869 100644 --- a/src/components/medications/scheduling/schedule-times-editor.tsx +++ b/src/components/medications/scheduling/schedule-times-editor.tsx @@ -20,6 +20,7 @@ import { useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -170,8 +171,10 @@ export function ScheduleTimesEditor({ await apiPut(`/api/medications/${medicationId}`, body); await invalidateKeys(queryClient, medicationDependentKeys); toast.success(t("medications.detail.zeitplan.saved")); - } catch { - toast.error(t("medications.detail.zeitplan.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zeitplan.failed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/sections/api-tokens-row.tsx b/src/components/medications/sections/api-tokens-row.tsx index 617a17ea9..6ce63e5aa 100644 --- a/src/components/medications/sections/api-tokens-row.tsx +++ b/src/components/medications/sections/api-tokens-row.tsx @@ -19,6 +19,7 @@ import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ChevronDown, ChevronUp, Copy, KeyRound } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -142,8 +143,10 @@ export function ApiTokensRow({ try { await navigator.clipboard.writeText(text); toast.success(label); - } catch { - toast.error(t("medications.detail.api.copyFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.api.copyFailed"), { + cause: failure, + }); } } @@ -178,10 +181,11 @@ export function ApiTokensRow({ : (status?.activeTokenCount ?? 0) + 1, }); } catch (err) { - toast.error( + showErrorToast( err instanceof ApiError && err.message ? err.message : t("medications.detail.api.mintFailed"), + { cause: err }, ); } finally { setBusy(false); diff --git a/src/components/medications/sections/auto-mark-taken-section.tsx b/src/components/medications/sections/auto-mark-taken-section.tsx index dff300198..45f44fa1b 100644 --- a/src/components/medications/sections/auto-mark-taken-section.tsx +++ b/src/components/medications/sections/auto-mark-taken-section.tsx @@ -13,6 +13,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; @@ -55,9 +56,11 @@ export function AutoMarkTakenBody({ ? t("medications.autoMarkTaken.enabledToast") : t("medications.autoMarkTaken.disabledToast"), ); - } catch { + } catch (failure) { setLocalOn(previous); - toast.error(t("medications.autoMarkTaken.toggleFailed")); + showErrorToast(t("medications.autoMarkTaken.toggleFailed"), { + cause: failure, + }); } finally { setSubmitting(false); } diff --git a/src/components/medications/sections/courses-section.tsx b/src/components/medications/sections/courses-section.tsx index a60d5959e..98886eff9 100644 --- a/src/components/medications/sections/courses-section.tsx +++ b/src/components/medications/sections/courses-section.tsx @@ -15,6 +15,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -222,8 +223,8 @@ export function CoursesRow({ await apiDelete(`/api/medications/${medicationId}/courses/${course.id}`); await invalidateMedicationReads(queryClient); setDeleting(null); - } catch { - toast.error(t("medications.course.error.generic")); + } catch (failure) { + showErrorToast(t("medications.course.error.generic"), { cause: failure }); } finally { setDeleteBusy(false); } diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx index 4240c3346..bd3635cc8 100644 --- a/src/components/medications/sections/destructive-zone-section.tsx +++ b/src/components/medications/sections/destructive-zone-section.tsx @@ -30,6 +30,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Archive, Loader2, Trash2 } from "lucide-react"; import { @@ -92,9 +93,11 @@ export function LifecycleManageBody({ : t("medications.detail.zone.pause.resumedToast"), ); onAfterAction?.(); - } catch { + } catch (failure) { setPaused(previous); - toast.error(t("medications.detail.zone.pause.failed")); + showErrorToast(t("medications.detail.zone.pause.failed"), { + cause: failure, + }); } finally { setTier1Busy(false); } @@ -110,8 +113,10 @@ export function LifecycleManageBody({ toast.success(t("medications.detail.zone.end.toast")); setEndDialogOpen(false); onAfterAction?.(); - } catch { - toast.error(t("medications.detail.zone.end.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zone.end.failed"), { + cause: failure, + }); } finally { setTier2Busy(false); } @@ -246,8 +251,10 @@ export function DangerZoneBody({ toast.success(t("medications.detail.zone.purge.toast")); setPurgeDialogOpen(false); onAfterAction?.(); - } catch { - toast.error(t("medications.detail.zone.purge.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zone.purge.failed"), { + cause: failure, + }); } finally { setTier3aBusy(false); } @@ -278,8 +285,10 @@ export function DangerZoneBody({ // analytics / compliance / inline chart). Not awaited: navigation // already happened and a transient refetch must not block it. void invalidateMedicationReads(queryClient); - } catch { - toast.error(t("medications.detail.zone.delete.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zone.delete.failed"), { + cause: failure, + }); setTier3bBusy(false); } } diff --git a/src/components/medications/sections/intake-tracking-section.tsx b/src/components/medications/sections/intake-tracking-section.tsx index 21b86e397..624dba62c 100644 --- a/src/components/medications/sections/intake-tracking-section.tsx +++ b/src/components/medications/sections/intake-tracking-section.tsx @@ -13,6 +13,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; @@ -55,9 +56,11 @@ export function IntakeTrackingBody({ ? t("medications.trackIntake.enabledToast") : t("medications.trackIntake.disabledToast"), ); - } catch { + } catch (failure) { setLocalTracked(previous); - toast.error(t("medications.trackIntake.toggleFailed")); + showErrorToast(t("medications.trackIntake.toggleFailed"), { + cause: failure, + }); } finally { setSubmitting(false); } diff --git a/src/components/medications/sections/inventory-dialogs.tsx b/src/components/medications/sections/inventory-dialogs.tsx index 73939f220..fbbc61941 100644 --- a/src/components/medications/sections/inventory-dialogs.tsx +++ b/src/components/medications/sections/inventory-dialogs.tsx @@ -18,6 +18,7 @@ import { useId, useState, type FormEvent } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -145,8 +146,10 @@ export function AddInventoryDialog({ await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.addSuccess")); onClose(); - } catch { - toast.error(t("medications.detail.bestand.addFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.addFailed"), { + cause: failure, + }); } finally { setBusy(false); } @@ -399,8 +402,10 @@ export function AdjustInventoryDialog({ await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.adjustSuccess")); onClose(); - } catch { - toast.error(t("medications.detail.bestand.adjustFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.adjustFailed"), { + cause: failure, + }); } finally { setBusy(false); } @@ -573,8 +578,10 @@ export function PackagingDialog({ ]); toast.success(t("medications.detail.bestand.packagingSuccess")); onClose(); - } catch { - toast.error(t("medications.detail.bestand.packagingFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.packagingFailed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index f946a6481..7af96d638 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -48,6 +48,7 @@ import { useState } from "react"; import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2, PackageOpen, Plus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -129,8 +130,10 @@ export function InventorySection({ await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`); await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.deleteSuccess")); - } catch { - toast.error(t("medications.detail.bestand.deleteFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.deleteFailed"), { + cause: failure, + }); } } diff --git a/src/components/medications/sections/notifications-section.tsx b/src/components/medications/sections/notifications-section.tsx index fa78453b3..a8c0a3aa1 100644 --- a/src/components/medications/sections/notifications-section.tsx +++ b/src/components/medications/sections/notifications-section.tsx @@ -23,6 +23,7 @@ import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; @@ -137,9 +138,11 @@ export function NotificationsBody({ ? t("medications.detail.notifications.enabledToast") : t("medications.detail.notifications.disabledToast"), ); - } catch { + } catch (failure) { setLocalEnabled(previous); - toast.error(t("medications.detail.notifications.toggleFailed")); + showErrorToast(t("medications.detail.notifications.toggleFailed"), { + cause: failure, + }); } finally { setSubmitting(false); } diff --git a/src/components/medications/sections/phase-config-sheet.tsx b/src/components/medications/sections/phase-config-sheet.tsx index b40933e85..bb2bed055 100644 --- a/src/components/medications/sections/phase-config-sheet.tsx +++ b/src/components/medications/sections/phase-config-sheet.tsx @@ -28,6 +28,7 @@ import { useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -227,10 +228,11 @@ function PhaseConfigForm({ toast.success(t("medications.phaseSaved")); onClose(); } catch (err) { - toast.error( + showErrorToast( err instanceof ApiError && err.message ? err.message : t("medications.detail.phases.saveFailed"), + { cause: err }, ); } finally { setSaving(false); diff --git a/src/components/medications/sections/settings-section.tsx b/src/components/medications/sections/settings-section.tsx index 64856df27..ebb127d47 100644 --- a/src/components/medications/sections/settings-section.tsx +++ b/src/components/medications/sections/settings-section.tsx @@ -21,6 +21,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -124,8 +125,10 @@ export function GraceRow({ await apiPut(`/api/medications/${medicationId}`, body); await invalidateKeys(queryClient, medicationDependentKeys); toast.success(t("medications.detail.settings.grace.saved")); - } catch { - toast.error(t("medications.detail.settings.grace.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.settings.grace.failed"), { + cause: failure, + }); } finally { setGraceBusy(false); } @@ -207,8 +210,10 @@ export function DrugCodingRow({ }); await invalidateKeys(queryClient, medicationDependentKeys); toast.success(t("medications.detail.settings.codes.saved")); - } catch { - toast.error(t("medications.detail.settings.codes.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.settings.codes.failed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 140fb8edb..a2554c2fe 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -9,6 +9,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2, Plus, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -215,8 +216,10 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { }, // v1.16.4 — a failed delete used to fail silently, leaving the row // in place with no signal that the request was rejected. - onError: () => { - toast.error(t("medications.sideEffects.deleteError")); + onError: (failure) => { + showErrorToast(t("medications.sideEffects.deleteError"), { + cause: failure, + }); }, }); diff --git a/src/components/medications/take-all-due.ts b/src/components/medications/take-all-due.ts index 6ce7997c8..04bea56f4 100644 --- a/src/components/medications/take-all-due.ts +++ b/src/components/medications/take-all-due.ts @@ -47,6 +47,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiPost } from "@/lib/api/api-fetch"; import { intakeToastOptions } from "@/components/medications/use-medication-intake"; @@ -286,9 +287,9 @@ export async function runTakeAllDue(deps: { toast.success(message); } } else if (taken > 0) { - toast.error(t("medications.takeAllDue.partialToast", { taken, failed })); + showErrorToast(t("medications.takeAllDue.partialToast", { taken, failed })); } else { - toast.error(t("medications.takeAllDue.failedToast")); + showErrorToast(t("medications.takeAllDue.failedToast")); } return { taken, failed }; diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts index bf1abbdaa..6abb424b9 100644 --- a/src/components/medications/use-medication-intake.ts +++ b/src/components/medications/use-medication-intake.ts @@ -4,6 +4,7 @@ import { useActiveRecordName } from "@/hooks/use-record-capabilities"; import { useState } from "react"; import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateMedicationReads } from "@/lib/query-keys"; @@ -203,8 +204,11 @@ export async function runRecordIntake(deps: { ); await invalidateMedicationReads(queryClient); onRecorded?.(eventId, skipped); - } catch { - toast.error(t("medications.intakeToastFailed", { name: medication.name })); + } catch (failure) { + showErrorToast( + t("medications.intakeToastFailed", { name: medication.name }), + { cause: failure }, + ); } finally { setIntakeLoading(null); } @@ -300,8 +304,11 @@ export async function runLogIntake(deps: { toast.success(message); } return true; - } catch { - toast.error(t("medications.intakeToastFailed", { name: medication.name })); + } catch (failure) { + showErrorToast( + t("medications.intakeToastFailed", { name: medication.name }), + { cause: failure }, + ); return false; } } @@ -321,8 +328,8 @@ export async function runUndoIntake(deps: { await apiDelete(`/api/medications/${medication.id}/intake/${eventId}`); await invalidateMedicationReads(queryClient); toast.success(t("medications.intakeUndone")); - } catch { - toast.error(t("medications.intakeUndoFailed")); + } catch (failure) { + showErrorToast(t("medications.intakeUndoFailed"), { cause: failure }); } } diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx index 26e0d8cf2..1a79b18ec 100644 --- a/src/components/mood/__tests__/mood-qol-fixes.test.tsx +++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx @@ -193,9 +193,11 @@ describe("MoodList — level A on the edit path (v1.37)", () => { describe("MoodList — delete failure toast (v1.11.5)", () => { it("surfaces a toast on a rejected delete instead of failing silently", () => { - expect(listSrc).toContain('import { toast } from "sonner"'); + expect(listSrc).toContain( + 'import { showErrorToast } from "@/lib/errors/error-toast"', + ); expect(listSrc).toMatch( - /deleteMutation = useMutation\(\{[\s\S]*onError: \(\) => \{[\s\S]*toast\.error\(t\("mood\.deleteError"\)\)/, + /deleteMutation = useMutation\(\{[\s\S]*onError: \((\w+)\) => \{[\s\S]*showErrorToast\(t\("mood\.deleteError"\), \{ cause: \1 \}\)/, ); }); }); diff --git a/src/components/mood/manage/archived-tags-card.tsx b/src/components/mood/manage/archived-tags-card.tsx index 1b095e126..21bef8aa3 100644 --- a/src/components/mood/manage/archived-tags-card.tsx +++ b/src/components/mood/manage/archived-tags-card.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { ArchiveRestore, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -70,9 +71,12 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) { } catch (err) { rollback(); if (err instanceof ApiError && err.status === 422) { - toast.error(t("mood.manage.tagLimitReached")); + showErrorToast(t("mood.manage.tagLimitReached"), { cause: err }); } else { - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } } finally { void invalidateMoodTagCaches(queryClient); @@ -89,7 +93,10 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) { toast.success(t("mood.manage.purged")); } catch (err) { rollback(); - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } finally { void invalidateMoodTagCaches(queryClient); } diff --git a/src/components/mood/manage/tag-editor-sheet.tsx b/src/components/mood/manage/tag-editor-sheet.tsx index bb678c8ac..c2014b3db 100644 --- a/src/components/mood/manage/tag-editor-sheet.tsx +++ b/src/components/mood/manage/tag-editor-sheet.tsx @@ -4,6 +4,7 @@ import { useId, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -141,9 +142,12 @@ export function TagEditorSheet({ void invalidateMoodTagCaches(queryClient); } catch (err) { if (err instanceof ApiError && err.status === 422) { - toast.error(t("mood.manage.tagLimitReached")); + showErrorToast(t("mood.manage.tagLimitReached"), { cause: err }); } else { - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } } finally { setSaving(false); diff --git a/src/components/mood/manage/tag-groups-card.tsx b/src/components/mood/manage/tag-groups-card.tsx index 329a2483b..6c87547c0 100644 --- a/src/components/mood/manage/tag-groups-card.tsx +++ b/src/components/mood/manage/tag-groups-card.tsx @@ -30,6 +30,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -173,7 +174,10 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) { toast.message(t("common.conflictReloaded")); return false; } - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); return false; } finally { void invalidateMoodTagCaches(queryClient); @@ -354,8 +358,9 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) { ); toast.success(t("mood.manage.groupDeleted")); } catch (err) { - toast.error( + showErrorToast( err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, ); } finally { void invalidateMoodTagCaches(queryClient); @@ -554,9 +559,12 @@ function GroupEditorSheet({ void invalidateMoodTagCaches(queryClient); } catch (err) { if (err instanceof ApiError && err.status === 422) { - toast.error(t("mood.manage.groupLimitReached")); + showErrorToast(t("mood.manage.groupLimitReached"), { cause: err }); } else { - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } } finally { setSaving(false); diff --git a/src/components/mood/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx index e83e14e2e..9f39d4bae 100644 --- a/src/components/mood/manage/tag-manager-card.tsx +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -33,6 +33,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -166,7 +167,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { toast.message(t("common.conflictReloaded")); return false; } - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); return false; } finally { void invalidateMoodTagCaches(queryClient); @@ -238,7 +242,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { } } catch (err) { rollback(); - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } finally { void invalidateMoodTagCaches(queryClient); } @@ -275,7 +282,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { toast.message(t("common.conflictReloaded")); return; } - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } finally { void invalidateMoodTagCaches(queryClient); } diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 03602aad7..f9035cd54 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -69,6 +69,7 @@ import { // reads newest-first by default; every other column opens ascending. const MOOD_DESC_COLUMNS: ReadonlySet = new Set(["moodLoggedAt"]); import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { formatDateTime } from "@/lib/format"; import { DayLink } from "@/components/day/day-link"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -384,8 +385,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { await invalidateKeys(queryClient, moodDependentKeys); await refetchInactiveDailyReads(queryClient); toast.success(t("mood.restoredToast")); - } catch { - toast.error(t("mood.restoreError")); + } catch (failure) { + showErrorToast(t("mood.restoreError"), { cause: failure }); } }, [queryClient, t], @@ -409,8 +410,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { // leaving the row in place with no signal that the request was rejected. // Surface the failure so the user knows to retry. Mirrors the v1.11.3 // medication-card silent-failure fix. - onError: () => { - toast.error(t("mood.deleteError")); + onError: (failure) => { + showErrorToast(t("mood.deleteError"), { cause: failure }); }, }); @@ -439,8 +440,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { }, ); }, - onError: () => { - toast.error(t("mood.bulkDeleteError")); + onError: (failure) => { + showErrorToast(t("mood.bulkDeleteError"), { cause: failure }); }, }); diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index d85f732c3..ba06c061b 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BaselineFields, @@ -234,7 +235,9 @@ export function BaselineForm({ await onConfirmed(); }, (err) => - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")), + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }), ); } diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index 2f8e9160f..fc1012f26 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BaselineForm } from "@/components/onboarding/baseline-form"; import { StepHeading } from "@/components/onboarding/step-heading"; @@ -189,7 +190,9 @@ export function ConfirmScreen({ /** The managed arm's two buttons: nothing above them reports a failure. */ function finishReporting(managedRecordId?: string) { void finish(managedRecordId).catch((err) => { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); }); } diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 02462c2ba..58aab06f1 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { CheckCircle2, Compass, @@ -171,10 +172,10 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { queryKey: queryKeys.aiConsentReceipt("ai_full"), }); }, - onError: () => { + onError: (failure) => { // The button stays. A failed grant is a failed grant; the screen must // not read as though consent were on file. - toast.error(t("onboarding.ai.offer.error")); + showErrorToast(t("onboarding.ai.offer.error"), { cause: failure }); }, }); diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index 948ba61c0..86b3f2300 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { MeasurementForm } from "@/components/measurements/measurement-form"; @@ -109,7 +110,9 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { }); setCompleted(true); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } @@ -118,7 +121,9 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { await answer.mutateAsync({ step: "first-result", status: "skipped" }); router.push(screenHref("done")); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx index a151437ee..37577e5ae 100644 --- a/src/components/onboarding/question-screen.tsx +++ b/src/components/onboarding/question-screen.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Activity, Brain, @@ -110,7 +111,9 @@ export function QuestionScreen({ const written = await answer.mutateAsync(input); router.push(screenHref(nextScreen(written, step) ?? "confirm")); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/onboarding/units-screen.tsx b/src/components/onboarding/units-screen.tsx index e4be7672d..1eb517c62 100644 --- a/src/components/onboarding/units-screen.tsx +++ b/src/components/onboarding/units-screen.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ChoiceChip } from "@/components/onboarding/choice-chip"; import { StepActions } from "@/components/onboarding/step-actions"; @@ -68,7 +69,9 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) { const written = await answer.mutateAsync(input); router.push(screenHref(nextScreen(written, "units") ?? "confirm")); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/onboarding/welcome-screen.tsx b/src/components/onboarding/welcome-screen.tsx index 736f4b69d..1e62c48ee 100644 --- a/src/components/onboarding/welcome-screen.tsx +++ b/src/components/onboarding/welcome-screen.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -70,7 +71,9 @@ export function WelcomeScreen({ variant }: { variant: "fresh" | "again" }) { acknowledge.isPending || complete.isPending || restart.isPending; const fail = (err: unknown) => - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); async function setUp() { if (pending || !acknowledged) return; diff --git a/src/components/outcome/outcome-toast.ts b/src/components/outcome/outcome-toast.ts index 84ca8d157..20a9f71c1 100644 --- a/src/components/outcome/outcome-toast.ts +++ b/src/components/outcome/outcome-toast.ts @@ -1,6 +1,7 @@ "use client"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; @@ -25,7 +26,7 @@ export function toastWrittenOutcome( toast.warning(message); return; case "failed": - toast.error(message); + showErrorToast(message); return; case "empty": toast.info(message); diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index 7aa6524b0..b3232114f 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -14,6 +14,7 @@ import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -256,7 +257,10 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { const id = deleting.id; setDeleting(null); remove.mutate(id, { - onError: () => toast.error(t("practitioners.deleteFailed")), + onError: (failure) => + showErrorToast(t("practitioners.deleteFailed"), { + cause: failure, + }), }); }} > diff --git a/src/components/practitioners/practitioner-sheet.tsx b/src/components/practitioners/practitioner-sheet.tsx index 0c2639a43..53e5394d5 100644 --- a/src/components/practitioners/practitioner-sheet.tsx +++ b/src/components/practitioners/practitioner-sheet.tsx @@ -14,6 +14,7 @@ */ import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { FieldGroup } from "@/components/ui/field-group"; @@ -107,7 +108,7 @@ export function PractitionerSheet({ const message = err instanceof Error ? err.message : t("practitioners.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 07d77b6dc..5b10ecbde 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -18,6 +18,7 @@ import { import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/config"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Toaster } from "@/components/ui/sonner"; import { applyThemeColor } from "@/lib/pwa/theme-color"; import { KeyboardInsetBridge } from "@/components/keyboard-inset-bridge"; @@ -370,7 +371,7 @@ function OfflineMutationToaster() { typeof navigator !== "undefined" && navigator.onLine === false ) { - toast.error(t("offlineBanner.saveFailed"), { + showErrorToast(t("offlineBanner.saveFailed"), { id: "offline-mutation-failed", }); } diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx index 2fa1059bf..a5f334d93 100644 --- a/src/components/records/about-me-note-manager.tsx +++ b/src/components/records/about-me-note-manager.tsx @@ -30,6 +30,7 @@ import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, MessageCircleQuestion, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -116,7 +117,7 @@ export function AboutMeNoteManager() { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("settings.ai.aboutMe.saveError")); + showErrorToast(t("settings.ai.aboutMe.saveError"), { cause: err }); }, }); diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx index 8f0c3e88b..9ad51e11d 100644 --- a/src/components/records/ai-profile-inclusion-manager.tsx +++ b/src/components/records/ai-profile-inclusion-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -88,7 +89,7 @@ export function AiProfileInclusionManager() { if (isConflict(error)) { queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() }); } - toast.error(t("records.aiInclusion.saveError")); + showErrorToast(t("records.aiInclusion.saveError"), { cause: error }); }, }); diff --git a/src/components/records/allergy-free-text-note.tsx b/src/components/records/allergy-free-text-note.tsx index f9efc719b..741d00396 100644 --- a/src/components/records/allergy-free-text-note.tsx +++ b/src/components/records/allergy-free-text-note.tsx @@ -25,6 +25,7 @@ import { useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -98,7 +99,7 @@ export function AllergyFreeTextNote({ toast.message(t("common.conflictReloaded")); return; } - toast.error(t("records.allergies.freeText.saveError")); + showErrorToast(t("records.allergies.freeText.saveError"), { cause: err }); }, }); diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index e89ce9a56..015931429 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -4,6 +4,7 @@ import { useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Pencil, Plus, ShieldAlert } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Badge } from "@/components/ui/badge"; @@ -53,7 +54,8 @@ export function AllergyManager({ toast.success(t("records.allergies.deletedToast")); queryClient.invalidateQueries({ queryKey: queryKeys.allergies() }); }, - onError: () => toast.error(t("records.allergies.deleteError")), + onError: (failure) => + showErrorToast(t("records.allergies.deleteError"), { cause: failure }), }); function openNew() { diff --git a/src/components/records/conditions-manager.tsx b/src/components/records/conditions-manager.tsx index 681ca000b..9045d4c90 100644 --- a/src/components/records/conditions-manager.tsx +++ b/src/components/records/conditions-manager.tsx @@ -24,6 +24,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -89,8 +90,8 @@ export function ConditionsManager() { queryKey: queryKeys.coachAboutMeQuestions(), }); }, - onError: () => { - toast.error(t("records.conditions.saveError")); + onError: (failure) => { + showErrorToast(t("records.conditions.saveError"), { cause: failure }); }, }); diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index 7bf147763..c8223954d 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -84,7 +85,8 @@ export function EmergencyProfileManager() { queryClient.setQueryData(queryKeys.emergencyProfile(), data); toastWrittenOutcome("success", t("records.emergency.savedToast")); }, - onError: () => toast.error(t("records.emergency.saveError")), + onError: (failure) => + showErrorToast(t("records.emergency.saveError"), { cause: failure }), }); if (query.isError) { diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index 2f84fff10..e9266e439 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Pencil, Plus, Users } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -43,7 +44,8 @@ export function FamilyHistoryManager() { toast.success(t("records.family.deletedToast")); queryClient.invalidateQueries({ queryKey: queryKeys.familyHistory() }); }, - onError: () => toast.error(t("records.family.deleteError")), + onError: (failure) => + showErrorToast(t("records.family.deleteError"), { cause: failure }), }); function openNew() { diff --git a/src/components/records/health-profile-facts-manager.tsx b/src/components/records/health-profile-facts-manager.tsx index 61676506f..4e05393a3 100644 --- a/src/components/records/health-profile-facts-manager.tsx +++ b/src/components/records/health-profile-facts-manager.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -177,7 +178,7 @@ export function HealthProfileFactsManager() { if (failed.length === 0) { toastWrittenOutcome("success", t("records.profileFacts.savedToast")); } else { - toast.error( + showErrorToast( t("records.profileFacts.savePartialError", { kinds: failed.map((r) => t(KIND_LABEL[r.kind])).join(", "), }), @@ -228,10 +229,11 @@ export function HealthProfileFactsManager() { queryKey: queryKeys.healthProfileFacts(), }); } - toast.error( + showErrorToast( t("records.profileFacts.removeError", { kind: t(KIND_LABEL[current.kind]), }), + { cause: error }, ); }, }); diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 5904130ed..2e913d33e 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SupportInfoCard } from "@/components/settings/support-info-card"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -260,6 +261,9 @@ export function AboutSection() { )} + {/* v1.43 — the text a bug report needs, put together in the browser. */} + + {/* Sources & docs — every link card was untitled in the v1.4.2 About surface, so the buttons floated alone with no heading telling the user what the section was for. */} diff --git a/src/components/settings/access/managed-profile-handover.tsx b/src/components/settings/access/managed-profile-handover.tsx index 934ed319e..d6fc0efc3 100644 --- a/src/components/settings/access/managed-profile-handover.tsx +++ b/src/components/settings/access/managed-profile-handover.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Check, Copy, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { stepUpErrorKey } from "@/components/settings/access/grant-action-error"; @@ -406,8 +407,10 @@ function HandoverLink({ t("recordSharing.managed.handover.copied"), ); window.setTimeout(() => setCopied(false), 2_000); - } catch { - toast.error(t("recordSharing.managed.handover.copyError")); + } catch (failure) { + showErrorToast(t("recordSharing.managed.handover.copyError"), { + cause: failure, + }); } } diff --git a/src/components/settings/account-section/setup-card.tsx b/src/components/settings/account-section/setup-card.tsx index 47d19d28d..50e4f6f41 100644 --- a/src/components/settings/account-section/setup-card.tsx +++ b/src/components/settings/account-section/setup-card.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ListChecks, Loader2 } from "lucide-react"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -37,7 +38,9 @@ export function SetupCard() { await restart.mutateAsync(); router.push("/onboarding/who"); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index 236b9fde2..c12d86e85 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -7,6 +7,7 @@ import { useState, type ReactNode } from "react"; import { Activity, Loader2, RefreshCw, Sparkles } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; @@ -201,8 +202,8 @@ export function RuntimeActionsRow({ try { await apiPut("/api/insights/settings", { privacyMode: next }); onPrivacyChanged(); - } catch { - toast.error(t("settings.savingError")); + } catch (failure) { + showErrorToast(t("settings.savingError"), { cause: failure }); } } diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 9639edb24..2357a16a5 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -15,6 +15,7 @@ import { type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -229,8 +230,8 @@ function IngestTokenCard({ setTokenCopied(true); toast.success(t("settings.tokenCopied")); setTimeout(() => setTokenCopied(false), 2_000); - } catch { - toast.error(t("settings.tokenCopyFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenCopyFailed"), { cause: failure }); } } @@ -455,12 +456,12 @@ function ApiTokensCard() { method: "DELETE", }); if (!res.ok) { - toast.error(t("settings.tokenRevokeFailed")); + showErrorToast(t("settings.tokenRevokeFailed"), { cause: res }); return; } queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); - } catch { - toast.error(t("settings.tokenRevokeFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenRevokeFailed"), { cause: failure }); } } diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 3264a1289..20403a5ca 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -56,6 +56,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiDelete } from "@/lib/api/api-fetch"; import { Textarea } from "@/components/ui/textarea"; import { @@ -123,7 +124,7 @@ function FactRow({ const edit = useEditCoachFact({ // The edited words in place are the confirmation. onSuccess: () => setEditing(false), - onError: () => toast.error(t("admin.settingsSaveError")), + onError: () => showErrorToast(t("admin.settingsSaveError")), }); const sourceKey = factSourceKey(fact); const trimmed = draft.trim(); @@ -262,7 +263,7 @@ export function CoachMemorySection({ const forgetOne = useForgetCoachFact({ onSuccess: () => toast.success(t("settings.ai.coachMemory.forgotToast")), - onError: () => toast.error(t("settings.ai.coachMemory.forgotError")), + onError: () => showErrorToast(t("settings.ai.coachMemory.forgotError")), }); const forgetAll = useMutation({ @@ -279,8 +280,10 @@ export function CoachMemorySection({ ); queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() }); }, - onError: () => { - toast.error(t("settings.ai.coachMemory.clearError")); + onError: (failure) => { + showErrorToast(t("settings.ai.coachMemory.clearError"), { + cause: failure, + }); }, }); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 1c3afecfa..f13ba961e 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useId, useMemo, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { LayoutDashboard, RotateCcw, @@ -335,7 +336,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { toast.message(t("dashboard.layoutConflictReloaded")); return; } - toast.error(t("dashboard.layoutSaveError")); + showErrorToast(t("dashboard.layoutSaveError"), { cause: err }); }, }); diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx index 2c3ef08c1..0bc0850e3 100644 --- a/src/components/settings/environment-section.tsx +++ b/src/components/settings/environment-section.tsx @@ -18,6 +18,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { CloudSun, MapPin, Plane, Trash2, Wind } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -180,7 +181,10 @@ export function EnvironmentSection() { setHomeResults(data.results); setHomeSearched(true); }, - onError: () => toast.error(t("settings.sections.environment.searchError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.searchError"), { + cause: failure, + }), }); const searchTravel = useMutation({ @@ -190,7 +194,10 @@ export function EnvironmentSection() { setTravelResults(data.results); setTravelSearched(true); }, - onError: () => toast.error(t("settings.sections.environment.searchError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.searchError"), { + cause: failure, + }), }); const setHome = useMutation({ @@ -209,7 +216,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.homeSaved")); }, - onError: () => toast.error(t("settings.sections.environment.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.saveError"), { + cause: failure, + }), }); const addTravel = useMutation({ @@ -230,7 +240,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.travelAdded")); }, - onError: () => toast.error(t("settings.sections.environment.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.saveError"), { + cause: failure, + }), }); const removeTravel = useMutation({ @@ -240,7 +253,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.travelRemoved")); }, - onError: () => toast.error(t("settings.sections.environment.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.saveError"), { + cause: failure, + }), }); const setAirQuality = useMutation({ @@ -249,8 +265,10 @@ export function EnvironmentSection() { apiPatch("/api/environment/preferences", { airQualityEnabled }), // The switch itself shows the new state; no toast on success. onSuccess: () => invalidate(), - onError: () => - toast.error(t("settings.sections.environment.airQuality.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.airQuality.saveError"), { + cause: failure, + }), }); const backfill = useMutation({ @@ -264,8 +282,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.backfillQueued")); }, - onError: () => - toast.error(t("settings.sections.environment.backfillError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.backfillError"), { + cause: failure, + }), }); if (!enabled) { diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index 7222916a8..aeeb5c209 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Eye, EyeOff, GripVertical, ListOrdered, Loader2 } from "lucide-react"; import { DndContext, @@ -187,7 +188,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("insights.pillOrder.saveError")); + showErrorToast(t("insights.pillOrder.saveError"), { cause: err }); }, }); diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index bc89bb947..e0773c183 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -15,6 +15,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card"; import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note"; @@ -246,7 +247,7 @@ export function ConnectionsPanel({ const reasonKey = WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ?? "settings.withingsOauthError.generic"; - toast.error(t("settings.withingsOauthFailed"), { + showErrorToast(t("settings.withingsOauthFailed"), { description: t(reasonKey), duration: 10_000, }); @@ -285,7 +286,7 @@ export function ConnectionsPanel({ } else { // Known reason tags resolve to a specific message; anything else falls // back to the generic copy (matching the Withings handler). - toast.error(t(`settings.${provider}OauthFailed`), { + showErrorToast(t(`settings.${provider}OauthFailed`), { description: t(oauthReasonKey(provider, oauthOutcome.reason)), duration: 10_000, }); diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index a9dfe96a1..fdffc8ad1 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -20,6 +20,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -106,12 +107,16 @@ function McpConnectionsCard() { method: "DELETE", }); if (!res.ok) { - toast.error(t("settings.mcp.connectionRevokeFailed")); + showErrorToast(t("settings.mcp.connectionRevokeFailed"), { + cause: res, + }); return; } queryClient.invalidateQueries({ queryKey: queryKeys.mcpConnections() }); - } catch { - toast.error(t("settings.mcp.connectionRevokeFailed")); + } catch (failure) { + showErrorToast(t("settings.mcp.connectionRevokeFailed"), { + cause: failure, + }); } } @@ -224,7 +229,8 @@ function McpEnableCard() { void invalidateKeys(queryClient, aiInputDependentKeys); toast.success(t("settings.sections.modules.saved")); }, - onError: () => toast.error(t("settings.sections.modules.error")), + onError: (failure) => + showErrorToast(t("settings.sections.modules.error"), { cause: failure }), }); return ( @@ -334,8 +340,8 @@ function McpTokensCard() { setTokenCopied(true); toast.success(t("settings.tokenCopied")); setTimeout(() => setTokenCopied(false), 2_000); - } catch { - toast.error(t("settings.tokenCopyFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenCopyFailed"), { cause: failure }); } } @@ -345,12 +351,12 @@ function McpTokensCard() { method: "DELETE", }); if (!res.ok) { - toast.error(t("settings.tokenRevokeFailed")); + showErrorToast(t("settings.tokenRevokeFailed"), { cause: res }); return; } queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() }); - } catch { - toast.error(t("settings.tokenRevokeFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenRevokeFailed"), { cause: failure }); } } diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index 88c2542f1..5987c6574 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -48,6 +48,7 @@ import { useMemo, useState } from "react"; import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -248,7 +249,7 @@ export function ScoreSection() { return; } } - toast.error(t("settings.sections.score.error")); + showErrorToast(t("settings.sections.score.error"), { cause: err }); }, }); @@ -267,8 +268,10 @@ export function ScoreSection() { ); void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); }, - onError: () => { - toast.error(t("settings.sections.score.notice.dismissError")); + onError: (failure) => { + showErrorToast(t("settings.sections.score.notice.dismissError"), { + cause: failure, + }); }, }); @@ -287,8 +290,10 @@ export function ScoreSection() { ); void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); }, - onError: () => { - toast.error(t("settings.sections.score.notice.dismissError")); + onError: (failure) => { + showErrorToast(t("settings.sections.score.notice.dismissError"), { + cause: failure, + }); }, }); diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 30dbba232..49f1e845c 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -31,6 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ArrowDown, ArrowUp, @@ -193,7 +194,10 @@ export function SourcesSection() { setDraft(null); toast.success(t("settings.sections.sources.saveSuccess")); }, - onError: () => toast.error(t("settings.sections.sources.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.sources.saveError"), { + cause: failure, + }), }); const resetMutation = useMutation({ diff --git a/src/components/settings/support-info-card.tsx b/src/components/settings/support-info-card.tsx new file mode 100644 index 000000000..7daa22a25 --- /dev/null +++ b/src/components/settings/support-info-card.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Copy, LifeBuoy } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import type { IntegrationStatusEnvelope } from "@/components/settings/integrations/shared"; +import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; +import { recentErrorIds } from "@/lib/errors/error-id"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { buildSupportInfo } from "@/lib/support/support-info"; + +interface VersionPayload { + version: string; + buildSha: string | null; +} + +/** + * Settings, About: "Copy support info". Puts together what a bug report needs + * from reads the app already makes (build, language, time zone, modules, AI + * state, each connection's health, the last error ids of this session) and + * copies it as plain text. No new route, nothing leaves the browser until the + * person pastes it somewhere. See `src/lib/support/support-info.ts`. + */ +export function SupportInfoCard() { + const { t, locale } = useTranslations(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(false); + + async function copy() { + setBusy(true); + try { + const [version, integrations] = await Promise.all([ + queryClient + .fetchQuery({ + queryKey: queryKeys.apiVersion(), + queryFn: async () => apiGet("/api/version"), + staleTime: Infinity, + }) + .catch(() => null), + queryClient + .fetchQuery({ + queryKey: queryKeys.integrationsStatus(), + queryFn: async () => + apiGet("/api/integrations/status"), + }) + .catch(() => null), + ]); + const text = buildSupportInfo({ + version, + me: user + ? { + id: user.id, + username: user.username, + email: user.email, + fullName: user.fullName, + timezone: user.timezone, + modules: user.modules, + ai: user.ai ?? null, + } + : null, + locale, + connections: integrations?.integrations ?? null, + recentErrorIds: recentErrorIds(), + client: { + userAgent: + typeof navigator !== "undefined" ? navigator.userAgent : null, + standalone: + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(display-mode: standalone)").matches, + }, + generatedAt: new Date().toISOString(), + }); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + window.prompt(t("settings.about.support.copy"), text); + } + } catch (failure) { + showErrorToast(t("settings.about.support.copyFailed"), { + cause: failure, + }); + } finally { + setBusy(false); + } + } + + return ( + + + + + + + ); +} diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index 6e857bc5a..ee3869cb9 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button"; @@ -101,8 +102,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) { toast.success(t("thresholds.saveSuccess")); }, onError: (err) => - toast.error( + showErrorToast( err instanceof Error ? err.message : t("thresholds.saveError"), + { cause: err }, ), }); @@ -131,7 +133,8 @@ export function ThresholdsEditorSection({ id }: { id: string }) { void refetchInactiveDailyReads(queryClient); toast.success(t("thresholds.resetSuccess")); }, - onError: () => toast.error(t("thresholds.saveError")), + onError: (failure) => + showErrorToast(t("thresholds.saveError"), { cause: failure }), }); return ( diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index bddd162e0..c0f018f18 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -3,6 +3,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { RotateCcw } from "lucide-react"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -257,8 +258,9 @@ function TargetEditSheetBody({ onOpenChange(false); }, onError: (err) => - toast.error( + showErrorToast( err instanceof Error ? err.message : t("targets.edit.saveError"), + { cause: err }, ), }); @@ -284,7 +286,8 @@ function TargetEditSheetBody({ toast.success(t("targets.edit.resetSuccess")); onOpenChange(false); }, - onError: () => toast.error(t("targets.edit.saveError")), + onError: (failure) => + showErrorToast(t("targets.edit.saveError"), { cause: failure }), }); const busy = updateMutation.isPending || resetMutation.isPending; diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx index cb80e2982..ff7488a06 100644 --- a/src/components/vaccinations/booster-mint-prompt.tsx +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -18,6 +18,7 @@ */ import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { @@ -105,8 +106,9 @@ export function BoosterMintPrompt({ }); toastWrittenOutcome("success", t("vaccinations.booster.planned")); } catch (err) { - toast.error( + showErrorToast( err instanceof Error ? err.message : t("vaccinations.booster.failed"), + { cause: err }, ); } finally { onClose(); diff --git a/src/components/vaccinations/custom-vaccine-sheet.tsx b/src/components/vaccinations/custom-vaccine-sheet.tsx index 929ac6819..7431a0add 100644 --- a/src/components/vaccinations/custom-vaccine-sheet.tsx +++ b/src/components/vaccinations/custom-vaccine-sheet.tsx @@ -19,6 +19,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -152,7 +153,7 @@ export function CustomVaccineSheet({ ? t("vaccinations.custom.nameTaken") : t("vaccinations.custom.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; @@ -315,8 +316,10 @@ export function CustomVaccineSheet({ ); onOpenChange(false); }, - onError: () => - toast.error(t("vaccinations.custom.saveFailed")), + onError: (failure) => + showErrorToast(t("vaccinations.custom.saveFailed"), { + cause: failure, + }), }); }} > diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx index 942ba59bc..319d19b0a 100644 --- a/src/components/vaccinations/vaccination-document-suggestion.tsx +++ b/src/components/vaccinations/vaccination-document-suggestion.tsx @@ -19,6 +19,7 @@ import { useState } from "react"; import { Check, Syringe } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -74,7 +75,10 @@ export function VaccinationDocumentSuggestion({ setLinkedId(candidate.id); toastWrittenOutcome("success", t("vaccinations.suggestion.linked")); }, - onError: () => toast.error(t("vaccinations.suggestion.failed")), + onError: (failure) => + showErrorToast(t("vaccinations.suggestion.failed"), { + cause: failure, + }), }, ); }; diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 3cb0e8130..40dbbd401 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -21,6 +21,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -100,7 +101,7 @@ export function VaccinationSheet({ const message = err instanceof Error ? err.message : t("vaccinations.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; @@ -188,8 +189,10 @@ export function VaccinationSheet({ onClick={() => { remove.mutate(vaccination.id, { onSuccess: () => onOpenChange(false), - onError: () => - toast.error(t("vaccinations.deleteFailed")), + onError: (failure) => + showErrorToast(t("vaccinations.deleteFailed"), { + cause: failure, + }), }); }} > diff --git a/src/hooks/use-account-switch.ts b/src/hooks/use-account-switch.ts index e23ddc89a..924073df2 100644 --- a/src/hooks/use-account-switch.ts +++ b/src/hooks/use-account-switch.ts @@ -35,6 +35,7 @@ import { } from "@tanstack/react-query"; import { useCallback } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { @@ -223,10 +224,11 @@ export function useAccountSwitch() { return landOnRecord(destination); }, onError: (error: unknown) => { - toast.error( + showErrorToast( isSharingAccessDenied(error) ? t("recordSharing.switch.noLongerAvailable") : t("recordSharing.switch.failed"), + { cause: error }, ); }, }); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 038625e60..fb17c7f50 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -8,6 +8,7 @@ import { } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch"; import { adoptRecordFenceState } from "@/lib/api/record-fence"; @@ -646,6 +647,7 @@ export function useLogout() { }, // v1.16.4 — a network-failed logout used to do nothing at all (the // menu closed, the session stayed); a toast names the failure. - onError: () => toast.error(t("common.networkError")), + onError: (failure) => + showErrorToast(t("common.networkError"), { cause: failure }), }); } diff --git a/src/hooks/use-module-toggle.ts b/src/hooks/use-module-toggle.ts index db2b9224b..c54fbce6b 100644 --- a/src/hooks/use-module-toggle.ts +++ b/src/hooks/use-module-toggle.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useAuth } from "@/hooks/use-auth"; import { apiPatch } from "@/lib/api/api-fetch"; @@ -106,7 +107,7 @@ export function useModuleToggle(options?: { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("settings.sections.modules.error")); + showErrorToast(t("settings.sections.modules.error"), { cause: err }); }, }); } diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts index 148635b07..e526e6f12 100644 --- a/src/lib/api/__tests__/api-fetch.test.ts +++ b/src/lib/api/__tests__/api-fetch.test.ts @@ -405,3 +405,51 @@ describe("verb helpers", () => { ); }); }); + +describe("ApiError.requestId", () => { + it("takes meta.requestId, else the x-request-id header", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { data: null, error: "boom", meta: { requestId: "meta-id-0001" } }, + 500, + ), + ); + const fromMeta = await apiGet("/api/x").catch((e: unknown) => e); + expect(fromMeta).toBeInstanceOf(ApiError); + expect((fromMeta as ApiError).requestId).toBe("meta-id-0001"); + + fetchMock.mockResolvedValueOnce( + new Response("Bad gateway", { + status: 502, + headers: { "x-request-id": "header-id-0001" }, + }), + ); + const fromHeader = await apiGet("/api/x").catch((e: unknown) => e); + expect((fromHeader as ApiError).requestId).toBe("header-id-0001"); + + fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 })); + const none = await apiGet("/api/x").catch((e: unknown) => e); + expect((none as ApiError).requestId).toBeNull(); + }); + + it("records a server failure's id for the session's support info", async () => { + const { recentErrorIds, _resetRecentErrorIdsForTests } = + await import("@/lib/errors/error-id"); + _resetRecentErrorIdsForTests(); + fetchMock.mockResolvedValueOnce( + jsonResponse( + { data: null, error: "boom", meta: { requestId: "srv-fail-0001" } }, + 503, + ), + ); + fetchMock.mockResolvedValueOnce( + jsonResponse( + { data: null, error: "no", meta: { requestId: "client-0001" } }, + 404, + ), + ); + await apiGet("/api/x").catch(() => {}); + await apiGet("/api/x").catch(() => {}); + expect(recentErrorIds()).toEqual(["srv-fail-0001"]); + }); +}); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 5d6dfcc31..95c13f927 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -50,6 +50,7 @@ */ import { randomId } from "@/lib/random-id"; +import { recordErrorId } from "@/lib/errors/error-id"; import { readError } from "./read-error"; import { validateResponseContext, @@ -62,12 +63,28 @@ export class ApiError extends Error { readonly status: number; /** `meta` object of the error envelope, when the body carried one. */ readonly meta: Record | undefined; + /** + * The server's request id for this failure: `meta.requestId`, else the + * `x-request-id` response header, else null (a proxy answered, or an older + * server). Its first characters are the error id a person reads out. + */ + readonly requestId: string | null; - constructor(message: string, status: number, meta?: Record) { + constructor( + message: string, + status: number, + meta?: Record, + requestId?: string | null, + ) { super(message); this.name = "ApiError"; this.status = status; this.meta = meta; + const fromMeta = + typeof meta?.requestId === "string" && meta.requestId.length > 0 + ? meta.requestId + : null; + this.requestId = fromMeta ?? requestId ?? null; } } @@ -87,7 +104,16 @@ async function throwApiError(res: Response): Promise { } catch { /* non-JSON body — readError supplies the fallback message */ } - throw new ApiError(await readError(res), res.status, meta); + const error = new ApiError( + await readError(res), + res.status, + meta, + res.headers.get("x-request-id"), + ); + // A server-side failure is worth an id in the session's support info, + // whether or not the call site ends up showing it. + if (error.status >= 500 && error.requestId) recordErrorId(error.requestId); + throw error; } async function parseEnvelope( diff --git a/src/lib/errors/__tests__/error-id.test.ts b/src/lib/errors/__tests__/error-id.test.ts new file mode 100644 index 000000000..aef107c63 --- /dev/null +++ b/src/lib/errors/__tests__/error-id.test.ts @@ -0,0 +1,98 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// The node test environment has no `window`; a session storage stand-in is +// all this module touches. +const store = new Map(); +const sessionStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), +}; +vi.stubGlobal("window", { sessionStorage }); +afterAll(() => { + vi.unstubAllGlobals(); +}); + +import { ApiError } from "@/lib/api/api-fetch"; +import { + _resetRecentErrorIdsForTests, + errorIdOf, + recentErrorIds, + recordErrorId, + RECENT_ERROR_IDS_MAX, + shortErrorId, +} from "../error-id"; + +const ID = "3f9a2c1b-0000-4000-8000-000000000001"; + +beforeEach(() => { + _resetRecentErrorIdsForTests(); +}); + +describe("errorIdOf", () => { + it("shows a server failure's request id and keeps a 4xx quiet", () => { + expect(errorIdOf(new ApiError("boom", 500, { requestId: ID }))).toBe(ID); + expect(errorIdOf(new ApiError("no", 404, { requestId: ID }))).toBeNull(); + expect(errorIdOf(new ApiError("boom", 503, undefined, ID))).toBe(ID); + expect(errorIdOf(new ApiError("boom", 500))).toBeNull(); + }); + + it("shows a 4xx too once it reached an error boundary", () => { + expect( + errorIdOf(new ApiError("no", 404, { requestId: ID }), { + unexpected: true, + }), + ).toBe(ID); + }); + + it("prefers meta.requestId over the header", () => { + const error = new ApiError("boom", 500, { requestId: ID }, "header-0001"); + expect(error.requestId).toBe(ID); + }); + + it("reads the header of a raw Response", () => { + const res = new Response("x", { + status: 502, + headers: { "x-request-id": ID }, + }); + expect(errorIdOf(res)).toBe(ID); + expect( + errorIdOf( + new Response("x", { status: 409, headers: { "x-request-id": ID } }), + ), + ).toBeNull(); + }); + + it("falls back to a render digest and to nothing", () => { + const rendered = Object.assign(new Error("render"), { + digest: "123456789", + }); + expect(errorIdOf(rendered)).toBe("123456789"); + expect(errorIdOf(new TypeError("Failed to fetch"))).toBeNull(); + expect(errorIdOf(undefined)).toBeNull(); + expect(errorIdOf("text")).toBeNull(); + }); + + it("shows the first 8 characters", () => { + expect(shortErrorId(ID)).toBe("3f9a2c1b"); + }); +}); + +describe("recent error ids", () => { + it("keeps the newest five, newest first, without repeats", () => { + for (let i = 0; i < 7; i++) recordErrorId(`id-0000-${i}`); + recordErrorId("id-0000-5"); + const ids = recentErrorIds(); + expect(ids).toHaveLength(RECENT_ERROR_IDS_MAX); + expect(ids[0]).toBe("id-0000-5"); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).not.toContain("id-0000-0"); + }); + + it("survives a reload through session storage", () => { + recordErrorId(ID); + expect( + JSON.parse(sessionStorage.getItem("healthlog-recent-error-ids")!), + ).toEqual([ID]); + }); +}); diff --git a/src/lib/errors/__tests__/error-toast.test.tsx b/src/lib/errors/__tests__/error-toast.test.tsx new file mode 100644 index 000000000..0abc6b7cd --- /dev/null +++ b/src/lib/errors/__tests__/error-toast.test.tsx @@ -0,0 +1,48 @@ +import { isValidElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("sonner", () => ({ toast: { error: vi.fn(() => "toast-id") } })); + +import { toast } from "sonner"; +import { ApiError } from "@/lib/api/api-fetch"; +import { showErrorToast } from "../error-toast"; + +const ID = "3f9a2c1b-0000-4000-8000-000000000001"; + +beforeEach(() => { + vi.mocked(toast.error).mockClear(); +}); + +describe("showErrorToast", () => { + it("is exactly toast.error without a cause", () => { + showErrorToast("Could not save"); + expect(toast.error).toHaveBeenCalledWith("Could not save"); + showErrorToast("Could not save", { id: "x" }); + expect(toast.error).toHaveBeenLastCalledWith("Could not save", { id: "x" }); + }); + + it("is exactly toast.error for a cause with no id worth showing", () => { + showErrorToast("Not allowed", { + cause: new ApiError("no", 403, { requestId: ID }), + }); + expect(toast.error).toHaveBeenCalledWith("Not allowed"); + }); + + it("adds the error id to a server failure and keeps the toast up longer", () => { + showErrorToast("Could not save", { + cause: new ApiError("boom", 500, { requestId: ID }), + description: "Try again.", + }); + const [message, options] = vi.mocked(toast.error).mock.calls[0] as [ + string, + { description: unknown; duration: number }, + ]; + expect(message).toBe("Could not save"); + expect(options.duration).toBe(10_000); + expect(isValidElement(options.description)).toBe(true); + const props = (options.description as { props: Record }) + .props; + expect(props.id).toBe(ID); + expect(props.description).toBe("Try again."); + }); +}); diff --git a/src/lib/errors/error-id.ts b/src/lib/errors/error-id.ts new file mode 100644 index 000000000..9acc827f3 --- /dev/null +++ b/src/lib/errors/error-id.ts @@ -0,0 +1,129 @@ +/** + * Error ids: the short handle a person reads out when something failed. + * + * Every API error envelope carries the request id (`meta.requestId`, and the + * `x-request-id` header). The web shows its first 8 characters with a copy + * button on a server failure (5xx) or an unexpected one, and the operator + * finds the event with it under Admin, App logs. A render failure that never + * reached the API has no request id; for one the server rendered, Next's + * `digest` is the handle the server log prints beside the stack. + * + * The last few ids of this browser session are kept for the "Copy support + * info" text in Settings, About, so a person filing an issue later still has + * them. Session storage, not local: they belong to this visit. + * + * Every error display goes through this module (toasts through + * `showErrorToast`, boundaries through `ErrorIdLine`); the guard is + * `src/__tests__/error-display-helper-guard.test.ts`. + */ + +/** How many characters of the id are shown. */ +export const ERROR_ID_LENGTH = 8; + +/** How many ids the session keeps for the support info. */ +export const RECENT_ERROR_IDS_MAX = 5; + +const STORAGE_KEY = "healthlog-recent-error-ids"; + +let memory: string[] = []; + +/** The displayed form of an id. */ +export function shortErrorId(id: string): string { + return id.slice(0, ERROR_ID_LENGTH); +} + +function readStored(): string[] { + if (typeof window === "undefined") return memory; + try { + const raw = window.sessionStorage.getItem(STORAGE_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + if ( + Array.isArray(parsed) && + parsed.every((v) => typeof v === "string" && v.length <= 64) + ) { + return parsed.slice(0, RECENT_ERROR_IDS_MAX); + } + } catch { + /* storage blocked or malformed: memory is the fallback */ + } + return memory; +} + +/** Remember an id for this session's support info, newest first. */ +export function recordErrorId(id: string): void { + if (!id) return; + const next = [id, ...readStored().filter((v) => v !== id)].slice( + 0, + RECENT_ERROR_IDS_MAX, + ); + memory = next; + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* the in-memory copy still serves this page */ + } +} + +/** The ids this session saw, newest first, at most `RECENT_ERROR_IDS_MAX`. */ +export function recentErrorIds(): string[] { + return [...readStored()]; +} + +/** Test helper. */ +export function _resetRecentErrorIdsForTests(): void { + memory = []; + if (typeof window === "undefined") return; + try { + window.sessionStorage.removeItem(STORAGE_KEY); + } catch { + /* nothing to clear */ + } +} + +interface ApiLikeError { + status: number; + requestId: string | null; +} + +function isApiLike(error: unknown): error is ApiLikeError { + return ( + typeof error === "object" && + error !== null && + typeof (error as { status?: unknown }).status === "number" && + "requestId" in error + ); +} + +/** + * The id to show for a failure, or null when there is none worth showing. + * + * - An API failure shows its request id when the server failed (5xx). A + * 4xx is an answer the person can act on and its message says how, + * unless it reached an error boundary (`unexpected`), where nothing is + * an expected answer any more. + * - Any other error is unexpected; it shows Next's `digest` when the + * server rendered it, and nothing when there is no id to find it by. + */ +export function errorIdOf( + error: unknown, + options: { unexpected?: boolean } = {}, +): string | null { + // A raw `Response` from `apiFetchRaw`, for the call sites that branch on + // `res.ok` themselves: the id rides the header. + if (typeof Response !== "undefined" && error instanceof Response) { + const id = error.headers.get("x-request-id"); + const shown = options.unexpected === true || error.status >= 500; + return shown && id ? id : null; + } + if (isApiLike(error)) { + // Inside an error boundary every failure is unexpected, a 4xx included. + const shown = options.unexpected === true || error.status >= 500; + return shown && error.requestId ? error.requestId : null; + } + if (typeof error === "object" && error !== null) { + const digest = (error as { digest?: unknown }).digest; + if (typeof digest === "string" && digest.length > 0) return digest; + } + return null; +} diff --git a/src/lib/errors/error-toast.tsx b/src/lib/errors/error-toast.tsx new file mode 100644 index 000000000..41ab12c22 --- /dev/null +++ b/src/lib/errors/error-toast.tsx @@ -0,0 +1,64 @@ +"use client"; + +import type { ReactNode } from "react"; +import { toast, type ExternalToast } from "sonner"; + +import { ErrorIdLine } from "@/components/errors/error-id-line"; +import { errorIdOf } from "./error-id"; + +export interface ErrorToastOptions extends ExternalToast { + /** + * The failure behind the toast, usually the caught error. When it is a + * server failure (an `ApiError` with a 5xx status) the toast carries its + * error id with a copy button; otherwise the toast is the plain one. + */ + cause?: unknown; +} + +/** How long a toast that carries an error id stays, so it can be copied. */ +const ERROR_ID_TOAST_DURATION_MS = 10_000; + +/** + * The one error toast of the web app. + * + * Every error toast goes through here so a server failure always shows the id + * a person needs to report it (`src/__tests__/error-display-helper-guard.test.ts` + * holds call sites to it). Without a `cause`, or with one that has no id worth + * showing, the call is exactly `toast.error(message, options)`. + */ +export function showErrorToast( + message: Parameters[0], + options: ErrorToastOptions = {}, +): string | number { + const { cause, ...rest } = options; + const id = errorIdOf(cause); + if (!id) { + return Object.keys(rest).length > 0 + ? toast.error(message, rest) + : toast.error(message); + } + const description = + typeof rest.description === "function" + ? rest.description() + : rest.description; + return toast.error(message, { + ...rest, + duration: rest.duration ?? ERROR_ID_TOAST_DURATION_MS, + description: , + }); +} + +function ErrorToastDescription({ + description, + id, +}: { + description: ReactNode; + id: string; +}) { + return ( + + {description ? {description} : null} + + + ); +} diff --git a/src/lib/queries/use-documents-layout.ts b/src/lib/queries/use-documents-layout.ts index 5b1251e0e..579bf5ec3 100644 --- a/src/lib/queries/use-documents-layout.ts +++ b/src/lib/queries/use-documents-layout.ts @@ -6,6 +6,7 @@ import { type QueryClient, } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { @@ -58,9 +59,9 @@ export async function runSetDocumentsLayout(deps: { { version: 1, ...patch }, ); if (saved) queryClient.setQueryData(key, saved); - } catch { + } catch (failure) { queryClient.setQueryData(key, previous); - toast.error(t("documents.layout.saveFailed")); + showErrorToast(t("documents.layout.saveFailed"), { cause: failure }); } } diff --git a/src/lib/queries/use-medication-list-layout.ts b/src/lib/queries/use-medication-list-layout.ts index 6a2ef6815..8b36d2ca6 100644 --- a/src/lib/queries/use-medication-list-layout.ts +++ b/src/lib/queries/use-medication-list-layout.ts @@ -6,6 +6,7 @@ import { type QueryClient, } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { @@ -74,7 +75,7 @@ export async function runSetMedicationListView(deps: { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("medications.viewSaveFailed")); + showErrorToast(t("medications.viewSaveFailed"), { cause: err }); } } @@ -114,7 +115,7 @@ export async function runSaveMedicationListOrder(deps: { toast.message(t("common.conflictReloaded")); return false; } - toast.error(t("medications.reorderSaveFailed")); + showErrorToast(t("medications.reorderSaveFailed"), { cause: err }); return false; } } diff --git a/src/lib/support/__tests__/support-info.test.ts b/src/lib/support/__tests__/support-info.test.ts new file mode 100644 index 000000000..7c05005f6 --- /dev/null +++ b/src/lib/support/__tests__/support-info.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { buildSupportInfo, type SupportInfoInput } from "../support-info"; + +const ERROR_ID = "3f9a2c1b-0000-4000-8000-000000000001"; + +function input(over: Partial = {}): SupportInfoInput { + return { + version: { version: "1.43.0", buildSha: "abc1234" }, + me: { + id: "cmuserabcdefghijklmnopqr", + username: "distinctuser", + email: "distinct.user@example.org", + fullName: "Dora Distinct", + timezone: "Europe/Berlin", + modules: { mood: true, sleep: true, cycle: false }, + ai: { + capabilities: { + coach: { available: false, reason: "not_granted" }, + briefing: { available: true, reason: null }, + }, + provider: { configured: true, managedBy: "server" }, + usage: { level: "STANDARD" }, + }, + }, + locale: "de", + connections: [ + { integration: "polar", connected: false, health: null }, + { + integration: "withings", + connected: true, + health: { + state: "degraded", + reason: "provider_errors", + since: "2026-10-14T08:00:00.000Z", + action: null, + unavailable: [{ item: "spo2", cause: "plan" }], + lastSuccessAt: "2026-10-14T07:00:00.000Z", + lastDataAt: "2026-10-14T06:00:00.000Z", + diagnostics: { + ledgerState: "failing", + failingLegs: ["sleep"], + failingSince: "2026-10-14T08:00:00.000Z", + lastAttemptAt: "2026-10-15T08:00:00.000Z", + verdict: "stale", + recentCalls: [ + { + endpoint: "sleep", + status: "transient", + at: "2026-10-15T08:00:00.000Z", + errorCode: "503", + }, + ], + }, + }, + }, + ], + recentErrorIds: [ERROR_ID], + client: { + userAgent: + "Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36", + standalone: true, + }, + generatedAt: "2026-10-15T10:00:00.000Z", + ...over, + } as SupportInfoInput; +} + +describe("buildSupportInfo", () => { + it("carries build, account settings, AI state, health and error ids", () => { + const text = buildSupportInfo(input()); + expect(text).toContain("- Server version: 1.43.0"); + expect(text).toContain("- Build SHA: abc1234"); + expect(text).toContain("- Client: web (installed app)"); + expect(text).toContain("Chrome/128.0.0.0"); + expect(text).toContain("- Language: de"); + expect(text).toContain("- Time zone: Europe/Berlin"); + expect(text).toContain("- Enabled modules: mood, sleep"); + expect(text).toContain("- Usage level: STANDARD"); + expect(text).toContain("- coach: unavailable (not_granted)"); + expect(text).toContain( + "- withings: degraded / provider_errors since 2026-10-14T08:00:00.000Z", + ); + expect(text).toContain("Unavailable: spo2 (plan)"); + expect(text).toContain("2026-10-15T08:00:00.000Z sleep: transient (503)"); + expect(text).not.toContain("polar"); + // The person's own error ids survive the id-shaped scrub. + expect(text).toContain(`- ${ERROR_ID}`); + }); + + it("never carries the username, email, name or account id", () => { + const text = buildSupportInfo(input()); + for (const value of [ + "cmuserabcdefghijklmnopqr", + "distinctuser", + "distinct.user@example.org", + "Dora Distinct", + ]) { + expect(text.toLowerCase()).not.toContain(value.toLowerCase()); + } + }); + + it("redacts an account value that turns up in a field it should not", () => { + const text = buildSupportInfo( + input({ + me: { + ...input().me!, + timezone: "distinct.user@example.org", + }, + }), + ); + expect(text).not.toContain("distinct.user@example.org"); + }); + + it("says what it could not load rather than guessing", () => { + const text = buildSupportInfo( + input({ version: null, connections: null, recentErrorIds: [] }), + ); + expect(text).toContain("- Server version: unknown"); + expect(text).toContain("## Connections\n- Not loaded"); + expect(text).toContain("## Recent error ids (this session)\n- None"); + }); +}); diff --git a/src/lib/support/redact.ts b/src/lib/support/redact.ts index 78b7b7c63..7ab5359ed 100644 --- a/src/lib/support/redact.ts +++ b/src/lib/support/redact.ts @@ -121,7 +121,8 @@ export const SUPPORT_REDACTION_STEPS: readonly SupportRedactionStep[] = [ name: "ip-addresses", apply: (text) => text - .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]") + // Not after a slash or a dot: `Chrome/128.0.0.0` is a version. + .replace(/(?>; + ai?: { + capabilities?: Record< + string, + { available: boolean; reason: string | null } + >; + provider?: { configured: boolean; managedBy: string | null }; + usage?: { level?: string | null } | null; + } | null; + } | null; + locale: string; + connections: Array<{ + integration: string; + connected?: boolean; + health?: ConnectionHealth | null; + }> | null; + recentErrorIds: readonly string[]; + client: { userAgent: string | null; standalone: boolean }; + generatedAt: string; +} + +/** The support info as plain text, redacted. */ +export function buildSupportInfo(input: SupportInfoInput): string { + const lines: string[] = ["# HealthLog support info"]; + lines.push(`Generated: ${input.generatedAt}`); + + lines.push("", "## App"); + lines.push(`- Server version: ${input.version?.version ?? "unknown"}`); + lines.push(`- Build SHA: ${input.version?.buildSha ?? "unknown"}`); + lines.push( + `- Client: web${input.client.standalone ? " (installed app)" : ""}`, + ); + if (input.client.userAgent) { + lines.push(`- Browser: ${input.client.userAgent}`); + } + + lines.push("", "## Account"); + lines.push(`- Language: ${input.locale}`); + lines.push(`- Time zone: ${input.me?.timezone ?? "unknown"}`); + const modules = Object.entries(input.me?.modules ?? {}) + .filter(([, on]) => on === true) + .map(([key]) => key) + .sort(); + lines.push(`- Enabled modules: ${modules.join(", ") || "none"}`); + + const ai = input.me?.ai ?? null; + lines.push("", "## AI"); + if (!ai) { + lines.push("- State: not reported"); + } else { + if (ai.provider) { + lines.push( + `- Provider configured: ${ai.provider.configured ? "yes" : "no"}${ai.provider.managedBy ? ` (${ai.provider.managedBy})` : ""}`, + ); + } + if (ai.usage?.level) lines.push(`- Usage level: ${ai.usage.level}`); + for (const [key, state] of Object.entries(ai.capabilities ?? {}).sort( + ([a], [b]) => a.localeCompare(b), + )) { + lines.push( + `- ${key}: ${state.available ? "available" : `unavailable (${state.reason ?? "unknown"})`}`, + ); + } + } + + lines.push("", "## Connections"); + const connected = (input.connections ?? []).filter((c) => c.health); + if (input.connections === null) { + lines.push("- Not loaded"); + } else if (connected.length === 0) { + lines.push("- None connected"); + } + for (const c of connected) { + const h = c.health!; + lines.push( + `- ${c.integration}: ${h.state} / ${h.reason}${h.since ? ` since ${h.since}` : ""}${h.action ? `, action ${h.action}` : ""}`, + ); + if (h.lastSuccessAt) lines.push(` - Last success: ${h.lastSuccessAt}`); + if (h.lastDataAt) lines.push(` - Newest data: ${h.lastDataAt}`); + if (h.unavailable.length > 0) { + lines.push( + ` - Unavailable: ${h.unavailable.map((u) => `${u.item} (${u.cause})`).join(", ")}`, + ); + } + const d = h.diagnostics; + lines.push( + ` - Ledger: ${d.ledgerState ?? "none"}, verdict ${d.verdict}${d.failingLegs.length > 0 ? `, failing ${d.failingLegs.join(", ")}` : ""}${d.failingSince ? ` since ${d.failingSince}` : ""}`, + ); + for (const call of d.recentCalls.slice(0, 5)) { + lines.push( + ` - ${call.at} ${call.endpoint}: ${call.status}${call.errorCode ? ` (${call.errorCode})` : ""}`, + ); + } + } + + lines.push("", "## Recent error ids (this session)"); + if (input.recentErrorIds.length === 0) lines.push("- None"); + for (const id of input.recentErrorIds) lines.push(`- ${id}`); + + const knownValues = input.me + ? [ + input.me.id, + input.me.username, + input.me.email, + input.me.fullName, + ].filter((v): v is string => typeof v === "string" && v.length > 0) + : []; + // The error ids are this session's own request ids, which the person is + // meant to hand over; they are put back after the id-shaped scrub. + const placeholders = input.recentErrorIds.map((_, i) => `@@ERRORID${i}@@`); + let text = `${lines.join("\n")}\n`; + input.recentErrorIds.forEach((id, i) => { + text = text.split(`- ${id}\n`).join(`- ${placeholders[i]}\n`); + }); + text = redactSupportText(text, { knownValues }); + input.recentErrorIds.forEach((id, i) => { + text = text.split(placeholders[i]).join(id); + }); + return text; +} diff --git a/tests/integration/admin-support-report.test.ts b/tests/integration/admin-support-report.test.ts index 8a05ac7a5..94b0e431e 100644 --- a/tests/integration/admin-support-report.test.ts +++ b/tests/integration/admin-support-report.test.ts @@ -57,7 +57,8 @@ async function signIn(userId: string) { } function call() { - return GET(new NextRequest("http://localhost/api/admin/support-report")); + const route = GET as unknown as (request: NextRequest) => Promise; + return route(new NextRequest("http://localhost/api/admin/support-report")); } describe("GET /api/admin/support-report (integration)", () => { From 6979928d73ca2f30ddcfc85ed407f1bac415fcef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:36:34 +0200 Subject: [PATCH 020/127] chore(web): drop toast imports the error toast helper made unused --- src/components/cycle/use-cycle.ts | 1 - src/components/documents/document-link-picker.tsx | 1 - src/components/encounters/encounter-sheet.tsx | 1 - src/components/insights/coach-panel/coach-conversation.tsx | 1 - src/components/insights/coach-panel/copy-table-button.tsx | 1 - src/components/insights/coach-panel/memory-note.tsx | 1 - src/components/insights/coach-panel/reminder-suggestion-card.tsx | 1 - src/components/insights/coach-panel/sources-rail.tsx | 1 - src/components/insights/coach-panel/suggested-action-card.tsx | 1 - src/components/insights/coach-panel/use-coach.ts | 1 - src/components/insights/pattern-dismiss-action.tsx | 1 - src/components/labs/ocr-review-dialog.tsx | 1 - src/components/medications/detail/efficacy/efficacy-tab.tsx | 1 - src/components/medications/glp1-medication-card.tsx | 1 - src/components/medications/log-intake-dialog.tsx | 1 - src/components/medications/medication-card.tsx | 1 - src/components/medications/sections/courses-section.tsx | 1 - src/components/medications/side-effects-section.tsx | 1 - src/components/onboarding/confirm-screen.tsx | 1 - src/components/onboarding/done-screen.tsx | 1 - src/components/onboarding/first-result-screen.tsx | 1 - src/components/onboarding/question-screen.tsx | 1 - src/components/onboarding/units-screen.tsx | 1 - src/components/onboarding/welcome-screen.tsx | 1 - src/components/practitioners/practitioner-list.tsx | 1 - src/components/practitioners/practitioner-sheet.tsx | 1 - src/components/providers.tsx | 1 - src/components/records/ai-profile-inclusion-manager.tsx | 1 - src/components/records/emergency-profile-manager.tsx | 1 - src/components/records/health-profile-facts-manager.tsx | 1 - src/components/settings/access/managed-profile-handover.tsx | 1 - src/components/settings/account-section/setup-card.tsx | 1 - src/components/settings/ai/runtime-actions-row.tsx | 1 - src/components/vaccinations/booster-mint-prompt.tsx | 1 - src/components/vaccinations/custom-vaccine-sheet.tsx | 1 - src/components/vaccinations/vaccination-document-suggestion.tsx | 1 - src/components/vaccinations/vaccination-sheet.tsx | 1 - src/hooks/use-account-switch.ts | 1 - src/hooks/use-auth.ts | 1 - src/lib/queries/use-documents-layout.ts | 1 - 40 files changed, 40 deletions(-) diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 213a2b193..7b561dc7e 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -11,7 +11,6 @@ */ import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx index 59076672f..aadd54e5b 100644 --- a/src/components/documents/document-link-picker.tsx +++ b/src/components/documents/document-link-picker.tsx @@ -16,7 +16,6 @@ import { useDocumentDate } from "@/components/documents/use-document-date"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Input } from "@/components/ui/input"; diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 463442272..654c6ac8f 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -33,7 +33,6 @@ */ import { useState } from "react"; import { Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 0ac00a043..a0092b46b 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -8,7 +8,6 @@ import { useQueryClient, } from "@tanstack/react-query"; import { Paperclip, Sparkles } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { cn } from "@/lib/utils"; diff --git a/src/components/insights/coach-panel/copy-table-button.tsx b/src/components/insights/coach-panel/copy-table-button.tsx index da1995278..4320cfa5e 100644 --- a/src/components/insights/coach-panel/copy-table-button.tsx +++ b/src/components/insights/coach-panel/copy-table-button.tsx @@ -11,7 +11,6 @@ */ import { useCallback, useEffect, useRef, useState } from "react"; import { Check, Copy } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/insights/coach-panel/memory-note.tsx b/src/components/insights/coach-panel/memory-note.tsx index 24b04f4b0..e07cc3a78 100644 --- a/src/components/insights/coach-panel/memory-note.tsx +++ b/src/components/insights/coach-panel/memory-note.tsx @@ -16,7 +16,6 @@ * no line behind. */ import { useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/insights/coach-panel/reminder-suggestion-card.tsx b/src/components/insights/coach-panel/reminder-suggestion-card.tsx index 476c6277a..9344c5868 100644 --- a/src/components/insights/coach-panel/reminder-suggestion-card.tsx +++ b/src/components/insights/coach-panel/reminder-suggestion-card.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { BellPlus, Check, Loader2 } from "lucide-react"; diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index a55fbbd20..f53812d38 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Eye, Loader2, Target } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Label } from "@/components/ui/label"; diff --git a/src/components/insights/coach-panel/suggested-action-card.tsx b/src/components/insights/coach-panel/suggested-action-card.tsx index 5ba5cf205..9e528a079 100644 --- a/src/components/insights/coach-panel/suggested-action-card.tsx +++ b/src/components/insights/coach-panel/suggested-action-card.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Check, Loader2, Sparkles } from "lucide-react"; diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index c0c77d30a..cedd31787 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -15,7 +15,6 @@ import { useQueryClient, } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import type { diff --git a/src/components/insights/pattern-dismiss-action.tsx b/src/components/insights/pattern-dismiss-action.tsx index 9b00478ab..88075d434 100644 --- a/src/components/insights/pattern-dismiss-action.tsx +++ b/src/components/insights/pattern-dismiss-action.tsx @@ -3,7 +3,6 @@ import { QueryClientContext, type QueryClient } from "@tanstack/react-query"; import { EyeOff, RotateCcw, X } from "lucide-react"; import { useContext, useReducer, useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index df911daac..b88947659 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -13,7 +13,6 @@ import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-c import { useEffect, useId, useMemo, useRef, useState } from "react"; import { AlertCircle, Loader2, ScanLine, Upload } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index cde93df12..a6a7fbcdb 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -13,7 +13,6 @@ import { useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { Activity, TrendingUp } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 06e430cb8..cf4068cdf 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -6,7 +6,6 @@ import { } from "@/components/medications/course-fields"; import { useEffect, useReducer, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx index bcc7d306e..e985ab0b2 100644 --- a/src/components/medications/log-intake-dialog.tsx +++ b/src/components/medications/log-intake-dialog.tsx @@ -23,7 +23,6 @@ import { useActiveRecordName } from "@/hooks/use-record-capabilities"; import { useId, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 07e73bbf8..b7c5a2d55 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -6,7 +6,6 @@ import { } from "@/components/medications/course-fields"; import { useState, useEffect, useReducer } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useMedicationIntake } from "@/components/medications/use-medication-intake"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; diff --git a/src/components/medications/sections/courses-section.tsx b/src/components/medications/sections/courses-section.tsx index 98886eff9..b2edada7f 100644 --- a/src/components/medications/sections/courses-section.tsx +++ b/src/components/medications/sections/courses-section.tsx @@ -14,7 +14,6 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index a2554c2fe..ad04465a3 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -8,7 +8,6 @@ import { useMutation, useQueryClient, } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2, Plus, Trash2 } from "lucide-react"; diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index fc1012f26..8f0ba95ef 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -3,7 +3,6 @@ import { useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { BaselineForm } from "@/components/onboarding/baseline-form"; diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 58aab06f1..3cb5432ba 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -5,7 +5,6 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { CheckCircle2, diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index 86b3f2300..f5d828042 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx index 37577e5ae..244b2bc4d 100644 --- a/src/components/onboarding/question-screen.tsx +++ b/src/components/onboarding/question-screen.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Activity, diff --git a/src/components/onboarding/units-screen.tsx b/src/components/onboarding/units-screen.tsx index 1eb517c62..a1f10d5d0 100644 --- a/src/components/onboarding/units-screen.tsx +++ b/src/components/onboarding/units-screen.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { ChoiceChip } from "@/components/onboarding/choice-chip"; diff --git a/src/components/onboarding/welcome-screen.tsx b/src/components/onboarding/welcome-screen.tsx index 1e62c48ee..1a95aaf4e 100644 --- a/src/components/onboarding/welcome-screen.tsx +++ b/src/components/onboarding/welcome-screen.tsx @@ -4,7 +4,6 @@ import { useId, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMutation } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index b3232114f..9dfd0f535 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -13,7 +13,6 @@ */ import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/practitioners/practitioner-sheet.tsx b/src/components/practitioners/practitioner-sheet.tsx index 53e5394d5..f9654f691 100644 --- a/src/components/practitioners/practitioner-sheet.tsx +++ b/src/components/practitioners/practitioner-sheet.tsx @@ -13,7 +13,6 @@ * created back to whoever opened it. */ import { useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 5b10ecbde..947ac9cbf 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -17,7 +17,6 @@ import { } from "react"; import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/config"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Toaster } from "@/components/ui/sonner"; import { applyThemeColor } from "@/lib/pwa/theme-color"; diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx index 9ad51e11d..176e264bf 100644 --- a/src/components/records/ai-profile-inclusion-manager.tsx +++ b/src/components/records/ai-profile-inclusion-manager.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index c8223954d..8d7716467 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/records/health-profile-facts-manager.tsx b/src/components/records/health-profile-facts-manager.tsx index 4e05393a3..0d405ad34 100644 --- a/src/components/records/health-profile-facts-manager.tsx +++ b/src/components/records/health-profile-facts-manager.tsx @@ -3,7 +3,6 @@ import { useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/settings/access/managed-profile-handover.tsx b/src/components/settings/access/managed-profile-handover.tsx index d6fc0efc3..c5a79fcce 100644 --- a/src/components/settings/access/managed-profile-handover.tsx +++ b/src/components/settings/access/managed-profile-handover.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Check, Copy, Loader2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; diff --git a/src/components/settings/account-section/setup-card.tsx b/src/components/settings/account-section/setup-card.tsx index 50e4f6f41..768fff45f 100644 --- a/src/components/settings/account-section/setup-card.tsx +++ b/src/components/settings/account-section/setup-card.tsx @@ -1,7 +1,6 @@ "use client"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { ListChecks, Loader2 } from "lucide-react"; diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index c12d86e85..79a2b24f4 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -6,7 +6,6 @@ import { useState, type ReactNode } from "react"; import { Activity, Loader2, RefreshCw, Sparkles } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx index ff7488a06..49707c3ef 100644 --- a/src/components/vaccinations/booster-mint-prompt.tsx +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -17,7 +17,6 @@ * already closed by the time this appears. */ import { useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; diff --git a/src/components/vaccinations/custom-vaccine-sheet.tsx b/src/components/vaccinations/custom-vaccine-sheet.tsx index 7431a0add..2dc6d4e38 100644 --- a/src/components/vaccinations/custom-vaccine-sheet.tsx +++ b/src/components/vaccinations/custom-vaccine-sheet.tsx @@ -18,7 +18,6 @@ */ import { useState } from "react"; import { Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx index 319d19b0a..53a8d4590 100644 --- a/src/components/vaccinations/vaccination-document-suggestion.tsx +++ b/src/components/vaccinations/vaccination-document-suggestion.tsx @@ -18,7 +18,6 @@ */ import { useState } from "react"; import { Check, Syringe } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 40dbbd401..8597b1b84 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -20,7 +20,6 @@ */ import { useState } from "react"; import { Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/hooks/use-account-switch.ts b/src/hooks/use-account-switch.ts index 924073df2..c9e1d80ff 100644 --- a/src/hooks/use-account-switch.ts +++ b/src/hooks/use-account-switch.ts @@ -34,7 +34,6 @@ import { type QueryClient, } from "@tanstack/react-query"; import { useCallback } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index fb17c7f50..22a0ce25b 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -7,7 +7,6 @@ import { type QueryClient, } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch"; diff --git a/src/lib/queries/use-documents-layout.ts b/src/lib/queries/use-documents-layout.ts index 579bf5ec3..638ba87a9 100644 --- a/src/lib/queries/use-documents-layout.ts +++ b/src/lib/queries/use-documents-layout.ts @@ -5,7 +5,6 @@ import { useQueryClient, type QueryClient, } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; From 703689069f53e30e1a1f1af82070ee7ead91b1d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:37:18 +0200 Subject: [PATCH 021/127] docs(support): document error ids, the support info and the support report, and ask for them in bug reports --- .github/ISSUE_TEMPLATE/bug_report.yml | 22 +++++- docs/README.md | 5 +- docs/self-hosting/getting-started.md | 5 ++ docs/self-hosting/support.md | 105 ++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 docs/self-hosting/support.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 919b5a557..d3202921c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -15,7 +15,7 @@ body: id: version attributes: label: HealthLog version - description: Output of `/api/version` or the tag of the GHCR image you're running. + description: Settings, About shows it, as does `/api/version`, or the tag of the GHCR image you're running. placeholder: "1.4.19" validations: required: true @@ -55,11 +55,27 @@ body: 3. See error validations: required: true + - type: input + id: error-id + attributes: + label: Error ID + description: If the failure showed an error ID (eight characters, e.g. `3f9a2c1b`), paste it here. The operator finds the event with it under Admin, App logs. + placeholder: "3f9a2c1b" + - type: textarea + id: support-info + attributes: + label: Support info / support report + description: | + Everyone: Settings, About, "Copy support info". + Operators: Admin, System status, "Support report", then Copy. + Both are redacted (no names, emails, values or tokens); read them before you paste. + See https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/support.md + render: text - type: textarea id: logs attributes: - label: Relevant logs / screenshots - description: Paste any relevant log output (redact secrets) or attach screenshots. Wide-event logs from `/admin/app-logs` are useful. + label: Screenshots / other logs + description: Attach screenshots, or paste other log output if you have it. Redact secrets and personal data first. render: shell - type: input id: browser diff --git a/docs/README.md b/docs/README.md index dfe00df1a..34465adf4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,7 +33,10 @@ notes, and machine-readable specs that ship alongside the source: three access levels, the eight sections a grant can be narrowed to, managed profiles for somebody with no login and the guardians who look after them, what revocation does and does not undo, and which trust - properties of the instance are unchanged by any of it. + properties of the instance are unchanged by any of it. And + [`self-hosting/support.md`](./self-hosting/support.md) — reporting a + problem: error ids, the support info every account can copy, the + operator's redacted support report, and finding an error in the admin log. - [`using/`](./using/) — notes on using the app that sit close to the code. [`using/search.md`](./using/search.md) covers the command palette's record search: opening it, what it finds, how matching works, and its diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index fac39c9b6..aa6ed6ba4 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -218,6 +218,11 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build ## Troubleshooting +- **Where to look.** For a bug report, copy the support report (Admin, + System status) and the support info (Settings, About), and note the error + id the failure showed. Admin, App logs finds the event behind an error id. + What each contains, and what it never contains, is in + [`support.md`](./support.md). - **`app` container exits with a Prisma migration error.** The Postgres healthcheck races the migration on slow disks. Run `docker compose restart app` once the database has stabilised — diff --git a/docs/self-hosting/support.md b/docs/self-hosting/support.md new file mode 100644 index 000000000..d9e8af841 --- /dev/null +++ b/docs/self-hosting/support.md @@ -0,0 +1,105 @@ +# Reporting a problem + +HealthLog does not send anything home, and nobody but the operator can read +an instance's logs. So a bug report has to carry what is needed to find the +fault. Two texts do that, and both are redacted before you see them. + +| Who | Where | What it is | +| -------- | ---------------------------------------- | ------------------------------------------ | +| Everyone | Settings, About, **Copy support info** | Your app version, settings and connections | +| Operator | Admin, System status, **Support report** | The whole instance, accounts as numbers | + +Add the **error id** of the failure if you have one, and paste both texts into +the [bug report](https://github.com/MBombeck/HealthLog/issues/new?template=bug_report.yml). +Read them before you post. They are built to contain nothing personal, but +you are the one publishing them. + +## Error ids + +When the server fails (a 5xx answer), the message shows **Error ID** and +eight characters, for example `3f9a2c1b`, with a copy button that copies the +full id. A page that fails to render shows one too when the server has one +to give: the request id of the failed call, or the digest the server printed +beside the stack in its log. + +The id is the request id of the failing call. Every error answer of the API +carries it in `meta.requestId` and in the `x-request-id` header. An incoming +`x-request-id` is only adopted when it consists of letters, digits and hyphens +(8 to 64 characters); anything else is replaced with a fresh id. + +The operator finds the event under **Admin, App logs** by typing the id (or +its first 8 characters) into **Error ID**. A search by id reads the second log +buffer, which keeps the last 1,000 warnings and errors of the process. Ordinary +traffic cannot push them out, so an error stays findable for much longer than +in the main buffer of 500 events. Both buffers live in memory: a restart +empties them, and a split `web` / `worker` deployment has one per process. + +The same search works on the API: + +``` +GET /api/admin/app-logs?requestId=3f9a2c1b&ring=problems +``` + +A 4xx answer (a refused input, a missing permission) shows no id. Its message +says what to change. + +## Support info (everyone) + +**Settings, About, Copy support info** puts together, in your browser: + +- server version and build, and your browser; +- language, time zone and the modules you have switched on; +- the AI state: whether a provider is set up, your usage level, and which AI + features are available and why not; +- for each connected source (Withings, Oura, and so on), its health state, + reason, last success, newest data and the last recorded sync attempts; +- the last five error ids of this browser session. + +It never contains your username, email address, name, server address, +tokens, recorded values or record ids. Nothing is sent anywhere; the text +goes to your clipboard. + +## Support report (operator) + +**Admin, System status, Support report** creates a text with **Copy** and +**Download**. It is created when you press a button, not on page load, and +shown above the buttons so you can read it first. It contains: + +- **Build:** version, build SHA, build time, Node, process type, heap limit, + uptime. +- **Database:** PostgreSQL version, server time zone, size, last applied + migration, unfinished migrations. +- **Configuration**, as yes or no only: secure cookie flag, OIDC, OIDC only, + Loki, GlitchTip, off-host backup, APNs, Web Push, SMTP, GeoLite2, the number + of `NOTIFICATION_PRIVATE_ORIGINS` entries, operator AI key, central Codex, + the AI provider types accounts chose, demo mode. +- **Jobs:** whether the worker runs in this process and when it last + reported, the newest finished job, and every queue with failures in the + last 72 hours with an error class (`timeout`, `TypeError`, `P2002`, + `ECONNREFUSED`, an HTTP status), never the message. +- **Connections:** per source, how many accounts are in each health state and + reason, which accounts are not `ok`, and open watchdog incidents. +- **AI:** the usage level default and ceiling, accounts per usage level, and + the provider ledger per provider type (working, failing, credential + refused, paused). +- **Notifications:** per channel, delivered, failed and skipped in the last 7 + days, with the most frequent failure reasons. +- **Backups:** newest in-database copy, the last weekly run, off-host + freshness, and the last restore drill. +- **Recent warnings and errors:** the 20 most frequent action names in the + warning and error buffer of this process, with count, last seen and error + class. + +Accounts appear as `Account 1`, `Account 2` and so on, numbered by creation. +The report never contains an email address, a username, an account id, a +recorded value, a token, or the host name of another service. As a second +layer, the finished text is scrubbed for the instance's own usernames, +emails, names and ids, for tokens, URLs, email addresses, ids, IP addresses +and host names before it is shown. + +## Logs beyond the report + +`docker compose logs app` prints every wide event as one JSON line; +`LOG_LEVEL=debug` adds debug events. With `LOKI_ENDPOINT` set the same events +go to Loki, and GlitchTip (Admin, General) receives unexpected server errors. +Each line carries `request_id`, so an error id finds its line there too. From 0ec7b80a9e6a516daa0a3f63f9c9f6288a93957f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:42:05 +0200 Subject: [PATCH 022/127] style(api): format the request id envelope test --- src/lib/__tests__/api-handler-error-request-id.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/__tests__/api-handler-error-request-id.test.ts b/src/lib/__tests__/api-handler-error-request-id.test.ts index ca32c21b0..57688c550 100644 --- a/src/lib/__tests__/api-handler-error-request-id.test.ts +++ b/src/lib/__tests__/api-handler-error-request-id.test.ts @@ -30,9 +30,7 @@ function call( headers: Record = {}, ) { const route = apiHandler(async (_request: NextRequest) => handler()); - return route( - new NextRequest("http://localhost/api/probe", { headers }), - ); + return route(new NextRequest("http://localhost/api/probe", { headers })); } describe("meta.requestId on every error envelope", () => { From ae8b614dd086d37f065a306af0da8d213a0e4b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:06:05 +0200 Subject: [PATCH 023/127] feat(insights): choose the status regeneration gap by ingest source A sync from a watch or an integration now waits the instance's sync gap (AppSettings.aiSyncRegenGapHours, else AI_SYNC_REGEN_GAP_HOURS, else 6 h) before it may regenerate a status card again; a hand entry keeps the hour. Every caller of afterMeasurementMutation and invalidateStatusInsightsForTypes names its source, so a new ingest path cannot skip the choice. Refs #1217 --- .env.production.example | 7 + docker-compose.yml | 5 + docs/integrations/ai-providers.md | 8 + scripts/env-manifest.json | 4 + src/app/api/import/csv/route.ts | 5 +- src/app/api/import/route.ts | 7 +- src/app/api/measurements/[id]/route.ts | 20 ++- src/app/api/measurements/batch/route.ts | 7 +- .../bulk-delete/__tests__/route.test.ts | 10 +- src/app/api/measurements/bulk-delete/route.ts | 2 +- .../by-external-ids/__tests__/route.test.ts | 9 +- .../api/measurements/by-external-ids/route.ts | 2 +- .../restore/__tests__/route.test.ts | 10 +- src/app/api/measurements/restore/route.ts | 2 +- src/app/api/measurements/route.ts | 18 +- .../api/mental-health/assessments/route.ts | 16 +- src/lib/fitbit/sync-core.ts | 1 + src/lib/fitbit/sync.ts | 4 +- src/lib/google-health/sync-core.ts | 1 + src/lib/google-health/sync.ts | 4 +- .../invalidate-status-insights.test.ts | 164 ++++++++++++++---- src/lib/insights/__tests__/score-row.test.ts | 17 +- src/lib/insights/score-row.ts | 8 +- src/lib/insights/status-invalidation.ts | 133 ++++++++++---- src/lib/jobs/apple-health-import-worker.ts | 6 +- src/lib/jobs/health-connect-import-worker.ts | 6 +- src/lib/mcp/__tests__/writes.test.ts | 4 +- src/lib/mcp/writes.ts | 4 +- .../__tests__/create-from-telegram.test.ts | 2 +- src/lib/measurements/create-from-telegram.ts | 2 +- .../repair-apple-health-distance.ts | 2 +- src/lib/nightscout/sync.ts | 1 + src/lib/oura/sync.ts | 6 +- src/lib/polar/sync.ts | 1 + .../after-measurement-mutation.test.ts | 56 +++--- src/lib/rollups/after-measurement-mutation.ts | 22 ++- src/lib/whoop/__tests__/sync-body.test.ts | 2 +- .../__tests__/webhook-notification.test.ts | 2 +- src/lib/whoop/sync-body.ts | 5 +- src/lib/whoop/sync-core.ts | 14 +- src/lib/whoop/webhook-handler.ts | 2 +- src/lib/withings/sync-activity.ts | 1 + src/lib/withings/sync-ecg.ts | 1 + src/lib/withings/sync-sleep.ts | 1 + src/lib/withings/sync.ts | 1 + .../sharing-provider-origin.test.ts | 4 +- .../summaries-pre-window-buckets.test.ts | 8 +- 47 files changed, 452 insertions(+), 165 deletions(-) diff --git a/.env.production.example b/.env.production.example index c5f56207c..bc8df0b6f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -327,6 +327,13 @@ API_TOKEN_HMAC_KEY="" # docs/integrations/ai-providers.md. # DOCUMENT_AI_LIMIT_PER_HOUR="" +# Minimum hours between two regenerations of a status card after a sync +# (optional). A watch or an integration delivers readings all day, and each +# delivery would otherwise regenerate the cards it touches every hour. Hand +# entries keep a one-hour gap. Default 6, whole hours from 1 to 48. The value +# under Admin > AI wins over this one. See docs/integrations/ai-providers.md. +# AI_SYNC_REGEN_GAP_HOURS="" + # Hourly upload ceiling for each document-upload token (optional). Applies # only to uploads made with a token minted under Settings > API & Tokens > # Document tokens: a Paperless workflow or the import script. Every upload diff --git a/docker-compose.yml b/docker-compose.yml index eaacd11d4..c8a65b987 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -332,6 +332,11 @@ x-healthlog-env: &healthlog_env # (the `environment:` block is a whitelist). See # docs/integrations/ai-providers.md. DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_LIMIT_PER_HOUR:-}" + # Minimum hours between two status-card regenerations after a sync + # (optional; default 6, whole hours 1-48; hand entries keep one hour). The + # value under Admin > AI wins. Listed here so an operator value in .env + # reaches the container. See docs/integrations/ai-providers.md. + AI_SYNC_REGEN_GAP_HOURS: "${AI_SYNC_REGEN_GAP_HOURS:-}" # Hourly upload ceiling for each narrow document-upload token (a Paperless # workflow, the import script; optional; default 120, clamped 1-1000). # Uploads from the web app and the phone keep their own bucket. Listed here diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md index a097541e5..34a6dbda1 100644 --- a/docs/integrations/ai-providers.md +++ b/docs/integrations/ai-providers.md @@ -431,6 +431,14 @@ many documents against a generous provider budget; lower it on a tight one. The 429 names the actual wait (the window is an hour) and carries the reset instant in `X-RateLimit-Reset`. +`AI_SYNC_REGEN_GAP_HOURS` (default `6`, whole hours from 1 to 48) is +the minimum time between two regenerations of a status card after a sync. +A watch or an integration delivers readings all day, and each delivery +moves today's figures, so without a gap every touched card regenerated +about once an hour. A reading entered by hand keeps a one-hour gap, so it +is still narrated the same day. The value set under Admin > AI wins over +the environment. + `LABS_OCR_LIMIT_PER_HOUR` (default `6`, clamped to 1–1000) does the same for lab-report scans. The photo or PDF scan and the browser-OCR text scan share one bucket per user, and a scan that fails before the diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 08a6796ec..52b63fa38 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -142,6 +142,10 @@ "name": "DOCUMENT_AI_LIMIT_PER_HOUR", "purpose": "Per-user hourly ceiling for the document-AI actions (read/index, extract, suggest, summarise share one bucket). Default 6, clamped to 1-1000." }, + { + "name": "AI_SYNC_REGEN_GAP_HOURS", + "purpose": "Minimum hours between two status-card regenerations after a sync. Default 6, whole hours 1-48; hand entries keep one hour. The admin AI setting wins." + }, { "name": "DOCUMENT_UPLOAD_LIMIT_PER_HOUR", "purpose": "Hourly upload ceiling per document-upload token (Paperless workflow, import script). Default 120, clamped to 1-1000. Web and phone uploads keep their own bucket." diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index e6990a216..d39ccc3e4 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -443,7 +443,10 @@ export const POST = apiHandler(async (request: NextRequest) => { // CSV pays at most ~N (type, day) recomputes, not 10 000 per-row hooks // — and the status re-warm for the touched types. Best-effort: a // populator hiccup never fails the importer. - await afterMeasurementMutation(userId, touchedMeasurements, "import.csv"); + await afterMeasurementMutation(userId, touchedMeasurements, { + source: "manual", + label: "import.csv", + }); } await auditLog("import.csv.upload", { diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index e827cc674..b64ff48ce 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -483,7 +483,12 @@ export const POST = apiHandler(async (request: NextRequest) => { // (type, day) the import touched (collapsed, so a 10 000-row restore pays // at most ~N recomputes) and re-warm the touched types' status // assessments. Best-effort: a populator hiccup never fails the importer. - await afterMeasurementMutation(userId, touchedMeasurements, "import"); + await afterMeasurementMutation(userId, touchedMeasurements, { + // A file the person chose to import: a deliberate act, narrated like a + // hand entry rather than held to the sync gap. + source: "manual", + label: "import", + }); annotate({ meta: { diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index d1a29a746..5b0431332 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -248,10 +248,14 @@ export const PUT = apiHandler( // NEW identity ride the list: an edit can move the row across day // boundaries or re-type it, and the helper collapses identical // (type, day) pairs so the common in-place edit fires once. - await afterMeasurementMutation(user.id, [ - { type: measurement.type, measuredAt: measurement.measuredAt }, - { type: existing.type, measuredAt: existing.measuredAt }, - ]); + await afterMeasurementMutation( + user.id, + [ + { type: measurement.type, measuredAt: measurement.measuredAt }, + { type: existing.type, measuredAt: existing.measuredAt }, + ], + { source: "manual" }, + ); return apiSuccess(shapeMeasurementNotes(measurement)); }, @@ -312,9 +316,11 @@ export const DELETE = apiHandler( // v1.37.19 (C2-F1) — shared post-mutation tail (the recompute drops // the rollup row when the day's measurement count goes to zero). - await afterMeasurementMutation(user.id, [ - { type: existing.type, measuredAt: existing.measuredAt }, - ]); + await afterMeasurementMutation( + user.id, + [{ type: existing.type, measuredAt: existing.measuredAt }], + { source: "manual" }, + ); return apiSuccess({ deleted: true }); }, diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 299b0ff1b..43de22c21 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -1209,7 +1209,12 @@ async function postBatch(request: NextRequest): Promise { // v1.37.19 (C2-F1) — shared post-mutation tail for every distinct // (type, day) the batch touched. Updates participate as well as // inserts, so this deliberately retains the prepared-row scope. - await afterMeasurementMutation(user.id, writtenIdentities); + // A batch is the phone's sync, held to the sync gap, unless it wrote one + // of the person's own hand-entered readings, which is narrated like any + // other hand entry. + await afterMeasurementMutation(user.id, writtenIdentities, { + source: wroteManualRow ? "manual" : "sync", + }); // S4 — Apple-Health sleep is the third sleep transport. If any SLEEP_DURATION // row for last night just landed, kick the debounced morning refresh so the diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts index 32ff2f171..3a3438a12 100644 --- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -154,10 +154,12 @@ describe("POST /api/measurements/bulk-delete", () => { expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3); // And one status re-warm over the distinct types. expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1); - expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [ - "WEIGHT", - "PULSE", - ]); + // A delete is the person's own act: narrated like a hand entry. + expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith( + "user-1", + ["WEIGHT", "PULSE"], + { source: "manual" }, + ); }); it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => { diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index 7d341b65c..fd378ea9d 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -123,7 +123,7 @@ async function postBulkDelete(request: NextRequest): Promise { type: row.type as MeasurementType, measuredAt: row.measuredAt, })), - "measurements bulk-delete", + { source: "manual", label: "measurements bulk-delete" }, ); } diff --git a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts index 072608711..afb091c8e 100644 --- a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts +++ b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts @@ -118,9 +118,12 @@ describe("DELETE /api/measurements/by-external-ids", () => { // The deleted row's type is re-warmed; the zero-row case below is the // twin that must not reach it. - expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [ - "WEIGHT", - ]); + // The phone's HealthKit deletions arrive with its sync: the sync gap. + expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith( + "user-1", + ["WEIGHT"], + { source: "sync" }, + ); }); it("is a no-op for a foreign-origin externalId that collides on a non-app-minted row", async () => { diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 387db7ed4..3a1caf88d 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -169,7 +169,7 @@ async function deleteByExternalIds(request: NextRequest): Promise { // v1.37.19 (C2-F1) — shared post-mutation tail (identities collected // BEFORE the destructive statement, while readable). - await afterMeasurementMutation(user.id, affectedRows); + await afterMeasurementMutation(user.id, affectedRows, { source: "sync" }); } return apiSuccess({ deletedCount: result.count }); diff --git a/src/app/api/measurements/restore/__tests__/route.test.ts b/src/app/api/measurements/restore/__tests__/route.test.ts index dcd8a0f15..8ef10c8c3 100644 --- a/src/app/api/measurements/restore/__tests__/route.test.ts +++ b/src/app/api/measurements/restore/__tests__/route.test.ts @@ -149,10 +149,12 @@ describe("POST /api/measurements/restore", () => { expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3); // And one status re-warm over the distinct types. expect(invalidateStatusInsightsForTypes).toHaveBeenCalledTimes(1); - expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith("user-1", [ - "WEIGHT", - "PULSE", - ]); + // A restore is the person's own act: narrated like a hand entry. + expect(invalidateStatusInsightsForTypes).toHaveBeenCalledWith( + "user-1", + ["WEIGHT", "PULSE"], + { source: "manual" }, + ); }); it("is a no-op (restored: 0) when no id is owned + tombstoned — no rollup, no invalidate", async () => { diff --git a/src/app/api/measurements/restore/route.ts b/src/app/api/measurements/restore/route.ts index cbef0acdf..be8a4321a 100644 --- a/src/app/api/measurements/restore/route.ts +++ b/src/app/api/measurements/restore/route.ts @@ -122,7 +122,7 @@ async function postRestore(request: NextRequest): Promise { type: row.type as MeasurementType, measuredAt: row.measuredAt, })), - "measurements restore", + { source: "manual", label: "measurements restore" }, ); } diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 278ddb899..fa46fa1f7 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -963,6 +963,7 @@ async function postMeasurement(request: NextRequest) { await afterMeasurementMutation( user.id, results.map((r) => ({ type: r.type, measuredAt: r.measuredAt })), + { source: "manual" }, ); return apiSuccess(shapedResults, 201); @@ -1116,9 +1117,11 @@ async function postMeasurement(request: NextRequest) { // pg-boss; WEEK / MONTH / YEAR recomputes are enqueued under the // hood. Best-effort — a populator hiccup never fails the user's // write. - await afterMeasurementMutation(user.id, [ - { type: measurement.type, measuredAt: measurement.measuredAt }, - ]); + await afterMeasurementMutation( + user.id, + [{ type: measurement.type, measuredAt: measurement.measuredAt }], + { source: "manual" }, + ); return apiSuccess(shapeMeasurementNotes(measurement), 201); } @@ -1259,10 +1262,11 @@ async function postNamedSleepStage( ).catch(() => {}); void maybeEnqueueMorningRefresh(userId, [segment.end]).catch(() => {}); } - await afterMeasurementMutation(userId, [ - { type, measuredAt: segment.end }, - ...(verdict.dirtyIdentities ?? []), - ]); + await afterMeasurementMutation( + userId, + [{ type, measuredAt: segment.end }, ...(verdict.dirtyIdentities ?? [])], + { source: "manual" }, + ); } return namedSleepStageResponse(verdict.row.id, status, { diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 74c81f181..c1ad8cc72 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -374,12 +374,16 @@ async function postAssessment(request: NextRequest): Promise { // route it through the shared post-mutation tail so the (type, day) // rollup bucket and the cached status assessment converge without // waiting for a nightly discovery pass. - await afterMeasurementMutation(user.id, [ - { - type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType, - measuredAt: when, - }, - ]); + await afterMeasurementMutation( + user.id, + [ + { + type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType, + measuredAt: when, + }, + ], + { source: "manual" }, + ); // v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on // PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing diff --git a/src/lib/fitbit/sync-core.ts b/src/lib/fitbit/sync-core.ts index d25ac2487..c636b3071 100644 --- a/src/lib/fitbit/sync-core.ts +++ b/src/lib/fitbit/sync-core.ts @@ -900,6 +900,7 @@ export async function upsertFitbitMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `fitbit: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index 449efbff2..84f1f84e5 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -129,7 +129,9 @@ export async function syncUserFitbit( // the aggregator's `< to` upper bound covers it. const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000); await recomputeUserRollups(userId, { types, from, to }); - invalidateStatusInsightsForTypes(userId, types).catch((err) => { + invalidateStatusInsightsForTypes(userId, types, { + source: "sync", + }).catch((err) => { getEvent()?.addWarning( `fitbit: status-insight invalidate failed for ${userId}: ${err}`, ); diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index 9bf3bf29c..4940e43ea 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -1606,6 +1606,7 @@ export async function upsertGoogleHealthMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `google-health: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index 85ba41422..0caff675a 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -370,7 +370,9 @@ async function runGoogleHealthSyncCycle( const from = new Date(sorted[0]!); const to = new Date(sorted[sorted.length - 1]! + 24 * 60 * 60 * 1000); await recomputeUserRollups(userId, { types, from, to }); - invalidateStatusInsightsForTypes(userId, types).catch((err) => { + invalidateStatusInsightsForTypes(userId, types, { + source: "sync", + }).catch((err) => { getEvent()?.addWarning( `google-health: status-insight invalidate failed for ${userId}: ${err}`, ); diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index e47f34ee0..08a6a8a51 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -19,6 +19,7 @@ const noteFindMany = vi.fn(); const aiCapabilityForRecord = vi.fn(); const enqueueStatusGeneration = vi.fn(); const userFindUnique = vi.fn(); +const appSettingsFindUnique = vi.fn(); const measurementFindMany = vi.fn(); // v1.42 — type discovery goes through the loose-index-scan helper; the // rows it answers with are the types themselves. @@ -40,6 +41,9 @@ vi.mock("@/lib/db", () => ({ findMany: (...a: unknown[]) => noteFindMany(...a), }, user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, + appSettings: { + findUnique: (...a: unknown[]) => appSettingsFindUnique(...a), + }, measurement: { findMany: (...a: unknown[]) => measurementFindMany(...a), }, @@ -85,10 +89,19 @@ beforeEach(() => { }); enqueueStatusGeneration.mockResolvedValue(undefined); userFindUnique.mockResolvedValue({ locale: "de" }); + appSettingsFindUnique.mockResolvedValue({ aiSyncRegenGapHours: null }); measurementFindMany.mockResolvedValue([]); measurementGroupBy.mockResolvedValue([]); + delete process.env.AI_SYNC_REGEN_GAP_HOURS; }); +/** How far in the past the freshness probe's cutoff sat, in hours. */ +function probedGapHours(before: number): number { + const where = noteFindMany.mock.calls[0][0].where; + const cutoff = (where.generatedAt.gte as Date).getTime(); + return Math.round((before - cutoff) / (60 * 60 * 1000)); +} + /** Distinct scopes the invalidator enqueued a regenerate for. */ function enqueuedScopes(): string[] { return [ @@ -102,7 +115,9 @@ function enqueuedScopes(): string[] { describe("invalidateStatusInsightsForTypes", () => { it("maps WEIGHT to weight + bmi + general", async () => { - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); expect( enqueueStatusGeneration.mock.calls.every( @@ -112,7 +127,9 @@ describe("invalidateStatusInsightsForTypes", () => { }); it("never deletes the cached assessment rows (v1.16.8 — hash gate decides)", async () => { - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); // The rows stay for stale-while-revalidate AND for the content-hash // gate the worker's forced regeneration runs; deleting them here would // force a full LLM regeneration even for a re-synced unchanged batch. @@ -120,18 +137,20 @@ describe("invalidateStatusInsightsForTypes", () => { }); it("maps both blood-pressure components to blood-pressure + general", async () => { - await invalidateStatusInsightsForTypes("u1", [ - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - ]); + await invalidateStatusInsightsForTypes( + "u1", + ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + { source: "manual" }, + ); expect(enqueuedScopes()).toEqual(["blood-pressure", "general"]); }); it("maps PULSE and RESTING_HEART_RATE to pulse + general (+ the resting-HR generic scope)", async () => { - await invalidateStatusInsightsForTypes("u1", [ - "PULSE", - "RESTING_HEART_RATE", - ]); + await invalidateStatusInsightsForTypes( + "u1", + ["PULSE", "RESTING_HEART_RATE"], + { source: "manual" }, + ); // PULSE feeds the specialised pulse + general scopes; RESTING_HEART_RATE // feeds those too AND carries its own generic assessment card // (`metric:RESTING_HEART_RATE`), so all three are dirtied. @@ -146,16 +165,18 @@ describe("invalidateStatusInsightsForTypes", () => { // BLOOD_GLUCOSE has no specialised assessment, but v1.8.7.1 gives it a // generic `metric:BLOOD_GLUCOSE` card, so a fresh reading dirties the // general overview AND that generic scope. - await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); + await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); }); it("dedupes scopes across a mixed batch", async () => { - await invalidateStatusInsightsForTypes("u1", [ - "WEIGHT", - "PULSE", - "BLOOD_GLUCOSE", - ]); + await invalidateStatusInsightsForTypes( + "u1", + ["WEIGHT", "PULSE", "BLOOD_GLUCOSE"], + { source: "manual" }, + ); // weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ // general + metric:BLOOD_GLUCOSE (BLOOD_GLUCOSE) expect(enqueuedScopes()).toEqual([ @@ -168,14 +189,16 @@ describe("invalidateStatusInsightsForTypes", () => { }); it("is a no-op for an empty type set (no DB call)", async () => { - await invalidateStatusInsightsForTypes("u1", []); + await invalidateStatusInsightsForTypes("u1", [], { source: "manual" }); expect(noteFindMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); it("enqueues nothing while statusText is unavailable for the record", async () => { aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"], { + source: "manual", + }); expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText"); expect(noteFindMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); @@ -183,7 +206,9 @@ describe("invalidateStatusInsightsForTypes", () => { it("enqueues a debounced regenerate for every dirtied scope, the user's locale only", async () => { userFindUnique.mockResolvedValue({ locale: "de" }); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); // weight + bmi + general, each warmed once for the user's resolved locale. expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); expect(enqueueStatusGeneration).toHaveBeenCalledTimes(3); @@ -197,7 +222,9 @@ describe("invalidateStatusInsightsForTypes", () => { it("resolves the user's locale (en) for the regenerate, never the unused one", async () => { userFindUnique.mockResolvedValue({ locale: "en" }); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); const locales = new Set( enqueueStatusGeneration.mock.calls.map( (c) => (c[0] as { locale: string }).locale, @@ -210,7 +237,9 @@ describe("invalidateStatusInsightsForTypes", () => { // A steps sample touches only the general overview, never the weight / // pulse / bp scopes — the constant Apple-Health sync must not thrash // every card's cache. - await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:STEPS"]); }); @@ -219,14 +248,18 @@ describe("invalidateStatusInsightsForTypes", () => { // its own generic assessment card (`metric:BLOOD_GLUCOSE`), so the // freshly-synced metric's card refreshes in the background instead of // lagging until the nightly warm pass. - await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); + await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); }); it("maps a remapped MeasurementType to its registry metric id", async () => { // ACTIVE_ENERGY_BURNED is stored under that DB type but the generic // metric id (route param + cache scope) is ACTIVE_ENERGY. - await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"]); + await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["general", "metric:ACTIVE_ENERGY"]); }); @@ -234,7 +267,9 @@ describe("invalidateStatusInsightsForTypes", () => { // A weight sample feeds weight + bmi + general; WEIGHT is one of the // seven specialised metrics and has no generic registry entry, so no // `metric:` scope is enqueued. - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); expect(enqueuedScopes().some((s) => s.startsWith("metric:"))).toBe(false); }); @@ -249,7 +284,9 @@ describe("invalidateStatusInsightsForTypes", () => { // `general` was regenerated within the gap; a fresh WEIGHT sample // must not re-enqueue it. weight + bmi are still stale, so they refresh. noteFindMany.mockResolvedValue([freshRow("general")]); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "weight"]); }); @@ -261,7 +298,9 @@ describe("invalidateStatusInsightsForTypes", () => { // this is what lets same-day data be narrated same-day without // reopening the per-batch regeneration storm. noteFindMany.mockResolvedValue([]); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); }); @@ -271,7 +310,9 @@ describe("invalidateStatusInsightsForTypes", () => { freshRow("bmi"), freshRow("general"), ]); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -280,7 +321,9 @@ describe("invalidateStatusInsightsForTypes", () => { // scope that recently stalled must retry rather than be debounced into // staying cold. The probe asks the database for noted rows only; with // none returned, every dirtied scope refreshes. - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); const where = noteFindMany.mock.calls[0][0].where; expect(where.textEncrypted).toEqual({ not: null }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); @@ -289,7 +332,9 @@ describe("invalidateStatusInsightsForTypes", () => { it("scopes the freshness probe to the user's resolved locale and a 1 h gap cutoff", async () => { userFindUnique.mockResolvedValue({ locale: "en" }); const before = Date.now(); - await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); const where = noteFindMany.mock.calls[0][0].where; expect(where.userId).toBe("u1"); // Only the en notes for the dirtied scopes are probed. @@ -387,3 +432,64 @@ describe("enqueueStatusRefillForUser", () => { ]); }); }); + +// #1217 — the gap is chosen by where the batch came from. A watch drips +// syncs all day and every drip moves today's partial bucket, so the content +// hash cannot hold them back; a hand entry still has to be narrated the +// same day. +describe("regeneration gap by ingest source", () => { + it("holds a sync to six hours by default", async () => { + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(6); + }); + + it("keeps a hand entry at one hour", async () => { + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"], { + source: "manual", + }); + expect(probedGapHours(before)).toBe(1); + expect(appSettingsFindUnique).not.toHaveBeenCalled(); + }); + + it("takes the sync gap from AI_SYNC_REGEN_GAP_HOURS when the operator set no value", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "12"; + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(12); + }); + + it("prefers the operator's stored gap over the environment", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "12"; + appSettingsFindUnique.mockResolvedValue({ aiSyncRegenGapHours: 3 }); + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(3); + }); + + it("ignores an out-of-range environment value", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "0"; + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(6); + }); + + it("falls back to the environment when the settings row cannot be read", async () => { + process.env.AI_SYNC_REGEN_GAP_HOURS = "24"; + appSettingsFindUnique.mockRejectedValue(new Error("db down")); + const before = Date.now(); + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"], { + source: "sync", + }); + expect(probedGapHours(before)).toBe(24); + }); +}); diff --git a/src/lib/insights/__tests__/score-row.test.ts b/src/lib/insights/__tests__/score-row.test.ts index b21f2b2c9..68b211f84 100644 --- a/src/lib/insights/__tests__/score-row.test.ts +++ b/src/lib/insights/__tests__/score-row.test.ts @@ -131,12 +131,17 @@ describe("upsertScoreRow", () => { now: new Date("2026-06-02T03:00:00Z"), }); - expect(afterMeasurementMutation).toHaveBeenCalledWith("u1", [ - { - type: "STRAIN_SCORE", - measuredAt: new Date("2026-06-01T12:00:00.000Z"), - }, - ]); + expect(afterMeasurementMutation).toHaveBeenCalledWith( + "u1", + [ + { + type: "STRAIN_SCORE", + measuredAt: new Date("2026-06-01T12:00:00.000Z"), + }, + ], + // A computed score is not a hand entry: it waits the sync gap. + { source: "sync", label: "score" }, + ); }); }); diff --git a/src/lib/insights/score-row.ts b/src/lib/insights/score-row.ts index c82b07487..e49d57a31 100644 --- a/src/lib/insights/score-row.ts +++ b/src/lib/insights/score-row.ts @@ -105,9 +105,11 @@ export async function upsertScoreRow( // it through the shared post-mutation tail so the (type, day) rollup // bucket and the cached status assessment converge on the same tick that // wrote the score, instead of waiting for a discovery pass. - await afterMeasurementMutation(args.userId, [ - { type: args.type, measuredAt }, - ]); + await afterMeasurementMutation( + args.userId, + [{ type: args.type, measuredAt }], + { source: "sync", label: "score" }, + ); } /** The tally every score pass returns. */ diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts index b12d59023..aafcc7d47 100644 --- a/src/lib/insights/status-invalidation.ts +++ b/src/lib/insights/status-invalidation.ts @@ -34,39 +34,88 @@ import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; import type { MeasurementType } from "@/generated/prisma/client"; /** - * Minimum gap between a scope's last cached (re)generation and the next - * ingest-driven re-enqueue. A scope whose cached assessment was - * (re)generated within this window is left intact on a fresh measurement - * ingest — not re-enqueued. + * Where a batch of fresh measurements came from, which decides how soon the + * status cards it dirties may regenerate. * - * A constantly-syncing client (Apple Health drips batches every few - * minutes) used to delete + re-enqueue every dirtied scope on every batch, - * and because each delete dropped the cache row the per-(user,scope,locale) - * enqueue singleton (120 s) could not coalesce across batches that arrive - * minutes apart — so STEPS / general / a metric card regenerated several - * times an hour and the assessment felt "regenerated on every visit". This - * window coalesces the storm: a genuinely stale scope (no fresh assessment) - * still refreshes immediately, but a scope refreshed inside the window is - * skipped, so a fresh assessment survives the day's sync drip. - * - * v1.16.8 — first widened from 30 min to 6 h to bound provider spend, but - * a 6 h wall meant same-day data was not narrated same-day: the nightly - * warm at 04:30 restarted the window, so a notable 08:00 reading never - * re-enqueued its scopes and the card showed the pre-reading text until - * the next nightly tick. The window is now a ONE-HOUR minimum gap, and - * the budget role the 6 h wall carried moves to the worker's content-hash - * gate: every ingest that lands past the gap re-enqueues the dirtied - * scopes, the worker's forced run re-gathers the snapshot, and the gate - * (`refreshUnchangedStatusInsight`) turns an unchanged snapshot into a - * timestamp refresh with zero provider cost. So this clock only bounds - * the SQL-gather frequency — at most one worker run per scope per hour - * under a constant sync drip — while provider spend tracks actual data - * change, which is what the gate exists to meter. Comparing the hash - * inline at invalidation time was rejected: it would run the full - * per-scope data gather inside the ingest path, which is exactly the - * work the queue exists to keep off that path. + * - `manual`: a reading the person entered or changed by hand (the web + * form, the phone's entry sheet, a Telegram reply, an MCP write, a + * delete or restore). It should be narrated the same day, so its gap + * stays short. + * - `sync`: rows a device or an integration delivered on its own schedule + * (Apple Health, Health Connect, Withings, Oura, Fitbit, Polar, WHOOP, + * Nightscout, Google Health, a file import, a computed score). A watch + * drips these all day, and every drip moves today's partial bucket, so + * the content-hash gate cannot catch them; only a longer gap does. */ -const INGEST_INVALIDATE_MIN_GAP_MS = 60 * 60 * 1000; +export type StatusIngestSource = "manual" | "sync"; + +/** + * Minimum gap between a scope's last cached (re)generation and the next + * ingest-driven re-enqueue, for a hand entry. A scope whose cached + * assessment was (re)generated within the gap is left intact and not + * re-enqueued. + * + * History: v1.9.0 introduced the window (30 min) because a constantly + * syncing client re-enqueued every dirtied scope on every batch. v1.16.8 + * widened it to 6 h for every ingest, which was rolled back to 1 h because a + * lone 08:00 reading after the 04:30 nightly warm was not narrated the same + * day. The gap is now chosen by source: a hand entry keeps the hour, a sync + * gets {@link DEFAULT_SYNC_REGEN_GAP_HOURS} (or the operator's value). + */ +const MANUAL_INGEST_MIN_GAP_MS = 60 * 60 * 1000; + +/** + * The sync gap when neither the operator's setting + * (`AppSettings.aiSyncRegenGapHours`) nor `AI_SYNC_REGEN_GAP_HOURS` names one. + * Six hours turns a watch's ~16 regenerations per scope and day into ~3. + */ +export const DEFAULT_SYNC_REGEN_GAP_HOURS = 6; + +/** The operator's sync gap bounds, shared with the admin setting. */ +export const SYNC_REGEN_GAP_HOURS_MIN = 1; +export const SYNC_REGEN_GAP_HOURS_MAX = 48; + +/** A gap in hours, or null when the value is not a usable whole hour count. */ +function parseGapHours(value: unknown): number | null { + const n = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) + : NaN; + if (!Number.isInteger(n)) return null; + if (n < SYNC_REGEN_GAP_HOURS_MIN || n > SYNC_REGEN_GAP_HOURS_MAX) return null; + return n; +} + +/** + * The instance's sync gap in hours: the operator's admin setting, else + * `AI_SYNC_REGEN_GAP_HOURS`, else six. An unreadable settings row falls back + * to the environment, never to a shorter gap. + */ +export async function loadSyncRegenGapHours(): Promise { + let stored: number | null = null; + try { + const row = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { aiSyncRegenGapHours: true }, + }); + stored = parseGapHours(row?.aiSyncRegenGapHours); + } catch { + stored = null; + } + return ( + stored ?? + parseGapHours(process.env.AI_SYNC_REGEN_GAP_HOURS) ?? + DEFAULT_SYNC_REGEN_GAP_HOURS + ); +} + +/** The regeneration gap for one ingest, in milliseconds. */ +async function ingestRegenGapMs(source: StatusIngestSource): Promise { + if (source === "manual") return MANUAL_INGEST_MIN_GAP_MS; + return (await loadSyncRegenGapHours()) * 60 * 60 * 1000; +} /** * The seven per-metric assessment scopes. Each generator persists its @@ -130,6 +179,7 @@ function statusScopesForMeasurementType( export async function invalidateStatusInsightsForTypes( userId: string, types: Iterable, + options: { source: StatusIngestSource }, ): Promise { // One ordered set of every scope the batch dirties. The seven specialised // scopes are bare slugs (`weight`, `general`, …); the generic HealthKit @@ -184,14 +234,20 @@ export async function invalidateStatusInsightsForTypes( // only the redundant churn is removed. Past the gap the enqueue always // goes through — the worker's content-hash gate decides whether the // batch actually changed anything (see the constant's doc). - const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes); + const gapMs = await ingestRegenGapMs(options.source); + const freshScopes = await findRecentlyWarmedScopes( + userId, + locale, + scopes, + gapMs, + ); const staleScopes = Array.from(scopes).filter( (scope) => !freshScopes.has(scope), ); if (staleScopes.length === 0) { annotate({ action: { name: "insights.status.invalidate.debounced" }, - meta: { skipped: scopes.size, refreshed: 0 }, + meta: { skipped: scopes.size, refreshed: 0, source: options.source }, }); return; } @@ -210,7 +266,11 @@ export async function invalidateStatusInsightsForTypes( annotate({ action: { name: "insights.status.invalidate.debounced" }, - meta: { skipped: freshScopes.size, refreshed: staleScopes.length }, + meta: { + skipped: freshScopes.size, + refreshed: staleScopes.length, + source: options.source, + }, }); } @@ -268,7 +328,7 @@ export async function enqueueStatusRefillForUser( /** * v1.9.0 — return the subset of `scopes` whose cached assessment for - * `locale` was generated within `INGEST_INVALIDATE_MIN_GAP_MS` and is a + * `locale` was generated within `gapMs` and is a * real (non-stub) assessment. Those scopes are skipped by the ingest * invalidator so a fresh assessment survives the sync drip. * @@ -281,8 +341,9 @@ async function findRecentlyWarmedScopes( userId: string, locale: SupportedLocale, scopes: ReadonlySet, + gapMs: number, ): Promise> { - const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS); + const cutoff = new Date(Date.now() - gapMs); // Match exactly the cache actions for the candidate scopes in this locale. const candidateActions = Array.from(scopes, (scope) => statusCacheAction(scope, locale), diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index ff5c64e2b..f90e09300 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -605,7 +605,11 @@ export async function handleAppleHealthImport( if (importedTypes.length > 0) { // Registered so the test suite can await the detached handle. trackBackgroundTask( - invalidateStatusInsightsForTypes(userId, importedTypes).catch((err) => { + invalidateStatusInsightsForTypes(userId, importedTypes, { + // An export file the person chose to import, narrated like any + // other import rather than held to the sync gap. + source: "manual", + }).catch((err) => { console.warn( `[apple-health-import] status-insight invalidate failed for user ${userId}`, err, diff --git a/src/lib/jobs/health-connect-import-worker.ts b/src/lib/jobs/health-connect-import-worker.ts index 58ba0c6db..b4928e022 100644 --- a/src/lib/jobs/health-connect-import-worker.ts +++ b/src/lib/jobs/health-connect-import-worker.ts @@ -228,7 +228,11 @@ export async function runHealthConnectImport( .map(([type]) => type as MeasurementType); if (touched.length > 0) { trackBackgroundTask( - invalidateStatusInsightsForTypes(userId, touched).catch((err) => { + invalidateStatusInsightsForTypes(userId, touched, { + // An export file the person chose to import, like the Apple Health + // import: narrated like a hand entry, not held to the sync gap. + source: "manual", + }).catch((err) => { console.warn( `[health-connect-import] status-insight invalidate failed for user ${userId}`, err, diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts index a762f1f4c..9d5445c9f 100644 --- a/src/lib/mcp/__tests__/writes.test.ts +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -478,7 +478,7 @@ describe("MCP writes — post-write tail", () => { expect(afterMeasurementMutation).toHaveBeenCalledWith( "u-1", [expect.objectContaining({ type: "WEIGHT" })], - "mcp", + { source: "manual", label: "mcp" }, ); }); @@ -495,7 +495,7 @@ describe("MCP writes — post-write tail", () => { expect.objectContaining({ type: "BLOOD_PRESSURE_SYS" }), expect.objectContaining({ type: "BLOOD_PRESSURE_DIA" }), ], - "mcp", + { source: "manual", label: "mcp" }, ); }); }); diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index 49e3365bd..196f0b291 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -355,7 +355,7 @@ export async function logMcpMeasurement(input: { await afterMeasurementMutation( input.userId, [{ type: input.type, measuredAt }], - "mcp", + { source: "manual", label: "mcp" }, ); await auditLog("mcp.write.measurement", { @@ -529,7 +529,7 @@ export async function logMcpBloodPressure(input: { { type: "BLOOD_PRESSURE_SYS", measuredAt }, { type: "BLOOD_PRESSURE_DIA", measuredAt }, ], - "mcp", + { source: "manual", label: "mcp" }, ); await auditLog("mcp.write.blood_pressure", { diff --git a/src/lib/measurements/__tests__/create-from-telegram.test.ts b/src/lib/measurements/__tests__/create-from-telegram.test.ts index 54fcbd717..b9a98ff09 100644 --- a/src/lib/measurements/__tests__/create-from-telegram.test.ts +++ b/src/lib/measurements/__tests__/create-from-telegram.test.ts @@ -96,7 +96,7 @@ describe("logTelegramMeasurement", () => { expect(afterMeasurementMutation).toHaveBeenCalledWith( "user-1", [expect.objectContaining({ type: "WEIGHT" })], - "telegram-measurement", + { source: "manual", label: "telegram-measurement" }, ); }); diff --git a/src/lib/measurements/create-from-telegram.ts b/src/lib/measurements/create-from-telegram.ts index 9bbe84543..d2f08a71c 100644 --- a/src/lib/measurements/create-from-telegram.ts +++ b/src/lib/measurements/create-from-telegram.ts @@ -162,7 +162,7 @@ export async function logTelegramMeasurement(input: { await afterMeasurementMutation( input.userId, [{ type: input.type, measuredAt }], - "telegram-measurement", + { source: "manual", label: "telegram-measurement" }, ); return { status: "ok" }; diff --git a/src/lib/measurements/repair-apple-health-distance.ts b/src/lib/measurements/repair-apple-health-distance.ts index 9b0178cbf..7a9881da9 100644 --- a/src/lib/measurements/repair-apple-health-distance.ts +++ b/src/lib/measurements/repair-apple-health-distance.ts @@ -364,7 +364,7 @@ export async function applyAppleHealthDistanceRepair( type: REPAIRED_TYPE, measuredAt: row.measuredAt, })), - "repair-apple-health-distance", + { source: "sync", label: "repair-apple-health-distance" }, ); await client.auditLog.update({ where: { id: auditId }, diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index 3af82a725..c1c0edc92 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -333,6 +333,7 @@ export async function upsertNightscoutEntries( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `nightscout: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 51a785ef3..855646c89 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -573,7 +573,10 @@ export async function retireOuraSleepRecords( where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null }, data: { deletedAt: new Date() }, }); - await afterMeasurementMutation(userId, rows, "oura"); + await afterMeasurementMutation(userId, rows, { + source: "sync", + label: "oura", + }); invalidateUserMeasurements(userId); annotate({ meta: { oura_sleep_retired_rows: res.count } }); return res.count; @@ -808,6 +811,7 @@ export async function upsertOuraMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `oura: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index 23db25dbf..6cd575dd0 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -404,6 +404,7 @@ export async function upsertPolarMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `polar: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts index 6a983e08e..681356457 100644 --- a/src/lib/rollups/__tests__/after-measurement-mutation.test.ts +++ b/src/lib/rollups/__tests__/after-measurement-mutation.test.ts @@ -47,20 +47,24 @@ const day = (iso: string) => new Date(iso); describe("afterMeasurementMutation", () => { it("recomputes one bucket per distinct (type, day) and invalidates each type once", async () => { - await afterMeasurementMutation("user-1", [ - { - type: "WEIGHT" as MeasurementType, - measuredAt: day("2026-05-10T08:00:00Z"), - }, - { - type: "WEIGHT" as MeasurementType, - measuredAt: day("2026-05-10T21:00:00Z"), - }, - { - type: "PULSE" as MeasurementType, - measuredAt: day("2026-05-10T09:00:00Z"), - }, - ]); + await afterMeasurementMutation( + "user-1", + [ + { + type: "WEIGHT" as MeasurementType, + measuredAt: day("2026-05-10T08:00:00Z"), + }, + { + type: "WEIGHT" as MeasurementType, + measuredAt: day("2026-05-10T21:00:00Z"), + }, + { + type: "PULSE" as MeasurementType, + measuredAt: day("2026-05-10T09:00:00Z"), + }, + ], + { source: "sync" }, + ); // Two rows on the same (WEIGHT, day) collapse into one recompute. expect(mocks.recompute).toHaveBeenCalledTimes(2); @@ -76,13 +80,15 @@ describe("afterMeasurementMutation", () => { ); expect(mocks.invalidate).toHaveBeenCalledTimes(1); - const [userId, types] = mocks.invalidate.mock.calls[0]; + const [userId, types, options] = mocks.invalidate.mock.calls[0]; expect(userId).toBe("user-1"); expect([...types].sort()).toEqual(["PULSE", "WEIGHT"]); + // The caller's source reaches the invalidator, which picks the gap by it. + expect(options).toEqual({ source: "sync" }); }); it("is a no-op for an empty row set", async () => { - await afterMeasurementMutation("user-1", []); + await afterMeasurementMutation("user-1", [], { source: "manual" }); expect(mocks.recompute).not.toHaveBeenCalled(); expect(mocks.invalidate).not.toHaveBeenCalled(); }); @@ -99,7 +105,7 @@ describe("afterMeasurementMutation", () => { measuredAt: day("2026-05-10T08:00:00Z"), }, ], - "test.surface", + { source: "manual", label: "test.surface" }, ); } finally { warn.mockRestore(); @@ -113,12 +119,16 @@ describe("afterMeasurementMutation", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { await expect( - afterMeasurementMutation("user-1", [ - { - type: "WEIGHT" as MeasurementType, - measuredAt: day("2026-05-10T08:00:00Z"), - }, - ]), + afterMeasurementMutation( + "user-1", + [ + { + type: "WEIGHT" as MeasurementType, + measuredAt: day("2026-05-10T08:00:00Z"), + }, + ], + { source: "manual" }, + ), ).resolves.toBeUndefined(); // Let the fire-and-forget rejection settle inside the helper's catch. await new Promise((resolve) => setImmediate(resolve)); diff --git a/src/lib/rollups/after-measurement-mutation.ts b/src/lib/rollups/after-measurement-mutation.ts index d4ff65816..f12c17160 100644 --- a/src/lib/rollups/after-measurement-mutation.ts +++ b/src/lib/rollups/after-measurement-mutation.ts @@ -22,16 +22,28 @@ import { collapseToTypeDayKeys, recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; +import { + invalidateStatusInsightsForTypes, + type StatusIngestSource, +} from "@/lib/insights/status-invalidation"; import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import type { MeasurementType } from "@/generated/prisma/client"; export async function afterMeasurementMutation( userId: string, rows: Array<{ type: MeasurementType; measuredAt: Date }>, - /** Log prefix identifying the calling surface in the warn lines. */ - label = "measurements", + options: { + /** + * Where the rows came from: a hand entry (`manual`) or a device or + * integration (`sync`). Decides how soon the status cards they dirty may + * regenerate (`StatusIngestSource`). + */ + source: StatusIngestSource; + /** Log prefix identifying the calling surface in the warn lines. */ + label?: string; + }, ): Promise { + const label = options.label ?? "measurements"; if (rows.length === 0) return; // Collapse to the distinct `(type, day)` set BEFORE recomputing so a @@ -53,7 +65,9 @@ export async function afterMeasurementMutation( // write on a detached handle, and one that lands while a vitest worker is // closing its RPC channel fails the whole run. No-op outside test. trackBackgroundTask( - invalidateStatusInsightsForTypes(userId, types).catch((err) => { + invalidateStatusInsightsForTypes(userId, types, { + source: options.source, + }).catch((err) => { console.warn(`[${label}] status-insight invalidate failed`, err); }), ); diff --git a/src/lib/whoop/__tests__/sync-body.test.ts b/src/lib/whoop/__tests__/sync-body.test.ts index 2002fd38b..4237ff0fc 100644 --- a/src/lib/whoop/__tests__/sync-body.test.ts +++ b/src/lib/whoop/__tests__/sync-body.test.ts @@ -149,7 +149,7 @@ describe("syncUserBody — profile weight is not ingested (v1.16.11)", () => { expect(afterMeasurementMutation).toHaveBeenCalledWith( "user1", [legacyRow], - "whoop-body", + { source: "sync", label: "whoop-body" }, ); expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({ where: { diff --git a/src/lib/whoop/__tests__/webhook-notification.test.ts b/src/lib/whoop/__tests__/webhook-notification.test.ts index 2e9db8f19..f118932bb 100644 --- a/src/lib/whoop/__tests__/webhook-notification.test.ts +++ b/src/lib/whoop/__tests__/webhook-notification.test.ts @@ -144,7 +144,7 @@ describe("processWhoopNotification — fetch-by-id enqueue", () => { expect(afterMutation).toHaveBeenCalledWith( "user-1", [{ type: "SLEEP_DURATION", measuredAt }], - "whoop.webhook", + { source: "sync", label: "whoop.webhook" }, ); }); }); diff --git a/src/lib/whoop/sync-body.ts b/src/lib/whoop/sync-body.ts index 2600b2687..5a3ca040a 100644 --- a/src/lib/whoop/sync-body.ts +++ b/src/lib/whoop/sync-body.ts @@ -78,7 +78,10 @@ export async function syncUserBody( }); if (legacy.length > 0) { await prisma.measurement.deleteMany({ where: legacyWhere }); - await afterMeasurementMutation(userId, legacy, "whoop-body"); + await afterMeasurementMutation(userId, legacy, { + source: "sync", + label: "whoop-body", + }); } } catch (err) { getEvent()?.addWarning( diff --git a/src/lib/whoop/sync-core.ts b/src/lib/whoop/sync-core.ts index 464ab6718..bb6da4b42 100644 --- a/src/lib/whoop/sync-core.ts +++ b/src/lib/whoop/sync-core.ts @@ -421,13 +421,13 @@ export async function upsertWhoopMeasurements( const invalidateTouchedTypes = (): void => { if (touchedTypes.size === 0) return; - void invalidateStatusInsightsForTypes(userId, [...touchedTypes]).catch( - (err) => { - getEvent()?.addWarning( - `whoop: status-insight invalidate failed for ${userId}: ${err}`, - ); - }, - ); + void invalidateStatusInsightsForTypes(userId, [...touchedTypes], { + source: "sync", + }).catch((err) => { + getEvent()?.addWarning( + `whoop: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); }; for ( diff --git a/src/lib/whoop/webhook-handler.ts b/src/lib/whoop/webhook-handler.ts index 55790ef77..6fe2cb8a5 100644 --- a/src/lib/whoop/webhook-handler.ts +++ b/src/lib/whoop/webhook-handler.ts @@ -197,7 +197,7 @@ export async function processWhoopNotification( type: row.type as MeasurementType, measuredAt: row.measuredAt, })), - "whoop.webhook", + { source: "sync", label: "whoop.webhook" }, ); if (resource === "workout") { // The Workout model carries no soft-delete column, so a deleted diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index 49a92cc29..1f2284f06 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -550,6 +550,7 @@ export async function syncUserActivity( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings activity: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts index 0e6812e46..4721853fe 100644 --- a/src/lib/withings/sync-ecg.ts +++ b/src/lib/withings/sync-ecg.ts @@ -528,6 +528,7 @@ export async function syncUserEcg( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings ecg: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 1f8719950..252332887 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -638,6 +638,7 @@ export async function syncUserSleep( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings sleep: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 10bdc24b3..af4525e23 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -457,6 +457,7 @@ export async function syncUserMeasurements( invalidateStatusInsightsForTypes( userId, keys.map((k) => k.type), + { source: "sync" }, ).catch((err) => { getEvent()?.addWarning( `withings: status-insight invalidate failed for ${userId}: ${err}`, diff --git a/tests/integration/sharing-provider-origin.test.ts b/tests/integration/sharing-provider-origin.test.ts index e764a2002..d433804e9 100644 --- a/tests/integration/sharing-provider-origin.test.ts +++ b/tests/integration/sharing-provider-origin.test.ts @@ -126,7 +126,9 @@ async function grantManage(input: { const enqueueFromSharedMutation: (request: NextRequest) => Promise = apiHandler(async () => { const { user } = await requireRecordAuth("manage", "medications"); - await invalidateStatusInsightsForTypes(user.id, ["WEIGHT"]); + await invalidateStatusInsightsForTypes(user.id, ["WEIGHT"], { + source: "manual", + }); return NextResponse.json({ data: { queued: true }, error: null }); }); diff --git a/tests/integration/summaries-pre-window-buckets.test.ts b/tests/integration/summaries-pre-window-buckets.test.ts index e8c95dea0..7c02ac22b 100644 --- a/tests/integration/summaries-pre-window-buckets.test.ts +++ b/tests/integration/summaries-pre-window-buckets.test.ts @@ -116,9 +116,11 @@ describe("all-time summaries with DAY buckets older than the fold window", () => await recomputeUserRollups(userId); // A backdated reading, as a manual entry or a sync of old samples writes // it: the hook folds that one day, window or not. - await afterMeasurementMutation(userId, [ - { type: "WEIGHT", measuredAt: old.at }, - ]); + await afterMeasurementMutation( + userId, + [{ type: "WEIGHT", measuredAt: old.at }], + { source: "manual" }, + ); await expectTrueFigures(userId, [64, 80]); }); From c2245d1d7949173ebe520ffa4c119d6128b276e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:09:26 +0200 Subject: [PATCH 024/127] perf(insights): send snapshots as compact JSON and size the cap against it The status cards, the briefing, the derived assessments and the Coach snapshot drop the two-space indentation from the JSON they hand the model. The content is unchanged (the hash gate hashes the object, so stored hashes stay valid). MAX_SNAPSHOT_CHARS is now measured on the compact string: 14 000 for the status cards and the Coach, which every snapshot that fitted the old 24 000 pretty-printed characters still fits, so the model reads the same data for fewer tokens. Refs #1217 --- src/app/api/insights/generate/route.ts | 4 +- src/lib/ai/coach/__tests__/snapshot.test.ts | 199 +----------------- src/lib/ai/coach/snapshot-prelude.ts | 21 +- src/lib/ai/coach/snapshot.ts | 9 +- src/lib/ai/coach/source-snapshot.ts | 4 +- .../__tests__/compact-snapshot.test.ts | 165 +++++++++++++++ .../comprehensive-generate-force.test.ts | 2 +- src/lib/insights/biomarker-status.ts | 2 +- src/lib/insights/blood-pressure-status.ts | 2 +- src/lib/insights/bmi-status.ts | 2 +- src/lib/insights/comprehensive-generate.ts | 4 +- .../insights/derived/derived-assessment-ai.ts | 11 +- src/lib/insights/general-status.ts | 2 +- src/lib/insights/graded-series.ts | 24 ++- .../insights/medication-compliance-status.ts | 2 +- src/lib/insights/metric-status.ts | 2 +- src/lib/insights/mood-status.ts | 2 +- src/lib/insights/pulse-status.ts | 2 +- src/lib/insights/weight-status.ts | 2 +- 19 files changed, 226 insertions(+), 235 deletions(-) create mode 100644 src/lib/insights/__tests__/compact-snapshot.test.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 40a00ebb8..52a89ea4c 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -671,7 +671,9 @@ export const POST = apiHandler((request: NextRequest) => const compactFeatures = compactSections( features as unknown as Record, ); - const featuresJson = JSON.stringify(compactFeatures, null, 2); + // Compact on the wire: indentation is about 40% of a pretty-printed + // payload, and the model reads the structure without it (#1217). + const featuresJson = JSON.stringify(compactFeatures); if (payloadDowngraded) { annotate({ meta: { diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 7b9dfd64f..447dee86a 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -225,7 +225,10 @@ describe("buildCoachSnapshot", () => { ], }); - expect(out.snapshotJson).toBe(JSON.stringify(out.sections, null, 2)); + // The prompt carries the snapshot compact (#1217): the same content, + // without the indentation. + expect(out.snapshotJson).toBe(JSON.stringify(out.sections)); + expect(JSON.parse(out.snapshotJson)).toEqual(out.sections); expect(out).toMatchInlineSnapshot(` { "provenance": { @@ -468,199 +471,7 @@ describe("buildCoachSnapshot", () => { "unit": "kg", }, }, - "snapshotJson": "{ - "bloodPressure": { - "aggregate": { - "avgSys30": 125, - "avgDia30": 80, - "coverage": { - "count": 4 - } - }, - "timeline": { - "recent": [ - { - "date": "2026-07-10", - "weekday": "Fri", - "sys": 128, - "dia": 82 - } - ], - "weeklySys": [ - { - "weekISO": "2026-W27", - "mean": 122, - "count": 1 - } - ], - "weeklyDia": [ - { - "weekISO": "2026-W27", - "mean": 78, - "count": 1 - } - ] - } - }, - "weight": { - "unit": "kg", - "aggregate": { - "latest": 80.8, - "bmi": 24.7, - "coverage": { - "count": 2 - }, - "unit": "kg" - }, - "timeline": { - "recent": [ - { - "date": "2026-07-16", - "weekday": "Thu", - "value": 80.8 - } - ], - "weekly": [ - { - "weekISO": "2026-W27", - "mean": 81.4, - "count": 1 - } - ] - } - }, - "pulse": { - "aggregate": { - "avg7": 63, - "coverage": { - "count": 2 - } - }, - "timeline": { - "recent": [ - { - "date": "2026-07-17", - "weekday": "Fri", - "value": 63 - } - ], - "weekly": [ - { - "weekISO": "2026-W27", - "mean": 67, - "count": 1 - } - ] - } - }, - "mood": { - "aggregate": { - "avg30": 4, - "coverage": { - "count": 2 - } - }, - "timeline": { - "recent": [ - { - "date": "2026-07-18", - "weekday": "Sat", - "value": 5 - } - ], - "weekly": [ - { - "weekISO": "2026-W27", - "mean": 3, - "count": 1 - } - ] - } - }, - "bodyMassIndex": { - "unit": "kg/m²", - "timeline": { - "recent": [ - { - "date": "2026-07-08", - "weekday": "Wed", - "value": 24.8 - }, - { - "date": "2026-07-15", - "weekday": "Wed", - "value": 24.6 - } - ], - "weekly": [] - } - }, - "steps": { - "unit": "steps", - "timeline": { - "recent": [ - { - "date": "2026-07-18", - "weekday": "Sat", - "value": 8400 - } - ], - "weekly": [ - { - "weekISO": "2026-W27", - "mean": 7200, - "count": 1 - } - ] - } - }, - "skinTemperature": { - "unit": "°C", - "timeline": { - "recent": [ - { - "date": "2026-07-19", - "weekday": "Sun", - "value": 33.5 - } - ], - "weekly": [ - { - "weekISO": "2026-W27", - "mean": 33.2, - "count": 1 - } - ] - } - }, - "anthropometrics": { - "heightCm": 181, - "ageYears": 41, - "gender": "MALE" - }, - "dayStrain": { - "source": "WHOOP-native", - "scale": "0-21", - "latest": 13.9, - "recentMean": 12.6, - "days": 2, - "note": "Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy)." - }, - "scope": { - "window": "last30days", - "sources": [ - "bp", - "weight", - "pulse", - "mood", - "steps", - "bmi", - "skin_temp", - "hrv" - ], - "timelineRecentDays": 14 - } - }", + "snapshotJson": "{"bloodPressure":{"aggregate":{"avgSys30":125,"avgDia30":80,"coverage":{"count":4}},"timeline":{"recent":[{"date":"2026-07-10","weekday":"Fri","sys":128,"dia":82}],"weeklySys":[{"weekISO":"2026-W27","mean":122,"count":1}],"weeklyDia":[{"weekISO":"2026-W27","mean":78,"count":1}]}},"weight":{"unit":"kg","aggregate":{"latest":80.8,"bmi":24.7,"coverage":{"count":2},"unit":"kg"},"timeline":{"recent":[{"date":"2026-07-16","weekday":"Thu","value":80.8}],"weekly":[{"weekISO":"2026-W27","mean":81.4,"count":1}]}},"pulse":{"aggregate":{"avg7":63,"coverage":{"count":2}},"timeline":{"recent":[{"date":"2026-07-17","weekday":"Fri","value":63}],"weekly":[{"weekISO":"2026-W27","mean":67,"count":1}]}},"mood":{"aggregate":{"avg30":4,"coverage":{"count":2}},"timeline":{"recent":[{"date":"2026-07-18","weekday":"Sat","value":5}],"weekly":[{"weekISO":"2026-W27","mean":3,"count":1}]}},"bodyMassIndex":{"unit":"kg/m²","timeline":{"recent":[{"date":"2026-07-08","weekday":"Wed","value":24.8},{"date":"2026-07-15","weekday":"Wed","value":24.6}],"weekly":[]}},"steps":{"unit":"steps","timeline":{"recent":[{"date":"2026-07-18","weekday":"Sat","value":8400}],"weekly":[{"weekISO":"2026-W27","mean":7200,"count":1}]}},"skinTemperature":{"unit":"°C","timeline":{"recent":[{"date":"2026-07-19","weekday":"Sun","value":33.5}],"weekly":[{"weekISO":"2026-W27","mean":33.2,"count":1}]}},"anthropometrics":{"heightCm":181,"ageYears":41,"gender":"MALE"},"dayStrain":{"source":"WHOOP-native","scale":"0-21","latest":13.9,"recentMean":12.6,"days":2,"note":"Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy)."},"scope":{"window":"last30days","sources":["bp","weight","pulse","mood","steps","bmi","skin_temp","hrv"],"timelineRecentDays":14}}", "units": { "glucoseUnit": "mg/dL", "system": "metric", diff --git a/src/lib/ai/coach/snapshot-prelude.ts b/src/lib/ai/coach/snapshot-prelude.ts index 5acdedc8f..8a6b8ac30 100644 --- a/src/lib/ai/coach/snapshot-prelude.ts +++ b/src/lib/ai/coach/snapshot-prelude.ts @@ -36,16 +36,19 @@ import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types"; export const DAILY_TIMELINE_DAYS = 14; /** - * v1.7.0 — assembled-snapshot soft char cap. After the snapshot is - * built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per- - * token proxy and, if it exceeds this cap, progressively degrade the - * lowest-priority clusters (drop `timeline.recent`, then collapse the - * weekly buckets) until it fits. ~24 000 chars ≈ ~6 000 tokens, which - * sits comfortably inside every provider's context alongside the system - * prompt + history window. The daily token ledger (`budget.ts`) stays - * the per-day cost backstop; this is the per-prompt shape backstop. + * v1.7.0 — assembled-snapshot soft char cap. After the snapshot is built we + * measure its compact JSON (`JSON.stringify(snapshot).length`, the form the + * prompt and every tool result ship) and, if it exceeds this cap, + * progressively degrade the lowest-priority clusters (drop + * `timeline.recent`, then collapse the weekly buckets) until it fits. The + * daily token ledger (`budget.ts`) stays the per-day cost backstop; this is + * the per-prompt shape backstop. + * + * v1.43 (#1217) — 24 000 pretty-printed characters became 14 000 compact + * ones. Compact JSON is ~0.42 to ~0.5 of the pretty form for these blocks, so + * everything that fitted before still fits: the same data, fewer tokens. */ -export const MAX_SNAPSHOT_CHARS = 24_000; +export const MAX_SNAPSHOT_CHARS = 14_000; /** * v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 2dd46bbc6..04e37f92b 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1128,7 +1128,7 @@ async function buildCoachSnapshotImpl( } return { - snapshotJson: JSON.stringify(compactSnapshot, null, 2), + snapshotJson: JSON.stringify(compactSnapshot), // v1.20.0 (F1) — the structured, post-degrade snapshot record keyed by // domain block (`bloodPressure`, `glucose`, `labs`, …). The coach tool // executor slices a single domain block out of this so a retrieval tool @@ -1175,10 +1175,9 @@ export function degradeToBudget( cluster: CoachDataCluster; pass: number; }> = []; - // Measure against the SAME pretty-printed form the prompt ships - // (`JSON.stringify(snapshot, null, 2)`), not the compact form — - // otherwise the cap under-counts by ~2× and the prompt overflows. - const size = () => JSON.stringify(snapshot, null, 2).length; + // Measure against the SAME compact form the prompt ships + // (`JSON.stringify(snapshot)`), so the cap counts what the model reads. + const size = () => JSON.stringify(snapshot).length; if (size() <= MAX_SNAPSHOT_CHARS) return degraded; // Build a degrade order: blocks grouped by cluster, lowest priority diff --git a/src/lib/ai/coach/source-snapshot.ts b/src/lib/ai/coach/source-snapshot.ts index 723b94be5..ce9ae0bda 100644 --- a/src/lib/ai/coach/source-snapshot.ts +++ b/src/lib/ai/coach/source-snapshot.ts @@ -78,7 +78,7 @@ import type { } from "./types"; /** - * Above this pretty-printed size the section goes to the full builder, whose + * Above this compact size the section goes to the full builder, whose * budget pass may trim it (see the module comment). A 30-day section is a few * thousand characters; a year of blood pressure with its coarse tail is the * kind of section that crosses it. @@ -240,7 +240,7 @@ export async function buildCoachSourceSnapshot( const sections = compactSections(snapshot); annotateSnapshotFreshness(sections); - const size = JSON.stringify(sections, null, 2).length; + const size = JSON.stringify(sections).length; if (size > SOURCE_SNAPSHOT_FALLBACK_CHARS) { annotate({ action: { name: "coach.source_snapshot.full" }, diff --git a/src/lib/insights/__tests__/compact-snapshot.test.ts b/src/lib/insights/__tests__/compact-snapshot.test.ts new file mode 100644 index 000000000..3dbfd2c86 --- /dev/null +++ b/src/lib/insights/__tests__/compact-snapshot.test.ts @@ -0,0 +1,165 @@ +/** + * #1217 — the status snapshots travel as compact JSON, and the char cap was + * lowered with the switch so the model sees at least the same data for fewer + * tokens, never less. + * + * Two properties pin that: + * 1. compact JSON parses back to exactly the object the pretty form did, so + * dropping the indentation changes no content; + * 2. every snapshot (and every shed stage of one) that fitted the old cap, + * 24 000 pretty-printed characters, fits the new compact cap, so the + * shedding never starts earlier than it used to. + */ +import { describe, expect, it } from "vitest"; + +import { + MAX_SNAPSHOT_CHARS, + buildGradedSeriesFromPoints, + degradeStatusSnapshotToBudget, +} from "@/lib/insights/graded-series"; + +/** The cap before v1.43, measured on `JSON.stringify(snapshot, null, 2)`. */ +const PRETTY_CAP_BEFORE = 24_000; + +const NOW = new Date("2026-10-15T12:00:00Z"); + +function series(days: number, perDay: number, base: number, seed: number) { + const points: Array<{ measuredAt: Date; value: number }> = []; + let x = seed; + for (let d = 0; d < days; d++) { + for (let k = 0; k < perDay; k++) { + x = (x * 9301 + 49297) % 233280; + points.push({ + measuredAt: new Date(NOW.getTime() - d * 86_400_000 - k * 3_600_000), + value: Math.round((base + (x / 233280) * base * 0.2) * 10) / 10, + }); + } + } + return buildGradedSeriesFromPoints(points, NOW, "Europe/Berlin"); +} + +/** A general-status-shaped snapshot over `metrics` series of `days` days. */ +function snapshot(metrics: number, days: number): Record { + const measurementSeries: Record = {}; + for (let i = 0; i < metrics; i++) { + measurementSeries[`METRIC_${i}`] = { + unit: i % 2 === 0 ? "bpm" : "kg", + summary: { points: days * 2, mean: 61.2, min: 50, max: 80, latest: 63 }, + series: series(days, 2, 50 + i * 10, i + 1), + }; + } + return { + locale: "de", + generatedForDay: "2026-10-15", + interpretationHint: + "Use trend direction and deltas. Prioritize the newest data if trends conflict.", + dataCoverage: { + totalMeasurements: days * 2 * metrics, + totalSpanDays: days, + newestMeasurementDaysAgo: 0, + }, + measurementSeries, + correlations: Array.from({ length: 40 }, (_, i) => ({ + a: `METRIC_${i % metrics}`, + b: `METRIC_${(i + 1) % metrics}`, + r: Math.round(((i % 7) / 10 - 0.3) * 100) / 100, + n: 30 + i, + })), + }; +} + +const FIXTURES: Array<[number, number]> = []; +for (const metrics of [1, 2, 3, 4, 6, 8, 10, 16]) { + for (const days of [14, 30, 90, 200, 400, 1000]) { + FIXTURES.push([metrics, days]); + } +} + +/** The shed stages `degradeStatusSnapshotToBudget` walks, in order. */ +function stages(snap: Record): Record[] { + const out: Record[] = [structuredClone(snap)]; + const drop = (node: unknown, field: string): void => { + if (Array.isArray(node)) return node.forEach((n) => drop(n, field)); + if (node && typeof node === "object") { + const obj = node as Record; + for (const key of Object.keys(obj)) { + if (key === field && Array.isArray(obj[key])) delete obj[key]; + else drop(obj[key], field); + } + } + }; + const tails = (node: unknown): void => { + if (Array.isArray(node)) return node.forEach(tails); + if (node && typeof node === "object") { + const obj = node as Record; + for (const key of Object.keys(obj)) { + const value = obj[key]; + if (Array.isArray(value) && key !== "recent" && value.length > 30) { + obj[key] = value.slice(-30); + } + tails(obj[key]); + } + } + }; + let current = structuredClone(snap); + drop(current, "yearly"); + out.push(structuredClone(current)); + drop(current, "weekly"); + out.push(structuredClone(current)); + tails(current); + out.push(structuredClone(current)); + current = structuredClone(current); + drop(current, "recent"); + out.push(current); + return out; +} + +describe("compact status snapshots (#1217)", () => { + it.each(FIXTURES)( + "parses back to the same object (%i metrics, %i days)", + (metrics, days) => { + const snap = snapshot(metrics, days); + const compact = JSON.stringify(snap); + expect(JSON.parse(compact)).toEqual( + JSON.parse(JSON.stringify(snap, null, 2)), + ); + expect(compact.length).toBeLessThan(JSON.stringify(snap, null, 2).length); + }, + ); + + it("fits every stage that fitted the old pretty cap under the new compact cap", () => { + let checked = 0; + for (const [metrics, days] of FIXTURES) { + for (const stage of stages(snapshot(metrics, days))) { + if (JSON.stringify(stage, null, 2).length > PRETTY_CAP_BEFORE) continue; + checked += 1; + expect(JSON.stringify(stage).length).toBeLessThanOrEqual( + MAX_SNAPSHOT_CHARS, + ); + } + } + // The property is only worth something if it saw real cases near the cap. + expect(checked).toBeGreaterThan(40); + }); + + it("sheds nothing from a snapshot that fitted before", () => { + let fitted = 0; + for (const [metrics, days] of FIXTURES) { + const snap = snapshot(metrics, days); + if (JSON.stringify(snap, null, 2).length > PRETTY_CAP_BEFORE) continue; + fitted += 1; + const before = structuredClone(snap); + expect(degradeStatusSnapshotToBudget(snap)).toEqual([]); + expect(snap).toEqual(before); + } + expect(fitted).toBeGreaterThan(5); + }); + + it("still sheds a snapshot far over the cap, coarsest-last", () => { + const snap = snapshot(16, 1000); + const size = JSON.stringify(snap).length; + const shed = degradeStatusSnapshotToBudget(snap); + expect(shed[0]).toBe("yearly"); + expect(JSON.stringify(snap).length).toBeLessThan(size); + }); +}); diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 5eb22ab81..e9accd527 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -350,7 +350,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { expect(outcome).toEqual({ status: "generated", providerType: "openai" }); const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]); - expect(prompt).toContain('\\"unit\\": \\"lb\\"'); + expect(prompt).toContain('\\"unit\\":\\"lb\\"'); expect(prompt).toContain("179.5"); expect(prompt).not.toContain("81.4"); diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index 20b9e1887..17ca5636f 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -315,7 +315,7 @@ export async function generateBiomarkerStatus(args: { value: r.value, })), }; - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 4057590a4..59e2c9c70 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -642,7 +642,7 @@ export async function prepareBloodPressureStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 9c9808185..7257869dd 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -335,7 +335,7 @@ export async function prepareBmiStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index cd396bfc3..824681e30 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -965,7 +965,9 @@ export async function generateComprehensiveInsight( const compactFeatures = compactSections( features as unknown as Record, ); - const featuresJson = JSON.stringify(compactFeatures, null, 2); + // Compact on the wire: indentation is about 40% of a pretty-printed + // payload, and the model reads the structure without it (#1217). + const featuresJson = JSON.stringify(compactFeatures); // v1.16.8 — content-hash gate. When the compacted feature snapshot is // byte-equivalent (modulo clock-relative offsets, see snapshot-hash.ts) diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index 7d1acb045..bf29989e6 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -133,11 +133,12 @@ function scoreUserPrompt( /** v1.30.3 (QA F5) — the user's own IANA tz; was hardcoded "Europe/Berlin". */ tz: string, ): string { - const snapshot = JSON.stringify( - { promptVersion: PROMPT_VERSION, generatedForDay: todayKey, band, signal }, - null, - 2, - ); + const snapshot = JSON.stringify({ + promptVersion: PROMPT_VERSION, + generatedForDay: todayKey, + band, + signal, + }); if (instructionLocale(locale) === "en") { return `Date: ${todayKey} (${tz}) OPENER HINT: ${openerHint} diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index de8420700..837e0339a 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -419,7 +419,7 @@ export async function prepareGeneralStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index ed50332bb..c322381bf 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -682,17 +682,25 @@ export function convertGradedSeries( } /** - * Assembled-snapshot soft char cap, mirroring the Coach's - * `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the - * pretty-printed form). The status snapshots had no cap at all; a - * many-metric `general-status` could balloon past 100 K tokens. + * Assembled-snapshot soft char cap, measured against the COMPACT JSON the + * prompt ships (`JSON.stringify(snapshot)`). The status snapshots had no cap + * at all once; a many-metric `general-status` could balloon past 100 K + * tokens. + * + * Until v1.43 the cap was 24 000 characters of pretty-printed JSON. The + * prompt is compact now, and a graded series loses about 58% of its + * characters to indentation (compact is ~0.42 of pretty across one to sixteen + * metrics and thirty to a thousand days), so 24 000 pretty characters are + * ~10 100 compact ones. 14 000 keeps every snapshot that fitted before + * fitting now, with room for the string-heavier blocks whose ratio is higher: + * the model sees at least the same data for fewer tokens, never less. */ -export const MAX_SNAPSHOT_CHARS = 24_000; +export const MAX_SNAPSHOT_CHARS = 14_000; /** * Walk an arbitrary snapshot object and, while it exceeds - * `MAX_SNAPSHOT_CHARS` (measured against the pretty-printed form the - * prompt ships), shed the lowest-signal slices in order: + * `MAX_SNAPSHOT_CHARS` (measured against the compact form the prompt + * ships), shed the lowest-signal slices in order: * 1. drop every graded `yearly` array, * 2. drop every graded `weekly` array, * 3. truncate any other array to its tail `arrayTailCap` entries @@ -709,7 +717,7 @@ export function degradeStatusSnapshotToBudget( arrayTailCap = 30, ): string[] { const shed: string[] = []; - const size = () => JSON.stringify(snapshot, null, 2).length; + const size = () => JSON.stringify(snapshot).length; if (size() <= MAX_SNAPSHOT_CHARS) return shed; const walk = ( diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 299b97a64..7b6846d3f 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -406,7 +406,7 @@ export async function prepareMedicationComplianceStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 8ee1c927c..26b5748ab 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -454,7 +454,7 @@ export async function generateMetricStatus(args: { const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 4c787f040..53b4d06dd 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -665,7 +665,7 @@ export async function prepareMoodStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index b6143d2e1..1d1901f91 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -392,7 +392,7 @@ export async function preparePulseStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 7bc94bef0..59d2d58e4 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -608,7 +608,7 @@ export async function prepareWeightStatusForUser( const shed = degradeStatusSnapshotToBudget( snapshot as unknown as Record, ); - const snapshotJson = JSON.stringify(snapshot, null, 2); + const snapshotJson = JSON.stringify(snapshot); annotate({ action: { name: cacheAction }, From e573f28ae89a54d6d9bfa198094a3d2b029a1e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:14:20 +0200 Subject: [PATCH 025/127] perf(coach): keep the system prompt stable across turns and rounds The opener hint, the first-name clause, resolved follow-ups, the table rules and the final-round line used to be written into the system prompt, so the cached prefix (tools plus system) was rewritten on every turn and again on the forced final round. They travel on the user side now: the per-turn lines in the user message, the table rules as a note after the round that produced the first table, the final-round reason as the last user line. The system prompt depends on the locale, the Coach preferences and the about-me text only. Refs #1217 --- .../insights/chat/__tests__/dialog-harness.ts | 12 ++ .../chat/__tests__/golden-transcripts.test.ts | 31 +++- .../__tests__/route-cancelled-turn.test.ts | 1 + .../__tests__/route-capability-gate.test.ts | 1 + .../chat/__tests__/route-fence-guard.test.ts | 1 + .../chat/__tests__/route-followups.test.ts | 36 ++--- .../__tests__/route-guard-activation.test.ts | 1 + .../chat/__tests__/route-guard-ledger.test.ts | 1 + .../chat/__tests__/route-guard.test.ts | 1 + .../chat/__tests__/route-list-query.test.ts | 1 + .../__tests__/route-snapshot-once.test.ts | 1 + .../chat/__tests__/route-tool-mode.test.ts | 1 + .../__tests__/route-workout-scope.test.ts | 1 + .../ai/coach/__tests__/stable-prefix.test.ts | 108 ++++++++++++++ .../coach/__tests__/tools-loop-budget.test.ts | 19 ++- src/lib/ai/coach/chat-request-builder.ts | 9 +- .../__tests__/loop-result-tables.test.ts | 46 ++++-- src/lib/ai/coach/system-prompt.ts | 55 ++++--- src/lib/ai/coach/tools/loop.ts | 41 ++++-- .../__tests__/model-stable-prefix.test.ts | 136 ++++++++++++++++++ src/lib/ai/coach/turn/addenda/index.ts | 13 ++ src/lib/ai/coach/turn/context.ts | 22 ++- src/lib/ai/coach/turn/model.ts | 75 ++++++---- 23 files changed, 512 insertions(+), 101 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/stable-prefix.test.ts create mode 100644 src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts diff --git a/src/app/api/insights/chat/__tests__/dialog-harness.ts b/src/app/api/insights/chat/__tests__/dialog-harness.ts index c9b2dc079..b1f2cc60c 100644 --- a/src/app/api/insights/chat/__tests__/dialog-harness.ts +++ b/src/app/api/insights/chat/__tests__/dialog-harness.ts @@ -68,6 +68,18 @@ export interface ProviderCall { } export const providerCalls: ProviderCall[] = []; +/** + * Everything one provider call carried on the user side. Since #1217 a turn's + * own lines (a resolved follow-up, an answered clarification, a continuation) + * travel here, never in the system prompt, so the cached prefix holds. + */ +export function userSide(call: ProviderCall): string { + return call.messages + .filter((msg) => msg.role === "user") + .map((msg) => msg.content) + .join("\n\n"); +} + /** The last assistant message of the stored conversation. */ export const LAST_ASSISTANT_ID = "m-a1"; export const CONVERSATION_ID = "c-dialog"; diff --git a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts index 1703970b6..821db4dee 100644 --- a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts +++ b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts @@ -113,6 +113,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: h.m.getCoachSystemPrompt, + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: h.m.getSelfContextTextForUser, @@ -652,21 +653,34 @@ describe("coach chat golden transcripts", () => { expect(transcript.thrown).toBeNull(); expect(m.runStreamingRawCompletionWithFallback).toHaveBeenCalledTimes(1); const { params } = m.runStreamingRawCompletionWithFallback.mock - .calls[0][0] as { params: { system: string } }; - expect(params.system).toContain(CLARIFIED); + .calls[0][0] as { + params: { system: string; messages: Array<{ content: string }> }; + }; + // #1217 — on the user side, never in the cached system prompt. + const user = params.messages.map((msg) => msg.content).join("\n"); + expect(user).toContain(CLARIFIED); + expect(params.system).not.toContain(CLARIFIED); // Server-authored: the request's choice id picks the stored value, // the label the model once wrote never rides along. - expect(params.system).not.toContain("Resting pulse"); + const line = user + .split("\n") + .find((text) => text.startsWith("CLARIFIED:")); + expect(line).toContain(CLARIFIED); + expect(line).not.toContain("Resting pulse"); }); it("the tool-mode prompt carries the same line", async () => { reply("Your resting pulse averaged 62 bpm."); const transcript = await runTurn(post, body, {}); expect(transcript.thrown).toBeNull(); - const { system } = m.runCoachToolLoop.mock.calls[0][0] as { + const { system, messages } = m.runCoachToolLoop.mock.calls[0][0] as { system: string; + messages: Array<{ content: string }>; }; - expect(system).toContain(CLARIFIED); + expect(messages.map((msg) => msg.content).join("\n")).toContain( + CLARIFIED, + ); + expect(system).not.toContain(CLARIFIED); }); it("an unanswered turn sends neither prompt the line", async () => { @@ -680,8 +694,13 @@ describe("coach chat golden transcripts", () => { {}, ); const { params } = m.runStreamingRawCompletionWithFallback.mock - .calls[0][0] as { params: { system: string } }; + .calls[0][0] as { + params: { system: string; messages: Array<{ content: string }> }; + }; expect(params.system).not.toContain("CLARIFIED:"); + expect( + params.messages.map((msg) => msg.content).join("\n"), + ).not.toContain("CLARIFIED:"); }); }); }); diff --git a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts index e76d8a5e2..a945487be 100644 --- a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts +++ b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts @@ -118,6 +118,7 @@ vi.mock("@/lib/ai/coach/refusal", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts index ea52a5815..18ca5598c 100644 --- a/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts +++ b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts @@ -116,6 +116,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(), diff --git a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts index 203fde324..f966c86c3 100644 --- a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts @@ -103,6 +103,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-followups.test.ts b/src/app/api/insights/chat/__tests__/route-followups.test.ts index fbfc92f84..b4490a3d5 100644 --- a/src/app/api/insights/chat/__tests__/route-followups.test.ts +++ b/src/app/api/insights/chat/__tests__/route-followups.test.ts @@ -265,7 +265,7 @@ describe("a reuse chip", () => { const run = await tap("f3"); expect(run.status).toBe(200); expect(annotated("coach.followUp.reuse_unavailable")).toHaveLength(1); - expect(providerCalls[0].system).toContain( + expect(h.userSide(providerCalls[0])).toContain( "Call show_result with ref m1.r1 and view chart", ); }); @@ -282,15 +282,16 @@ describe("a model chip", () => { anchor: { domain: "glucose", window: "allTime" }, }); expect(run.status).toBe(200); - const system = providerCalls[0].system; - expect(system).toContain( + const user = h.userSide(providerCalls[0]); + expect(user).toContain( "FOLLOW-UP: the person tapped the previous_period chip", ); - expect(system).toContain( + expect(user).toContain( "get_metric_table metric=bp window=last30days period=previous granularity=week", ); - expect(system).toContain("m1.r1"); - expect(system).not.toContain("glucose window=allTime"); + expect(user).toContain("m1.r1"); + expect(user).not.toContain("glucose window=allTime"); + expect(providerCalls[0].system).not.toContain("FOLLOW-UP:"); }); it("degrades a chip that is not on the latest reply to a plain message", async () => { @@ -303,7 +304,7 @@ describe("a model chip", () => { }); expect(run.status).toBe(200); expect(providerCalls).toHaveLength(1); - expect(providerCalls[0].system).not.toContain("FOLLOW-UP:"); + expect(h.userSide(providerCalls[0])).not.toContain("FOLLOW-UP:"); expect(annotated("coach.followUp.stale")).toEqual([ expect.objectContaining({ meta: { reason: "not_latest" } }), ]); @@ -313,7 +314,7 @@ describe("a model chip", () => { conversationWithChips(); world.script = [{ text: "Here is what I see." }]; await tap("f9"); - expect(providerCalls[0].system).not.toContain("FOLLOW-UP:"); + expect(h.userSide(providerCalls[0])).not.toContain("FOLLOW-UP:"); expect(annotated("coach.followUp.stale")).toEqual([ expect.objectContaining({ meta: { reason: "unknown_chip" } }), ]); @@ -343,16 +344,17 @@ describe("a model chip", () => { { text: "Having looked further, the readings stay level." }, ]; await tap("f1"); - const system = providerCalls[0].system; - expect(system).toContain( + const user = h.userSide(providerCalls[0]); + expect(user).toContain( "CONTINUE: the person asked you to keep looking. The unfinished question is their message before that request, in CONVERSATION.", ); - // The question stays the person's own turn; it never reaches the - // system role. - expect(system).not.toContain("How was my blood pressure last month?"); - expect(system).toContain( + expect(user).toContain( "Already fetched: get_metric_table(bp, last30days, week) → m1.r1", ); + // The system role carries neither the line nor the question (#1217). + const { system } = providerCalls[0]; + expect(system).not.toContain("CONTINUE:"); + expect(system).not.toContain("How was my blood pressure last month?"); }); }); @@ -373,7 +375,7 @@ describe("an answered question", () => { clarification: { messageId: h.LAST_ASSISTANT_ID, choiceId: "c2" }, }); expect(run.status).toBe(200); - expect(providerCalls[0].system).toContain( + expect(h.userSide(providerCalls[0])).toContain( "CLARIFIED: the person answered your clarifying question by choosing metric=resting_hr.", ); }); @@ -385,7 +387,7 @@ describe("an answered question", () => { message: "The one from my watch", clarification: { messageId: h.LAST_ASSISTANT_ID }, }); - expect(providerCalls[0].system).toContain( + expect(h.userSide(providerCalls[0])).toContain( "CLARIFIED: the person answered your clarifying question in their own words", ); }); @@ -397,7 +399,7 @@ describe("an answered question", () => { message: "Resting heart rate", clarification: { messageId: "m-older", choiceId: "c2" }, }); - expect(providerCalls[0].system).not.toContain("CLARIFIED:"); + expect(h.userSide(providerCalls[0])).not.toContain("CLARIFIED:"); expect(annotated("coach.clarification.stale")).toHaveLength(1); }); diff --git a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts index d63bc8a7f..182861065 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts @@ -154,6 +154,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts index 4f455a742..c692f5205 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts @@ -147,6 +147,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-guard.test.ts b/src/app/api/insights/chat/__tests__/route-guard.test.ts index b47f19163..97d5c793f 100644 --- a/src/app/api/insights/chat/__tests__/route-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard.test.ts @@ -127,6 +127,7 @@ vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal })); // NB: the outbound guard + numeric verifier are REAL — that is the point. vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-list-query.test.ts b/src/app/api/insights/chat/__tests__/route-list-query.test.ts index 59beef5cf..7a221034f 100644 --- a/src/app/api/insights/chat/__tests__/route-list-query.test.ts +++ b/src/app/api/insights/chat/__tests__/route-list-query.test.ts @@ -109,6 +109,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(), diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index afe28acfc..713dfcc5c 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -120,6 +120,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index 19749f312..fcb3028b6 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -153,6 +153,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts index 10081bc48..28a98783e 100644 --- a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts +++ b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts @@ -193,6 +193,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({ })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn(() => "SYSTEM"), + buildCoachTurnNotes: vi.fn(() => ""), })); vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextTextForUser: vi.fn(async () => null), diff --git a/src/lib/ai/coach/__tests__/stable-prefix.test.ts b/src/lib/ai/coach/__tests__/stable-prefix.test.ts new file mode 100644 index 000000000..dcc7a7eb3 --- /dev/null +++ b/src/lib/ai/coach/__tests__/stable-prefix.test.ts @@ -0,0 +1,108 @@ +/** + * #1217 — the Coach's cached prefix (tools plus system prompt) is the same + * string on every turn of a conversation. What changes per turn (the opener + * hint, the name clause, a resolved follow-up, the table rules once a table + * exists) travels in the user message, so a provider's prompt cache holds + * from one turn to the next instead of being rewritten every time. + */ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { buildCoachToolRequest } from "@/lib/ai/coach/chat-request-builder"; +import { + buildCoachTurnNotes, + getCoachSystemPrompt, +} from "@/lib/ai/coach/system-prompt"; +import { buildToolModeAddendum } from "@/lib/ai/coach/tools/system-addendum"; +import { + buildDialogAddenda, + buildTableRulesAddendum, +} from "@/lib/ai/coach/turn/addenda"; +import { + openerArchetypeHint, + shouldUseNameForTurn, +} from "@/lib/ai/prompts/opener-archetype"; +import type { Locale } from "@/lib/i18n/config"; + +const sha = (text: string) => createHash("sha256").update(text).digest("hex"); + +/** One turn's request, assembled the way the turn model assembles it. */ +function turnRequest(locale: Locale, turnIndex: number, hints: string[]) { + const systemPrompt = getCoachSystemPrompt(locale, undefined, "I run."); + const turnNotes = buildCoachTurnNotes(locale, { + firstName: "Alex", + mayUseName: shouldUseNameForTurn(`u1:${turnIndex}`), + openerHint: openerArchetypeHint(`u1:${turnIndex}`, locale), + }); + const tables = turnIndex > 1 ? [buildTableRulesAddendum(locale)] : []; + return buildCoachToolRequest({ + systemPrompt, + toolModeAddendum: `${buildToolModeAddendum(locale)}\n\n${buildDialogAddenda( + locale, + { tableRules: false }, + )}`, + focusHint: "", + workoutEvidence: null, + dataInventory: "DATA INVENTORY\n- pulse", + guidedBlock: "", + transcript: `user: question ${turnIndex}`, + languageName: "English", + turnNotes: [...tables, turnNotes, ...hints] + .filter((block) => block.length > 0) + .join("\n\n"), + }); +} + +describe("stable Coach prefix (#1217)", () => { + it.each(["en", "de", "fr", "ko"] as Locale[])( + "hashes the system prompt identically on every turn (%s)", + (locale) => { + const hashes = new Set(); + for (let turn = 0; turn < 12; turn++) { + const request = turnRequest( + locale, + turn, + turn % 3 === 0 ? [`FOLLOW-UP: earlier answer ${turn}`] : [], + ); + hashes.add(sha(request.system)); + } + expect(hashes.size).toBe(1); + }, + ); + + it("carries the per-turn lines in the user message instead", () => { + const opener = openerArchetypeHint("u1:4", "en"); + const request = turnRequest("en", 4, ["FOLLOW-UP: the pulse table"]); + const user = String(request.messages[0]!.content); + expect(user).toContain(opener); + expect(user).toContain("FOLLOW-UP: the pulse table"); + expect(user).toContain(buildTableRulesAddendum("en")); + expect(request.system).not.toContain(opener); + expect(request.system).not.toContain("FOLLOW-UP"); + expect(request.system).not.toContain(buildTableRulesAddendum("en")); + }); + + it("keeps the opener hint and the name out of the system prompt", () => { + for (const locale of ["en", "de"] as Locale[]) { + const system = getCoachSystemPrompt(locale, undefined, null); + expect(system).not.toMatch( + /OPENER HINT for this turn|OPENER-HINWEIS für diesen Turn/, + ); + expect(system).not.toContain("Alex"); + } + }); + + it("names the turn's opener and, on a gated turn, the first name", () => { + const notes = buildCoachTurnNotes("en", { + firstName: "Alex", + mayUseName: true, + openerHint: "Lead with the finding.", + }); + expect(notes).toContain( + "OPENER HINT for this turn: Lead with the finding.", + ); + expect(notes).toContain('the user\'s first name is "Alex"'); + expect(buildCoachTurnNotes("en", {})).toBe(""); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts index d208abff1..c39c6f4b5 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts @@ -105,11 +105,24 @@ function lastParams() { const calls = runRawCompletionWithFallback.mock.calls; return calls[calls.length - 1][0].params as { system: string; + messages: Array<{ role: string; content: unknown }>; toolChoice?: string; tools?: unknown[]; }; } +/** + * The final round's reason, as the model reads it: the last user-side line. + * #1217 — it no longer rides the system prompt, which stays the cached + * prefix every round of the turn shares. + */ +function finalRoundLine(): string { + const last = lastParams().messages.at(-1); + expect(last?.role).toBe("user"); + expect(lastParams().system).toBe(baseArgs.system); + return String(last?.content); +} + beforeEach(() => { emitSignal.mockReset(); executeCoachTool.mockReset(); @@ -138,9 +151,7 @@ describe("stop reasons", () => { // The tools stay on the final round: the history holds the calls, and // Anthropic refuses tool_use/tool_result blocks without `tools` (400). expect(lastParams().tools).toEqual(COACH_TOOL_DEFS); - expect(lastParams().system).toMatch( - /FINAL ROUND: this turn's token budget/, - ); + expect(finalRoundLine()).toMatch(/FINAL ROUND: this turn's token budget/); expect(out.result.content).toBe("Answer from what I have."); }); @@ -209,7 +220,7 @@ describe("stop reasons", () => { expect(out.stop).toEqual({ reason: "no_progress", rounds: 3 }); expect(out.forcedFinal).toBe(false); expectFinalRoundKeepsTools(); - expect(lastParams().system).toMatch(/brought nothing new/); + expect(finalRoundLine()).toMatch(/brought nothing new/); }); }); diff --git a/src/lib/ai/coach/chat-request-builder.ts b/src/lib/ai/coach/chat-request-builder.ts index 1a892b46e..91ea7e03a 100644 --- a/src/lib/ai/coach/chat-request-builder.ts +++ b/src/lib/ai/coach/chat-request-builder.ts @@ -148,6 +148,13 @@ export function buildCoachToolRequest(args: { guidedBlock: string; transcript: string; languageName: string; + /** + * v1.43 (#1217) — what changes from one turn to the next: the opener hint, + * the name clause, a resolved follow-up, the table rules once a table + * exists. Carried here, on the user side, so `system` is the same string + * on every turn and every round. Empty or absent: nothing is added. + */ + turnNotes?: string; }): { system: string; messages: AiMessage[] } { const focusBlock = args.focusHint ? `${args.focusHint}\n\n` : ""; const workoutDataBlock = @@ -160,7 +167,7 @@ ${fenceHealthData(JSON.stringify({ thisWorkout: args.workoutEvidence }))} const messages: AiMessage[] = [ { role: "user", - content: `${focusBlock}${workoutDataBlock}${args.memoryBlock ? `${args.memoryBlock}\n\n` : ""}${args.dataInventory}${args.priorResults ? `\n\n${args.priorResults}` : ""}${args.guidedBlock} + content: `${focusBlock}${workoutDataBlock}${args.memoryBlock ? `${args.memoryBlock}\n\n` : ""}${args.dataInventory}${args.priorResults ? `\n\n${args.priorResults}` : ""}${args.guidedBlock}${args.turnNotes ? `\n\n${args.turnNotes}` : ""} CONVERSATION ${args.transcript} diff --git a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts index 780b1281f..1e392f059 100644 --- a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts +++ b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts @@ -99,7 +99,7 @@ describe("runCoachToolLoop — result tables", () => { expect(JSON.stringify(out.toolResults)).not.toContain("987654"); }); - it("adds the table rules to the rounds after the first table, not before", async () => { + it("adds the table rules to the rounds after the first table, not before, without touching the system prompt", async () => { executeCoachTool .mockResolvedValueOnce({ present: true, data: { n: 1 } }) .mockResolvedValueOnce({ @@ -113,16 +113,27 @@ describe("runCoachToolLoop — result tables", () => { name, arguments: '{"metric":"pulse"}', }); - runRawCompletionWithFallback - .mockResolvedValueOnce(round("", [call("a", "get_metric_series")])) - .mockResolvedValueOnce(round("", [call("b", "get_metric_table")])) - .mockResolvedValueOnce(round("answer")); + // The loop hands every round the same growing message array, so record + // what each round saw at the moment it was called. + const seen: Array<{ system: string; messages: unknown[] }> = []; + const replies = [ + round("", [call("a", "get_metric_series")]), + round("", [call("b", "get_metric_table")]), + round("answer"), + ]; + runRawCompletionWithFallback.mockImplementation(async (arg: unknown) => { + const { params } = arg as { + params: { system: string; messages: unknown[] }; + }; + seen.push({ system: params.system, messages: [...params.messages] }); + return replies[seen.length - 1]; + }); await runCoachToolLoop({ userId: "u1", providers: [], system: "sys", - systemOnceTableShown: "sys+tables", + tableRulesOnceShown: "TABLE RULES", messages: [{ role: "user", content: "q" }], tools: COACH_TOOL_DEFS, reach: UNBOUNDED_REACH, @@ -133,9 +144,26 @@ describe("runCoachToolLoop — result tables", () => { refs: createResultRefAllocator(), }, }); - const systems = runRawCompletionWithFallback.mock.calls.map( - ([arg]) => (arg as { params: { system: string } }).params.system, + // #1217 — one system prompt for every round, so the cached prefix holds. + expect(seen.map((round) => round.system)).toEqual(["sys", "sys", "sys"]); + const rulesIn = seen.map((round) => + round.messages.some( + (m) => + (m as { role: string; content: unknown }).role === "user" && + (m as { content: unknown }).content === "TABLE RULES", + ), + ); + expect(rulesIn).toEqual([false, false, true]); + // The rules follow the tool results that produced the table. + const last = seen[2]!.messages as Array<{ role: string }>; + expect(last.at(-1)).toEqual({ role: "user", content: "TABLE RULES" }); + expect(last.at(-2)?.role).toBe("tool"); + // Each round's messages extend the previous round's: a stable prefix. + expect(seen[1]!.messages.slice(0, seen[0]!.messages.length)).toEqual( + seen[0]!.messages, + ); + expect(seen[2]!.messages.slice(0, seen[1]!.messages.length)).toEqual( + seen[1]!.messages, ); - expect(systems).toEqual(["sys", "sys", "sys+tables"]); }); }); diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 5ee5600fe..ee5a21713 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -1249,8 +1249,12 @@ const LOCALE_REPLY_FOOTER_FALLBACK: Record< /** * v1.22 (W6) — per-turn personalization passed by the chat route. All fields - * are optional and the addendum is omitted when none apply, so the prompt is - * byte-identical to the pre-feature one for the coverage test + unnamed users. + * are optional and the notes are empty when none apply. + * + * v1.43 (#1217) — these lines change every turn, so they no longer ride the + * system prompt: a system prompt that changed per turn rewrote the provider's + * cached prefix (tools plus system, 14 to 19 K tokens) on every turn. They + * are composed by {@link buildCoachTurnNotes} and travel in the user message. */ export interface CoachPersonalization { /** The user's first name; null when no display name is set. */ @@ -1261,11 +1265,17 @@ export interface CoachPersonalization { openerHint?: string | null; } +/** + * The Coach system prompt. Stable for one person across every turn and every + * round of a turn: it depends on the locale, the Coach preferences and the + * about-me text only, so the provider's prompt cache holds. Anything that + * changes per turn (the opener hint, the name clause, a resolved follow-up) + * goes into the user message instead. + */ export function getCoachSystemPrompt( locale: Locale, prefs: CoachPrefs = DEFAULT_COACH_PREFS, aboutMe: string | null = null, - personalization: CoachPersonalization = {}, ): string { let base: string; if (locale === "de") { @@ -1313,11 +1323,11 @@ export function getCoachSystemPrompt( // may only link a slug it lists, so a fabricated /learn URL is impossible. const withLearn = `${withPrefix}\n\n${learnCatalogPromptBlock()}`; // v1.22 (W6) — the narrative-quality addendum: the context-layer rule, the - // single-chart-token exception, the BP usual-range verbatim clause, plus the - // per-turn opener hint and sparse first-name personalization. Composed after - // the shared contracts so the chart-token exception can carve out the one - // place a `metric:` token is permitted. - const withV122 = `${withLearn}\n\n${buildCoachV122Addendum(locale, personalization)}`; + // single-chart-token exception, the BP usual-range verbatim clause. Composed + // after the shared contracts so the chart-token exception can carve out the + // one place a `metric:` token is permitted. The per-turn opener hint + // and name clause are in `buildCoachTurnNotes`. + const withV122 = `${withLearn}\n\n${buildCoachV122Addendum(locale)}`; // v1.15.20 — the user-authored "about me" self-description rides the // system prompt as a delimited, user-provided block (Settings → AI). const suffix = aboutMe ? buildAboutMeBlock(locale, aboutMe) : ""; @@ -1338,14 +1348,11 @@ export function getCoachSystemPrompt( * - BP USUAL RANGE — when the snapshot's blood-pressure block carries a * `usualRange`, quote THOSE bounds verbatim; never invent a band from window * means (kills the fabricated "153–150" range). - * - OPENER HINT — vary the turn's opening so a multi-turn session does not open - * identically every time. - * - NAME — sparse, anti-formulaic first-name address on the gated turns only. + * + * The per-turn opener hint and first-name clause that used to close this + * addendum are {@link buildCoachTurnNotes} now. */ -function buildCoachV122Addendum( - locale: Locale, - personalization: CoachPersonalization, -): string { +function buildCoachV122Addendum(locale: Locale): string { const isDe = instructionLocale(locale) === "de"; const parts: string[] = []; parts.push(isDe ? "COACH-ZUSATZ (v1.22)" : "COACH ADDENDUM (v1.22)"); @@ -1392,6 +1399,21 @@ function buildCoachV122Addendum( : `EXPERIMENT REVIEW — when the snapshot carries an "experimentOutcomes" block, read the result back in the user's own numbers, association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`, ); + return parts.join("\n\n"); +} + +/** + * v1.43 (#1217) — the lines that change every Coach turn: the hash-seeded + * opener hint and, on the gated turns, the first-name clause. They travel in + * the user message so the system prompt stays byte-stable across turns and + * the provider's prompt cache holds. Empty when neither applies. + */ +export function buildCoachTurnNotes( + locale: Locale, + personalization: CoachPersonalization, +): string { + const isDe = instructionLocale(locale) === "de"; + const parts: string[] = []; if (personalization.openerHint) { parts.push( isDe @@ -1409,7 +1431,8 @@ function buildCoachV122Addendum( ); } - return parts.join("\n\n"); + if (parts.length === 0) return ""; + return [isDe ? "FÜR DIESEN TURN" : "FOR THIS TURN", ...parts].join("\n\n"); } /** diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index 2a0c40a82..5fe032389 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -194,10 +194,13 @@ export async function runCoachToolLoop(args: { providers: ProviderChainResolved[]; system: string; /** - * v1.39.4 — the system prompt for the rounds after a call produced a - * table, when it differs (the table rules ride it). + * v1.39.4 — the table rules, for the rounds after a call produced a table. + * v1.43 (#1217) — they join the conversation as a user-side note after + * that round's tool results instead of switching the system prompt, so the + * cached prefix (tools plus system) survives the switch. Absent when the + * turn already carries them. */ - systemOnceTableShown?: string; + tableRulesOnceShown?: string; /** The conversation messages (history + the new user turn). */ messages: AiMessage[]; tools: AiToolDef[]; @@ -284,7 +287,12 @@ export async function runCoachToolLoop(args: { const cacheKey = coachPromptCacheKey(turn?.conversationId); const messages: AiMessage[] = [...args.messages]; - let system = args.system; + // v1.43 (#1217) — the system prompt never changes inside a turn: what used + // to be swapped or appended into it (the table rules, the final-round line) + // goes on the user side, so every round reads the same cached prefix. + const system = args.system; + let tableRulesPending = args.tableRulesOnceShown; + let tableShownThisRound = false; let totalTokens = 0; let cachedTokens = 0; let rounds = 0; @@ -352,10 +360,15 @@ export async function runCoachToolLoop(args: { surface: "coach", ledger, params: { - system: stopReason - ? `${system}\n\n${FINAL_ROUND_LINE[stopReason]}` - : system, - messages, + system, + // The final round says why it is final as the last user-side line, + // after everything the earlier rounds cached. + messages: stopReason + ? [ + ...messages, + { role: "user", content: FINAL_ROUND_LINE[stopReason] }, + ] + : messages, temperature, maxTokens, signal, @@ -522,9 +535,7 @@ export async function runCoachToolLoop(args: { // prose against that same summary. notify(() => onCallSettled?.(call, toolResult, index, round)); const { table, ...forModel } = toolResult; - if (table && args.systemOnceTableShown) { - system = args.systemOnceTableShown; - } + if (table) tableShownThisRound = true; const validArgs = call.name === COMPARE_SERIES_TOOL_NAME ? compareArgs(call.arguments) @@ -551,6 +562,14 @@ export async function runCoachToolLoop(args: { budget.addInput(textTokens(serialised)); messages.push({ role: "tool", toolCallId: call.id, content: serialised }); } + // The first table of the turn brings the table rules along, once, right + // after the results that produced it. + if (tableShownThisRound && tableRulesPending) { + messages.push({ role: "user", content: tableRulesPending }); + budget.addInput(textTokens(tableRulesPending)); + tableRulesPending = undefined; + } + tableShownThisRound = false; budget.addInput(textTokens(result.content ?? "")); notify(() => onRoundSettled?.(round)); diff --git a/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts b/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts new file mode 100644 index 000000000..1a90f4b14 --- /dev/null +++ b/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts @@ -0,0 +1,136 @@ +/** + * #1217 — the turn model sends the same system prompt on every turn. The + * per-turn lines (opener hint, name clause, a resolved follow-up) reach the + * model on the user side, on the tool path and on the streaming no-tools + * path alike, so the provider's cached prefix survives from turn to turn. + */ +import { createHash } from "node:crypto"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const m = vi.hoisted(() => ({ + loop: vi.fn(), + stream: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/ai/coach/persistence", () => ({ appendMessage: vi.fn() })); +vi.mock("@/lib/ai/coach/memory/contract", () => ({ + buildMemoryContextBlock: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/coach/tools/executor", async (importOriginal) => ({ + ...(await importOriginal()), + admittedPriorResults: vi.fn(async () => []), +})); +vi.mock("@/lib/ai/coach/tools", async (importOriginal) => ({ + ...(await importOriginal()), + buildCoachDataInventory: vi.fn(async () => ({ + entries: [], + probeScope: undefined, + })), + renderDataInventory: () => "DATA INVENTORY", + runCoachToolLoop: (args: unknown) => m.loop(args), +})); +vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({ + ...(await importOriginal()), + runStreamingRawCompletionWithFallback: (args: unknown) => m.stream(args), +})); + +import { runTurnModel } from "../model"; +import type { TurnContext } from "../context"; + +const sha = (text: string) => createHash("sha256").update(text).digest("hex"); + +function ctx(turnNotes: string): TurnContext { + return { + coachPrefs: {}, + reach: { kind: "unbounded" }, + aiResponseTimeoutMs: 60_000, + effectiveScope: undefined, + snapshot: { + snapshotJson: "{}", + sections: {}, + provenance: { windows: [], metrics: [], counts: {} }, + }, + aboutMe: null, + scheduleDoses: [], + medicationNames: [], + turnContext: { + allTurns: [], + window: [], + transcript: "user: hi", + guidedBlock: "", + historyElided: false, + isFirstTurn: true, + includeFullSnapshot: true, + }, + workoutEvidence: null, + systemPrompt: "STABLE SYSTEM", + userPrompt: "SNAPSHOT AND CONVERSATION", + turnNotes, + } as unknown as TurnContext; +} + +function run(turnNotes: string, turnHints: string[], toolMode: boolean) { + return runTurnModel({ + userId: "u1", + locale: "en", + signal: new AbortController().signal, + conversationId: "c1", + ctx: ctx(turnNotes), + chain: [] as never, + toolMode, + ledger: { + settleRound: vi.fn(async () => {}), + reserveRound: vi.fn(async () => true), + close: vi.fn(async () => {}), + } as never, + emitter: { emit: () => {}, aborted: () => false }, + turnHints, + message: "hi", + payer: "user", + conversationWindowSet: false, + }).catch(() => null); +} + +beforeEach(() => { + m.loop.mockReset().mockRejectedValue(new Error("stop after capture")); + m.stream.mockReset().mockRejectedValue(new Error("stop after capture")); +}); + +describe("runTurnModel keeps the system prompt stable (#1217)", () => { + it("on the tool path", async () => { + await run("OPENER HINT for this turn: A", [], true); + await run("OPENER HINT for this turn: B", ["FOLLOW-UP: the table"], true); + const [first, second] = m.loop.mock.calls.map( + ([args]) => + args as { system: string; messages: Array<{ content: string }> }, + ); + expect(sha(first!.system)).toBe(sha(second!.system)); + expect(first!.system).not.toContain("OPENER HINT"); + expect(second!.system).not.toContain("FOLLOW-UP"); + expect(first!.messages[0]!.content).toContain( + "OPENER HINT for this turn: A", + ); + expect(second!.messages[0]!.content).toContain("FOLLOW-UP: the table"); + }); + + it("on the streaming no-tools path", async () => { + await run("OPENER HINT for this turn: A", [], false); + await run("OPENER HINT for this turn: B", ["FOLLOW-UP: the table"], false); + const params = m.stream.mock.calls.map( + ([args]) => + ( + args as { + params: { system: string; messages: Array<{ content: string }> }; + } + ).params, + ); + expect(params[0]!.system).toBe("STABLE SYSTEM"); + expect(params[1]!.system).toBe("STABLE SYSTEM"); + const users = params.map((p) => JSON.stringify(p.messages)); + expect(users[0]).toContain("OPENER HINT for this turn: A"); + expect(users[1]).toContain("FOLLOW-UP: the table"); + }); +}); diff --git a/src/lib/ai/coach/turn/addenda/index.ts b/src/lib/ai/coach/turn/addenda/index.ts index 20f70cf04..6efb8ce3d 100644 --- a/src/lib/ai/coach/turn/addenda/index.ts +++ b/src/lib/ai/coach/turn/addenda/index.ts @@ -31,3 +31,16 @@ export function buildDialogAddenda( .filter((block) => block.length > 0) .join("\n\n"); } + +/** + * v1.43 (#1217) — the table rules alone (how a table was fetched, how to + * follow up on one). They apply once the conversation holds a table, which can + * change mid-turn, so they travel as a user-side note rather than switching the + * system prompt: a switched system prompt rewrites the provider's cached + * prefix on the costliest round of the turn. + */ +export function buildTableRulesAddendum(locale: Locale): string { + return [methodAddendum(locale), followUpsAddendum(locale)] + .filter((block) => block.length > 0) + .join("\n\n"); +} diff --git a/src/lib/ai/coach/turn/context.ts b/src/lib/ai/coach/turn/context.ts index b7e3ce1c0..d6dd8584e 100644 --- a/src/lib/ai/coach/turn/context.ts +++ b/src/lib/ai/coach/turn/context.ts @@ -13,7 +13,10 @@ import type { CoachScope } from "@/lib/ai/coach/types"; import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared"; import { instructionLocale } from "@/lib/ai/prompts/output-language"; import { storeDeterministicFacts } from "@/lib/ai/coach/facts"; -import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; +import { + buildCoachTurnNotes, + getCoachSystemPrompt, +} from "@/lib/ai/coach/system-prompt"; import { openerArchetypeHint, shouldUseNameForTurn, @@ -69,6 +72,11 @@ export interface TurnContext { workoutEvidence: Record | null; systemPrompt: string; userPrompt: string; + /** + * v1.43 (#1217) — the lines that change every turn (opener hint, name + * clause), for the user message. Empty when none apply. + */ + turnNotes: string; } export async function assembleTurnContext(args: { @@ -150,12 +158,11 @@ export async function assembleTurnContext(args: { firstName != null && shouldUseNameForTurn(`${userId}:${turnIndex}`), openerHint: openerArchetypeHint(`${userId}:${turnIndex}`, locale), }; - const baseSystemPrompt = getCoachSystemPrompt( - locale, - coachPrefs, - aboutMe, - coachPersonalization, - ); + // v1.43 (#1217) — the system prompt is stable per person; the per-turn + // personalization travels in the user message (`turnNotes`), so the + // provider's cached prefix survives from one turn to the next. + const baseSystemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe); + const turnNotes = buildCoachTurnNotes(locale, coachPersonalization); const turnContext = buildCoachTurnContext({ priorTurns, priorSummary, @@ -240,5 +247,6 @@ export async function assembleTurnContext(args: { workoutEvidence, systemPrompt, userPrompt, + turnNotes, }; } diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index f8c7996f7..b39c3ffb1 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -80,7 +80,7 @@ import { type PriorResultTurn, } from "@/lib/ai/coach/results/refs"; -import { buildDialogAddenda } from "./addenda"; +import { buildDialogAddenda, buildTableRulesAddendum } from "./addenda"; import type { TurnLedger } from "./budget"; import type { TurnReasoning } from "./reasoning"; import type { TurnChain } from "./chain"; @@ -228,7 +228,7 @@ export function buildTurnResults( } } -/** v1.39.4 — server-authored lines appended to the system prompt. */ +/** v1.39.4 — server-authored lines appended to a prompt block. */ function appendBlocks(base: string, blocks: string[]): string { const extra = blocks.filter((block) => block.length > 0); return extra.length > 0 ? [base, ...extra].join("\n\n") : base; @@ -374,28 +374,35 @@ export async function runTurnModel(args: { reach: ctx.reach, prior: args.priorResults ?? [], }); - const requestWith = (tableRules: boolean) => - buildCoachToolRequest({ - systemPrompt: ctx.systemPrompt, - toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [ - buildDialogAddenda(locale, { tableRules }), - ...args.turnHints, - ]), - focusHint: renderFocusHint(effectiveScope?.sources), - workoutEvidence, - dataInventory: renderDataInventory(inventory), - priorResults: renderPriorResultRefs(priorResults), - guidedBlock: turnContext.guidedBlock, - transcript: turnContext.transcript, - languageName: LANGUAGE_NAMES[locale], - ...(memory ? { memoryBlock: memory.text } : {}), - }); - // The recheck and follow-up rules are about tables: they ride the - // prompt once the conversation holds one, or from the round after this - // turn produced its first. - const withTables = requestWith(true); - const toolRequest = - priorResults.length > 0 ? withTables : requestWith(false); + // The recheck and follow-up rules are about tables: they ride the turn + // once the conversation holds one, or from the round after this turn + // produced its first. v1.43 (#1217) — like every other line that can + // change from one turn or round to the next (the opener hint, the name + // clause, a resolved follow-up), they travel on the user side, so the + // system prompt and the tools stay one cached prefix for the whole + // conversation. + const tableRules = buildTableRulesAddendum(locale); + const toolRequest = buildCoachToolRequest({ + systemPrompt: ctx.systemPrompt, + toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [ + buildDialogAddenda(locale, { tableRules: false }), + ]), + focusHint: renderFocusHint(effectiveScope?.sources), + workoutEvidence, + dataInventory: renderDataInventory(inventory), + priorResults: renderPriorResultRefs(priorResults), + guidedBlock: turnContext.guidedBlock, + transcript: turnContext.transcript, + languageName: LANGUAGE_NAMES[locale], + turnNotes: [ + ...(priorResults.length > 0 ? [tableRules] : []), + ctx.turnNotes, + ...args.turnHints, + ] + .filter((block) => block.length > 0) + .join("\n\n"), + ...(memory ? { memoryBlock: memory.text } : {}), + }); // v1.39.4 — every call this turn settled, by its turn-wide index, for // the result tables. Only this turn's own calls ever reach them. const settled: SettledToolCall[] = []; @@ -420,7 +427,7 @@ export async function runTurnModel(args: { userId, providers: chain, system: toolRequest.system, - systemOnceTableShown: withTables.system, + ...(priorResults.length > 0 ? {} : { tableRulesOnceShown: tableRules }), messages: toolRequest.messages, tools: COACH_TURN_TOOL_DEFS, temperature: AI_BUDGETS.coach.temperature, @@ -631,10 +638,18 @@ export async function runTurnModel(args: { }); const summaries: string[] = []; // v1.41 — the memory block rides the user turn here too, ahead of the - // snapshot, fenced like the rest of the person's own context. - const user = memory - ? `${memory.text}\n\n${ctx.userPrompt}` - : ctx.userPrompt; + // snapshot, fenced like the rest of the person's own context. v1.43 + // (#1217) — so do this turn's own lines (opener hint, name clause, a + // resolved follow-up), which used to ride the system prompt and rewrote + // its cached prefix on every turn. + const user = [ + memory?.text, + ctx.turnNotes, + ...args.turnHints, + ctx.userPrompt, + ] + .filter((block): block is string => Boolean(block)) + .join("\n\n"); const fallback = await runStreamingRawCompletionWithFallback({ surface: "coach", userId, @@ -644,7 +659,7 @@ export async function runTurnModel(args: { }, params: { ...singleUserTurn({ - system: appendBlocks(ctx.systemPrompt, args.turnHints), + system: ctx.systemPrompt, user, temperature: AI_BUDGETS.coach.temperature, maxTokens: AI_BUDGETS.coach.maxTokens, From 6f673a7b841a5d79b15703ff5c97ed489f42e9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:43:40 +0200 Subject: [PATCH 026/127] feat(ai): resolve an AI usage level per record and publish it as ai.usage Three levels (frugal, standard, full) with an operator ceiling and default and a per-account choice within them, resolved in the capability layer for the record and published on GET /api/auth/me as ai.usage: the level in effect, the stored choice, the bounds, why (user, default, admin_cap), the three options with whether each is allowed and an estimate of tokens per day scaled from the record's last seven full days of coach_usage, and the measured mean. A delegate sees the level, never the owner's spend. PUT /api/auth/me/ai-usage stores the account's own choice or null for the default, refusing a level above the ceiling with 422 ai.usage.aboveCeiling and meta.maxLevel. The new supplementLabel capability needs standard or above; below it the capability is unavailable with the new usage_level reason, refused with 403 ai.usage.levelTooLow and meta.requiredLevel. Refs #1217 --- docs/api/openapi.yaml | 110 ++++--- prisma/schema.prisma | 10 +- .../ai-egress-capability-guard.test.ts | 27 ++ src/__tests__/contract-stub-inventory.test.ts | 3 +- .../ai-capability-route-inventory.test.ts | 47 +++ .../auth/me/ai-usage/__tests__/route.test.ts | 206 ++++++++++++ src/app/api/auth/me/ai-usage/route.ts | 57 +++- src/hooks/use-ai-capability.ts | 20 ++ src/lib/__tests__/ai-refusal-envelope.test.ts | 13 + src/lib/ai/__tests__/usage-level.test.ts | 204 ++++++++++++ .../ai/capabilities/__tests__/resolve.test.ts | 58 +++- src/lib/ai/capabilities/load.ts | 46 ++- src/lib/ai/capabilities/refusal.ts | 16 + src/lib/ai/capabilities/resolve.ts | 16 + src/lib/ai/capabilities/types.ts | 30 ++ src/lib/ai/capabilities/usage.ts | 222 +++++++++++++ src/lib/ai/usage-level.ts | 311 ++++++++++++++++++ ...onfig-change-is-not-a-health-event.test.ts | 3 + src/lib/openapi/error-codes.ts | 3 + src/lib/openapi/routes/ai-usage.ts | 8 +- src/lib/openapi/routes/profile.ts | 6 +- src/lib/openapi/routes/shared.ts | 10 +- 22 files changed, 1342 insertions(+), 84 deletions(-) create mode 100644 src/app/api/auth/me/ai-usage/__tests__/route.test.ts create mode 100644 src/lib/ai/__tests__/usage-level.test.ts create mode 100644 src/lib/ai/capabilities/usage.ts create mode 100644 src/lib/ai/usage-level.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 43407e243..ecba71d5f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23992,7 +23992,6 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "501": *a30 /api/medications/{id}/ingredients: put: tags: @@ -35504,41 +35503,42 @@ components: (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own may carry no code at all. ai — - ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.invalid, - ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, - aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — - analytics.invalid_query; anamnesis — anamnesis.auditC.invalid, anamnesis.auditC.rateLimited, - anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue, - anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.admin.required, - auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, - auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required, - auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.stepup.required, - auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, - backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large; - biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded, - coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, - coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, - coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, - coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; - consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, - cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, - cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; - cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — - disable-coach.body.invalid_json; dawarich — dawarich.invalid, dawarich.rate_limited; day — day.invalid; - demo — demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, - documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, - documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, - documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, - documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, - documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, - documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, - documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, - documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, - documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, - documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, - documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, - documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, - documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, + ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, + ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider + — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, + aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — + anamnesis.auditC.invalid, anamnesis.auditC.rateLimited, anamnesis.fact.conflict, + anamnesis.fact.currentExists, anamnesis.fact.invalidValue, anamnesis.lifeConfirm.invalid, + anamnesis.lifeSections.invalid; auth — auth.admin.required, auth.already_authenticated, + auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, + auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, + auth.session.insecure_transport, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — + backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active, + backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — + body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle, + coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, + coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, + coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — + coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — + cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, + cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, + cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — + cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; + dawarich — dawarich.invalid, dawarich.rate_limited; day — day.invalid; demo — demo.readOnly; documents — + documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, + documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, + documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, + documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, + documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, + documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, + documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed, + documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, + documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, + documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, + documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, + documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, + documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -35625,19 +35625,27 @@ components: capability: description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, - `documentAi`, `labsOcr`, `medicationExtract`)." + `documentAi`, `labsOcr`, `medicationExtract`, `supplementLabel`)." type: string reason: description: "On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own - typed code where it has one, otherwise `ai.provider.none` (422), `consent_required` → - `consent.ai.required`, `check_failed` → `ai.unavailable` (503)." + typed code where it has one, otherwise `ai.provider.none` (422), `usage_level` → `ai.usage.levelTooLow` + with `requiredLevel`, `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` + (503)." type: string module: description: "Beside `module.disabled`: the module that is off. On an AI refusal it is the capability's owning module." type: string + requiredLevel: + description: "Beside `ai.usage.levelTooLow` (v1.43): the lowest AI usage level at which the capability is offered." + type: string + enum: + - frugal + - standard + - full additionalProperties: {} required: - data @@ -47738,6 +47746,7 @@ components: - not_permitted_for_record - module_disabled - user_disabled + - usage_level - no_provider - consent_required description: "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is @@ -47747,9 +47756,10 @@ components: admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. - `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider - chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does - not know treats the capability as unavailable." + `usage_level` — the record's AI usage level (`ai.usage.level`, v1.43) is below what the capability needs; today + only `supplementLabel` on `frugal`. `no_provider` — no configured provider can serve this capability's input. + `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a + client that meets a value it does not know treats the capability as unavailable." AiCapabilityState: type: object properties: @@ -47763,10 +47773,10 @@ components: onDeviceAllowed: type: boolean description: "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, - `module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the - device too. True otherwise, since a missing server provider or a missing consent for server egress do not - concern a model that runs on the device. Resolved here so the client never decides which reasons apply off - the server." + `module_disabled`, `user_disabled`, `usage_level` and `check_failed`: the operator's and the person's + decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for + server egress do not concern a model that runs on the device. Resolved here so the client never decides + which reasons apply off the server." required: - available - reason @@ -55227,6 +55237,7 @@ components: - not_permitted_for_record - module_disabled - user_disabled + - usage_level - no_provider - consent_required required: @@ -79968,6 +79979,9 @@ components: medicationExtract: description: The "medicationExtract" capability. Covered by the operator's "documentAi" switch. $ref: "#/components/schemas/AiCapabilityState" + supplementLabel: + description: The "supplementLabel" capability. Covered by the operator's "documentAi" switch. + $ref: "#/components/schemas/AiCapabilityState" required: - coach - briefing @@ -79979,6 +79993,7 @@ components: - documentAi - labsOcr - medicationExtract + - supplementLabel additionalProperties: false description: Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted @@ -79987,7 +80002,8 @@ components: paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. - `medicationExtract` — turning a typed medication description into a schedule. + `medicationExtract` — turning a typed medication description into a schedule. `supplementLabel` (v1.43) — + reading a supplement's label photo into its ingredients; unavailable on the `frugal` usage level. AiProviderState: type: object properties: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6328b220d..5bf61297a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -622,7 +622,7 @@ model User { // `useAuth()` hook. disableCoach Boolean @default(false) @map("disable_coach") - /// @pending-consumer(#v143-B1) The account's own AI usage level (#1217). + /// The account's own AI usage level (#1217). /// NULL follows the instance default (`AppSettings.aiUsageDefaultLevel`); /// a stored level above `AppSettings.aiUsageMaxLevel` is kept but capped /// at read time. Written by `PUT /api/auth/me/ai-usage`, resolved in @@ -5268,11 +5268,13 @@ model AppSettings { // `aiSyncRegenGapHours` the minimum gap between status-card regenerations // after a sync at `standard` (NULL = env `AI_SYNC_REGEN_GAP_HOURS`, else 6). // Edited through `PUT /api/admin/settings/assistant-flags` (`aiUsage`). - /// @pending-consumer(#v143-B1) Instance ceiling for the per-account AI usage level. + /// Instance ceiling for the per-account AI usage level (resolved in + /// `src/lib/ai/capabilities/usage.ts`). aiUsageMaxLevel AiUsageLevel @default(STANDARD) @map("ai_usage_max_level") - /// @pending-consumer(#v143-B1) Level for accounts without their own choice. + /// Level for accounts without their own choice. aiUsageDefaultLevel AiUsageLevel @default(STANDARD) @map("ai_usage_default_level") - /// @pending-consumer(#v143-B1) Sync regeneration gap in hours (1..48); NULL = env or 6. + /// Sync regeneration gap in hours (1..48); NULL = env or 6. Read by the + /// status invalidator at the `standard` level. aiSyncRegenGapHours Int? @map("ai_sync_regen_gap_hours") // v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts index 3b06191b0..7f411cc63 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -19,6 +19,7 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { AI_CAPABILITY_KEYS } from "@/lib/ai/capabilities/types"; import { DOCUMENT_AI_RUN_KINDS } from "@/lib/documents/ai-runs/types"; import type { runStatusCompletion } from "@/lib/insights/status-provider"; @@ -283,3 +284,29 @@ describe("the status chokepoint's capability argument", () => { expect(withCapability.capability).toBe("statusText"); }); }); + +/** + * v1.43 (#1217) — capabilities whose model call has not landed yet. The + * capability is published (clients can render its state) before the route + * that reads a label photo exists; until then that route must not reach a + * provider at all. When it does, it becomes an egress site above, re-checking + * its capability at the wire like every document read, and leaves this list. + */ +const CAPABILITY_EGRESS_PENDING: Record = { + supplementLabel: "app/api/medications/[id]/ingredients/extract/route.ts", +}; + +describe("capabilities whose model call is still to come", () => { + it.each(Object.entries(CAPABILITY_EGRESS_PENDING))( + "%s is a published capability whose route does not call a model yet", + (key, rel) => { + expect(AI_CAPABILITY_KEYS as readonly string[]).toContain(key); + const text = code(rel); + expect( + EGRESS_CALL.test(text), + `${rel} calls a provider now: list it in EGRESS_SITES with its re-check and drop it here`, + ).toBe(false); + expect(/\bnotImplemented\s*\(/.test(text)).toBe(true); + }, + ); +}); diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index 102729345..361a47866 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -39,7 +39,6 @@ const CONTRACT_STUBS: Readonly> = { ], "anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"], "auth/claim/sso/route.ts": ["profile_claim.sso.start"], - "auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"], "biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"], "dawarich/connect/route.ts": ["dawarich.connect"], "dawarich/disconnect/route.ts": ["dawarich.disconnect"], @@ -64,7 +63,7 @@ const CONTRACT_STUBS: Readonly> = { }; /** The size of the register when it was written; see the header. */ -const REGISTER_SIZE = 24; +const REGISTER_SIZE = 23; function routeFiles(): string[] { return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] }) diff --git a/src/app/api/__tests__/ai-capability-route-inventory.test.ts b/src/app/api/__tests__/ai-capability-route-inventory.test.ts index 2bb7afd8c..7e53af027 100644 --- a/src/app/api/__tests__/ai-capability-route-inventory.test.ts +++ b/src/app/api/__tests__/ai-capability-route-inventory.test.ts @@ -165,6 +165,53 @@ describe("AI capability route inventory", () => { }); }); +/** + * Capabilities no route asks, each with its reason. Every other capability is + * asked by at least one route on `AI_ROUTES`, so a key cannot be added to the + * table and forgotten at the edge. + * + * - `reactionLines`: written by a job after a new reading arrives, never on + * request; the job asks `aiCapabilityForJob`. + * - `supplementLabel` (v1.43, #1217): its route + * (`medications/[id]/ingredients/extract`) is a contract stub until the + * supplement package fills it. The check below fails the moment the stub + * answers anything but 501, so the entry has to move to `AI_ROUTES` with + * the gate. + */ +const CAPABILITIES_WITHOUT_ROUTE: Readonly< + Partial> +> = { + reactionLines: "job only", + supplementLabel: "src/app/api/medications/[id]/ingredients/extract/route.ts", +}; + +describe("every capability reaches a route", () => { + it("is asked by an AI route, or named with its reason", () => { + const asked = new Set( + Object.values(AI_ROUTES).flatMap((entry) => [...entry.capabilities]), + ); + const orphaned = AI_CAPABILITY_KEYS.filter( + (key) => !asked.has(key) && !(key in CAPABILITIES_WITHOUT_ROUTE), + ); + expect(orphaned).toEqual([]); + const stale = Object.keys(CAPABILITIES_WITHOUT_ROUTE).filter((key) => + asked.has(key as (typeof AI_CAPABILITY_KEYS)[number]), + ); + expect(stale, "asked by a route now: drop it from the list").toEqual([]); + }); + + it("knows supplementLabel, and its route is still the stub", () => { + expect(AI_CAPABILITY_KEYS).toContain("supplementLabel"); + const path = CAPABILITIES_WITHOUT_ROUTE.supplementLabel!; + expect(ROUTES).toContain(path); + const text = code(path); + expect( + /\bnotImplemented\s*\(/.test(text) && !ANY_GATE.test(text), + `${path} is no longer a stub: gate it with requireAiCapability("supplementLabel") and list it on AI_ROUTES`, + ).toBe(true); + }); +}); + /** The route that queues each kind of background run. */ const RUN_QUEUED_BY: Record = { DOCUMENT_INDEX: "src/app/api/documents/inbound/[id]/index/route.ts", diff --git a/src/app/api/auth/me/ai-usage/__tests__/route.test.ts b/src/app/api/auth/me/ai-usage/__tests__/route.test.ts new file mode 100644 index 000000000..71eb1b3af --- /dev/null +++ b/src/app/api/auth/me/ai-usage/__tests__/route.test.ts @@ -0,0 +1,206 @@ +/** + * `PUT /api/auth/me/ai-usage` (#1217): the account's own level, within the + * operator's ceiling. A level above it is refused with 422 + * `ai.usage.aboveCeiling` and `meta.maxLevel`, never capped silently; `null` + * returns to the instance default; the answer is the resolved `ai.usage`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const db = vi.hoisted(() => ({ + stored: null as string | null, + settings: { + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, + } as Record, + usage: [] as Array<{ totalTokens: number }>, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(async () => ({ + aiUsageLevel: db.stored, + timezone: "UTC", + })), + update: vi.fn(async (args: { data: { aiUsageLevel: string | null } }) => { + db.stored = args.data.aiUsageLevel; + return {}; + }), + }, + appSettings: { findUnique: vi.fn(async () => db.settings) }, + coachUsage: { findMany: vi.fn(async () => db.usage) }, + }, +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "UTC"), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const put = PUT as unknown as (req: Request) => Promise; + +function req(body: unknown): Request { + return new Request("http://localhost/api/auth/me/ai-usage", { + method: "PUT", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + db.stored = null; + db.settings = { + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, + }; + db.usage = []; + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now() + 3_600_000, + } as never); +}); + +describe("PUT /api/auth/me/ai-usage", () => { + it("stores a level within the ceiling and answers the resolved state", async () => { + db.usage = [ + { totalTokens: 90_000 }, + { totalTokens: 110_000 }, + { totalTokens: 100_000 }, + ]; + const res = await put(req({ level: "frugal" })); + expect(res.status).toBe(200); + expect(vi.mocked(prisma.user.update).mock.calls[0]![0]).toEqual({ + where: { id: "user-1" }, + data: { aiUsageLevel: "FRUGAL" }, + }); + const { data } = await res.json(); + expect(data).toMatchObject({ + level: "frugal", + preference: "frugal", + defaultLevel: "standard", + maxLevel: "standard", + source: "user", + measuredTokensPerDay: 100_000, + measuredDays: 3, + }); + expect(data.options).toEqual([ + { level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 }, + { level: "standard", allowed: true, estimatedTokensPerDay: 200_000 }, + { level: "full", allowed: false, estimatedTokensPerDay: 666_667 }, + ]); + expect(auditLog).toHaveBeenCalledWith( + "user.aiUsage.update", + expect.objectContaining({ + userId: "user-1", + details: { previous: null, next: "frugal" }, + }), + ); + }); + + it("refuses a level above the ceiling with the ceiling named", async () => { + const res = await put(req({ level: "full" })); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta).toMatchObject({ + errorCode: "ai.usage.aboveCeiling", + maxLevel: "standard", + }); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("accepts full once the operator allows it", async () => { + db.settings = { ...db.settings, aiUsageMaxLevel: "FULL" }; + const res = await put(req({ level: "full" })); + expect(res.status).toBe(200); + expect((await res.json()).data.level).toBe("full"); + }); + + it("returns to the instance default on null", async () => { + db.stored = "FRUGAL"; + const res = await put(req({ level: null })); + expect(res.status).toBe(200); + expect(vi.mocked(prisma.user.update).mock.calls[0]![0].data).toEqual({ + aiUsageLevel: null, + }); + const { data } = await res.json(); + expect(data).toMatchObject({ + level: "standard", + preference: null, + source: "default", + }); + // Fewer than three days of history: no estimate. + expect( + data.options.every( + (o: { estimatedTokensPerDay: number | null }) => + o.estimatedTokensPerDay === null, + ), + ).toBe(true); + }); + + it.each([ + [{ level: "lavish" }], + [{}], + [{ level: "frugal", extra: true }], + [{ level: 1 }], + ])("refuses %j as invalid", async (body) => { + const res = await put(req(body)); + expect(res.status).toBe(422); + expect((await res.json()).meta.errorCode).toBe("ai.usage.invalid"); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses the 31st change in an hour", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + const res = await put(req({ level: "frugal" })); + expect(res.status).toBe(429); + expect((await res.json()).meta.errorCode).toBe("ai.usage.rateLimited"); + expect(vi.mocked(checkRateLimit).mock.calls[0]!.slice(1)).toEqual([ + 30, + 60 * 60 * 1000, + ]); + }); + + it("refuses a caller without a session", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await put(req({ level: "frugal" })); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/auth/me/ai-usage/route.ts b/src/app/api/auth/me/ai-usage/route.ts index 1ceeb2d7d..7d5fd3d57 100644 --- a/src/app/api/auth/me/ai-usage/route.ts +++ b/src/app/api/auth/me/ai-usage/route.ts @@ -10,9 +10,22 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; +import { + loadAiUsageSettings, + loadAiUsageState, +} from "@/lib/ai/capabilities/usage"; +import { usageLevelRank, usageLevelToDb } from "@/lib/ai/usage-level"; import { aiUsagePutSchema } from "@/lib/validations/ai-usage"; export const dynamic = "force-dynamic"; @@ -38,5 +51,43 @@ export const PUT = apiHandler(async (request: NextRequest) => { }); } - return notImplemented("auth.me.ai_usage.put"); + const { level } = parsed.data; + const settings = await loadAiUsageSettings(); + if ( + level !== null && + usageLevelRank(level) > usageLevelRank(settings.maxLevel) + ) { + annotate({ + action: { name: "auth.me.ai_usage.put.above_ceiling" }, + meta: { level, maxLevel: settings.maxLevel }, + }); + return apiError( + "This level is above the highest one the server allows", + 422, + { errorCode: "ai.usage.aboveCeiling", maxLevel: settings.maxLevel }, + ); + } + + const previous = await prisma.user.findUnique({ + where: { id: user.id }, + select: { aiUsageLevel: true }, + }); + await prisma.user.update({ + where: { id: user.id }, + data: { aiUsageLevel: level === null ? null : usageLevelToDb(level) }, + }); + await auditLog("user.aiUsage.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + previous: previous?.aiUsageLevel?.toLowerCase() ?? null, + next: level, + }, + }); + annotate({ + action: { name: "auth.me.ai_usage.put" }, + meta: { level: level ?? "default" }, + }); + + return apiSuccess(await loadAiUsageState(user.id, { withMeasurement: true })); }); diff --git a/src/hooks/use-ai-capability.ts b/src/hooks/use-ai-capability.ts index 156115a4f..497c27e3d 100644 --- a/src/hooks/use-ai-capability.ts +++ b/src/hooks/use-ai-capability.ts @@ -9,6 +9,7 @@ import type { } from "@/lib/ai/capabilities/types"; import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout"; import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve"; +import type { AiUsageState } from "@/lib/ai/usage-level"; /** * The web reader for the `ai` block on `GET /api/auth/me`. @@ -85,6 +86,25 @@ function useCoachReasoningInner(): CoachReasoningState | null { return user?.coachReasoning ?? null; } +/** + * v1.43 (#1217) — the record's AI usage level (`ai.usage` on + * `GET /api/auth/me`): the level in effect, the person's choice, the + * operator's bounds, the three options with their estimates. `null` while it + * is not known or on a server that does not publish it; the setting is then + * not shown. Render from it; never compute an estimate or an allowance here. + */ +export function useAiUsage(): AiUsageState | null { + const hasClient = useQueryClientMounted(); + if (!hasClient) return null; + // eslint-disable-next-line react-hooks/rules-of-hooks + return useAiUsageInner(); +} + +function useAiUsageInner(): AiUsageState | null { + const { user } = useAuth(); + return user?.ai?.usage ?? null; +} + /** * The server's answer for one capability, or `null` while there is none yet * (`/me` loading, no signed-in account, no query client). For the few places diff --git a/src/lib/__tests__/ai-refusal-envelope.test.ts b/src/lib/__tests__/ai-refusal-envelope.test.ts index a21e8f390..d363ede4f 100644 --- a/src/lib/__tests__/ai-refusal-envelope.test.ts +++ b/src/lib/__tests__/ai-refusal-envelope.test.ts @@ -47,6 +47,7 @@ const CONTRACT: Record = { not_permitted_for_record: [403, "ai.record.notPermitted"], module_disabled: [403, "module.disabled"], user_disabled: [403, "module.disabled"], + usage_level: [403, "ai.usage.levelTooLow"], no_provider: [422, "ai.provider.none"], consent_required: [403, "consent.ai.required"], }; @@ -75,6 +76,18 @@ describe("the refusal table", () => { } }); + it("names the level a usage refusal needs (#1217)", () => { + expect(aiRefusal("supplementLabel", "usage_level")).toEqual({ + status: 403, + meta: { + errorCode: "ai.usage.levelTooLow", + capability: "supplementLabel", + reason: "usage_level", + requiredLevel: "standard", + }, + }); + }); + it("keeps the code shipped clients know for the Coach", () => { expect(aiRefusal("coach", "operator_disabled").meta.errorCode).toBe( "assistant.disabled.coach", diff --git a/src/lib/ai/__tests__/usage-level.test.ts b/src/lib/ai/__tests__/usage-level.test.ts new file mode 100644 index 000000000..7b06d9c0c --- /dev/null +++ b/src/lib/ai/__tests__/usage-level.test.ts @@ -0,0 +1,204 @@ +/** + * #1217 — the AI usage level: resolution, effects and the estimate. + * + * The resolution matrix is exhaustive over every stored choice (three levels + * and none), every default and every ceiling: 4 x 3 x 3 = 36 cases, each + * checked against a rule written out independently of the resolver. + */ +import { describe, expect, it } from "vitest"; + +import { + AI_USAGE_LEVELS, + AI_USAGE_LEVEL_EFFECTS, + buildAiUsageState, + capUsageLevel, + estimateTokensPerDay, + parseSyncRegenGapHours, + resolveAiUsageLevel, + statusRegenGapHours, + usageLevelFromDb, + usageLevelToDb, + type AiUsageLevel, +} from "@/lib/ai/usage-level"; + +const RANK: Record = { frugal: 0, standard: 1, full: 2 }; + +describe("resolveAiUsageLevel — every choice x default x ceiling", () => { + const choices: Array = [null, ...AI_USAGE_LEVELS]; + let cases = 0; + for (const preference of choices) { + for (const defaultLevel of AI_USAGE_LEVELS) { + for (const maxLevel of AI_USAGE_LEVELS) { + cases++; + it(`choice ${preference ?? "none"}, default ${defaultLevel}, ceiling ${maxLevel}`, () => { + const got = resolveAiUsageLevel({ + preference, + defaultLevel, + maxLevel, + }); + if (preference === null) { + // No choice: the default, never above the ceiling. + const expected = + RANK[defaultLevel] > RANK[maxLevel] ? maxLevel : defaultLevel; + expect(got).toEqual({ level: expected, source: "default" }); + } else if (RANK[preference] > RANK[maxLevel]) { + // A choice above the ceiling is capped, and says so. + expect(got).toEqual({ level: maxLevel, source: "admin_cap" }); + } else { + expect(got).toEqual({ level: preference, source: "user" }); + } + // Whatever the inputs, the result never passes the ceiling. + expect(RANK[got.level]).toBeLessThanOrEqual(RANK[maxLevel]); + }); + } + } + } + it("covered every combination", () => { + expect(cases).toBe(36); + }); +}); + +describe("what each level does", () => { + it("regenerates status cards after a sync: a day, the operator's gap, the hour", () => { + expect(statusRegenGapHours("frugal", "sync", 6)).toBe(24); + expect(statusRegenGapHours("standard", "sync", 6)).toBe(6); + expect(statusRegenGapHours("standard", "sync", 12)).toBe(12); + expect(statusRegenGapHours("full", "sync", 12)).toBe(1); + }); + + it("regenerates after a hand entry: six hours on frugal, the hour otherwise", () => { + expect(statusRegenGapHours("frugal", "manual", 6)).toBe(6); + expect(statusRegenGapHours("standard", "manual", 24)).toBe(1); + expect(statusRegenGapHours("full", "manual", 24)).toBe(1); + }); + + it("caps the status snapshot at 10 000 on frugal and 14 000 above", () => { + expect( + AI_USAGE_LEVELS.map((l) => AI_USAGE_LEVEL_EFFECTS[l].statusSnapshotChars), + ).toEqual([10_000, 14_000, 14_000]); + }); + + it("switches background reasoning and the automatic document read off on frugal only", () => { + for (const level of AI_USAGE_LEVELS) { + const effect = AI_USAGE_LEVEL_EFFECTS[level]; + expect(effect.backgroundReasoning, level).toBe(level !== "frugal"); + expect(effect.documentsAutoRead, level).toBe(level !== "frugal"); + } + }); + + it("orders the token factors with the levels, full at one", () => { + const factors = AI_USAGE_LEVELS.map( + (l) => AI_USAGE_LEVEL_EFFECTS[l].tokenFactor, + ); + expect(factors).toEqual([...factors].sort((a, b) => a - b)); + expect(AI_USAGE_LEVEL_EFFECTS.full.tokenFactor).toBe(1); + // Calibrated by the watch-day simulation: standard ~0.31 of full, + // frugal ~0.15, the nightly warm included. + expect(AI_USAGE_LEVEL_EFFECTS.standard.tokenFactor).toBe(0.3); + expect(AI_USAGE_LEVEL_EFFECTS.frugal.tokenFactor).toBe(0.15); + }); +}); + +describe("the estimate", () => { + it("is null without three days of history", () => { + for (const measuredDays of [0, 1, 2]) { + expect( + estimateTokensPerDay({ + measuredTokensPerDay: 100_000, + measuredDays, + from: "standard", + target: "full", + }), + ).toBeNull(); + } + expect( + estimateTokensPerDay({ + measuredTokensPerDay: null, + measuredDays: 7, + from: "standard", + target: "full", + }), + ).toBeNull(); + }); + + it("scales the measured use by the factor ratio, whole tokens", () => { + const at = (target: AiUsageLevel) => + estimateTokensPerDay({ + measuredTokensPerDay: 100_000, + measuredDays: 5, + from: "standard", + target, + }); + expect(at("standard")).toBe(100_000); + expect(at("full")).toBe(333_333); + expect(at("frugal")).toBe(50_000); + }); +}); + +describe("buildAiUsageState", () => { + it("publishes three options in ceiling order with allowance and estimate", () => { + const state = buildAiUsageState({ + preference: "full", + defaultLevel: "standard", + maxLevel: "standard", + measuredTokensPerDay: 50_000, + measuredDays: 7, + }); + expect(state.level).toBe("standard"); + expect(state.source).toBe("admin_cap"); + expect(state.preference).toBe("full"); + expect(state.options.map((o) => [o.level, o.allowed])).toEqual([ + ["frugal", true], + ["standard", true], + ["full", false], + ]); + expect(state.options.map((o) => o.estimatedTokensPerDay)).toEqual([ + 25_000, 50_000, 166_667, + ]); + expect(state.measuredTokensPerDay).toBe(50_000); + expect(state.measuredDays).toBe(7); + }); + + it("publishes no estimate without history", () => { + const state = buildAiUsageState({ + preference: null, + defaultLevel: "standard", + maxLevel: "full", + measuredTokensPerDay: null, + measuredDays: 0, + }); + expect(state.options.every((o) => o.estimatedTokensPerDay === null)).toBe( + true, + ); + expect(state.source).toBe("default"); + }); +}); + +describe("spelling and parsing", () => { + it("round-trips the database spelling", () => { + for (const level of AI_USAGE_LEVELS) { + expect(usageLevelFromDb(usageLevelToDb(level))).toBe(level); + } + expect(usageLevelFromDb(null)).toBeNull(); + expect(usageLevelFromDb("LAVISH")).toBeNull(); + }); + + it("caps a level at a ceiling", () => { + expect(capUsageLevel("full", "standard")).toBe("standard"); + expect(capUsageLevel("frugal", "standard")).toBe("frugal"); + }); + + it.each([ + ["6", 6], + [12, 12], + ["48", 48], + ["0", null], + ["49", null], + ["2.5", null], + ["", null], + [undefined, null], + ["abc", null], + ])("parses the sync gap %s as %s", (raw, expected) => { + expect(parseSyncRegenGapHours(raw)).toBe(expected); + }); +}); diff --git a/src/lib/ai/capabilities/__tests__/resolve.test.ts b/src/lib/ai/capabilities/__tests__/resolve.test.ts index e631014bd..5a250991d 100644 --- a/src/lib/ai/capabilities/__tests__/resolve.test.ts +++ b/src/lib/ai/capabilities/__tests__/resolve.test.ts @@ -58,6 +58,7 @@ function baseline(): AiCapabilityInputs { }, activeConsentKinds: new Set(), recordKind: "self", + usageLevel: "full", }; } @@ -103,6 +104,11 @@ const PERTURB: Record, Perturb> = { inputs.moduleAccess = access; return true; }, + usage_level: (inputs, key) => { + if (AI_CAPABILITIES[key].minUsageLevel === undefined) return false; + inputs.usageLevel = "frugal"; + return true; + }, no_provider: (inputs) => { inputs.provider = { ...inputs.provider, entries: [], managedBy: null }; return true; @@ -429,16 +435,18 @@ describe("on-device permission", () => { not_permitted_for_record: false, module_disabled: false, user_disabled: false, + usage_level: false, no_provider: true, consent_required: true, }; for (const reason of PERTURBABLE) { const inputs = baseline(); - PERTURB[reason](inputs, "briefing"); - expect( - resolveAiCapability("briefing", inputs).onDeviceAllowed, - reason, - ).toBe(expected[reason]); + // `usage_level` only applies to a capability with a minimum level. + const key = reason === "usage_level" ? "supplementLabel" : "briefing"; + PERTURB[reason](inputs, key); + expect(resolveAiCapability(key, inputs).onDeviceAllowed, reason).toBe( + expected[reason], + ); } expect(resolveAiCapability("briefing", null).onDeviceAllowed).toBe( expected.check_failed, @@ -486,3 +494,43 @@ describe("the provider block", () => { }); }); }); + +describe("the AI usage level (#1217)", () => { + it("closes only the capabilities that need a higher level", () => { + const inputs = baseline(); + inputs.usageLevel = "frugal"; + const resolved = resolveAiCapabilities(inputs); + for (const key of AI_CAPABILITY_KEYS) { + const needsMore = AI_CAPABILITIES[key].minUsageLevel !== undefined; + expect(resolved[key].reason, key).toBe(needsMore ? "usage_level" : null); + } + }); + + it("offers the label photo from standard up", () => { + for (const [level, available] of [ + ["frugal", false], + ["standard", true], + ["full", true], + ] as const) { + const inputs = baseline(); + inputs.usageLevel = level; + expect( + resolveAiCapability("supplementLabel", inputs).available, + level, + ).toBe(available); + } + }); + + it("ranks after the person's opt-out and before the provider", () => { + const inputs = baseline(); + inputs.usageLevel = "frugal"; + inputs.provider = { ...inputs.provider, entries: [], managedBy: null }; + expect(resolveAiCapability("supplementLabel", inputs).reason).toBe( + "usage_level", + ); + inputs.moduleAccess = { ...inputs.moduleAccess, nutrients: "disabled" }; + expect(resolveAiCapability("supplementLabel", inputs).reason).toBe( + "module_disabled", + ); + }); +}); diff --git a/src/lib/ai/capabilities/load.ts b/src/lib/ai/capabilities/load.ts index 9e43dc842..e277667ea 100644 --- a/src/lib/ai/capabilities/load.ts +++ b/src/lib/ai/capabilities/load.ts @@ -9,7 +9,9 @@ * memoised reads the account payload and every module gate use; * - provider presence: the record's credential row and the operator's * provider columns (`probeProviderChain`), presence only; - * - consent: the kinds of the record's active receipts, one indexed read. + * - consent: the kinds of the record's active receipts, one indexed read; + * - the record's AI usage level (v1.43): the operator's bounds (memoised) + * and one point read of the record's choice. * * Any failure resolves to `null`, which the resolver turns into * `check_failed` for every capability. Failing closed is safe because no data @@ -38,6 +40,7 @@ import { type AiRecordKind, } from "./resolve"; import type { AiCapabilities } from "./types"; +import { loadAiUsageState, loadRecordUsageLevel } from "./usage"; /** Whose record, seen by whom, through which grant. */ export interface AiCapabilityScope { @@ -89,14 +92,21 @@ export function loadAiCapabilityInputs( `ai-capability-inputs:${scopeKey(scope)}`, async () => { try { - const [switches, modules, availability, provider, activeConsentKinds] = - await Promise.all([ - loadAssistantSwitches(), - resolveModuleMap(scope.recordId), - getOperatorModuleAvailability(), - probeProviderChain(scope.recordId, scope.authority), - loadActiveConsentKinds(scope.recordId), - ]); + const [ + switches, + modules, + availability, + provider, + activeConsentKinds, + usageLevel, + ] = await Promise.all([ + loadAssistantSwitches(), + resolveModuleMap(scope.recordId), + getOperatorModuleAvailability(), + probeProviderChain(scope.recordId, scope.authority), + loadActiveConsentKinds(scope.recordId), + loadRecordUsageLevel(scope.recordId), + ]); if (switches === null) return null; const { moduleAccess } = buildModuleDisclosure( modules, @@ -110,6 +120,7 @@ export function loadAiCapabilityInputs( provider, activeConsentKinds, recordKind: scope.recordKind, + usageLevel, }; } catch { getEvent()?.addWarning( @@ -122,9 +133,22 @@ export function loadAiCapabilityInputs( ); } -/** The whole `ai` block for one scope. */ +/** + * The whole `ai` block for one scope, the record's AI usage level included + * (v1.43). The measured use and the estimates are published for one's own + * record only: a delegate sees which level a record runs at, not what its + * owner spends. A usage read that fails leaves `usage` out rather than + * failing the block; the capabilities already carry the level they resolved. + */ export async function loadAiCapabilities( scope: AiCapabilityScope, ): Promise { - return resolveAiBlock(await loadAiCapabilityInputs(scope)); + const [inputs, usage] = await Promise.all([ + loadAiCapabilityInputs(scope), + loadAiUsageState(scope.recordId, { + withMeasurement: scope.recordKind === "self", + }).catch(() => undefined), + ]); + const block = resolveAiBlock(inputs); + return usage ? { ...block, usage } : block; } diff --git a/src/lib/ai/capabilities/refusal.ts b/src/lib/ai/capabilities/refusal.ts index 0f6a44639..01c21d44f 100644 --- a/src/lib/ai/capabilities/refusal.ts +++ b/src/lib/ai/capabilities/refusal.ts @@ -8,12 +8,14 @@ */ import { MODULE_DISABLED_ERROR_CODE } from "@/lib/modules/gate"; import type { ModuleKey } from "@/lib/modules/registry"; +import type { AiUsageLevel } from "@/lib/ai/usage-level"; import { AI_CAPABILITIES, AI_PROVIDER_NONE_ERROR_CODE, AI_RECORD_NOT_PERMITTED_ERROR_CODE, AI_UNAVAILABLE_ERROR_CODE, + AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE, type AiCapabilityKey, type AiUnavailableReason, } from "./types"; @@ -33,6 +35,8 @@ export interface AiRefusalMeta { capability: AiCapabilityKey; reason: AiUnavailableReason; module?: ModuleKey; + /** Beside `ai.usage.levelTooLow`: the lowest level the capability needs. */ + requiredLevel?: AiUsageLevel; } /** The HTTP shape of one refusal. Pure; exported for the envelope test. */ @@ -72,6 +76,17 @@ export function aiRefusal( ...(module === null ? {} : { module }), }, }; + case "usage_level": + return { + status: 403, + meta: { + ...base, + errorCode: AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE, + ...(AI_CAPABILITIES[capability].minUsageLevel + ? { requiredLevel: AI_CAPABILITIES[capability].minUsageLevel } + : {}), + }, + }; case "no_provider": return { status: noProvider?.status ?? 422, @@ -99,6 +114,7 @@ const REFUSAL_MESSAGE: Record = { not_permitted_for_record: "AI work is not available for this record", module_disabled: "This AI feature's module is turned off for this record", user_disabled: "This AI feature is turned off in your settings", + usage_level: "This AI feature is not available at the current AI usage level", no_provider: "No AI provider is set up for this feature", consent_required: "AI consent is required for this feature", }; diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index 0152a7ac7..93a6f2341 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -21,6 +21,7 @@ import { isExternalProvider, isOperatorHeldProvider, } from "@/lib/ai/provider-egress"; +import { usageLevelAtLeast, type AiUsageLevel } from "@/lib/ai/usage-level"; import type { ModuleKey } from "@/lib/modules/registry"; import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; @@ -88,6 +89,11 @@ export interface AiCapabilityInputs { /** Kinds of the record's active (non-revoked) consent receipts. */ activeConsentKinds: ReadonlySet; recordKind: AiRecordKind; + /** + * v1.43 (#1217) — the record's AI usage level in effect. The loader always + * sets it; absent (a hand-built set of inputs) means no level restriction. + */ + usageLevel?: AiUsageLevel; } // Which tags are operator-held and which stays local: `@/lib/ai/provider-egress`, @@ -271,6 +277,15 @@ function consentReason( : "consent_required"; } +/** Whether the record's usage level is below what the capability needs. */ +function usageReason( + def: AiCapabilityDefinition, + level: AiUsageLevel | undefined, +): AiUnavailableReason | null { + if (def.minUsageLevel === undefined || level === undefined) return null; + return usageLevelAtLeast(level, def.minUsageLevel) ? null : "usage_level"; +} + function stateFor(reason: AiUnavailableReason | null): AiCapabilityState { if (reason === null) { return { available: true, reason: null, onDeviceAllowed: true }; @@ -314,6 +329,7 @@ export function explainAiCapability( switchReason, inputs.providerWorkAdmitted ? null : "not_permitted_for_record", modules.reason, + usageReason(def, inputs.usageLevel), providerReason(def, inputs.provider), consentReason(def, inputs.provider, inputs.activeConsentKinds), ]); diff --git a/src/lib/ai/capabilities/types.ts b/src/lib/ai/capabilities/types.ts index 1dc6274df..1acb92931 100644 --- a/src/lib/ai/capabilities/types.ts +++ b/src/lib/ai/capabilities/types.ts @@ -15,6 +15,7 @@ * contract, the structural guards — can depend on it without pulling a * database client along. */ +import type { AiUsageLevel, AiUsageState } from "@/lib/ai/usage-level"; import type { ModuleKey } from "@/lib/modules/registry"; /** Every AI capability, in publication order. */ @@ -29,6 +30,7 @@ export const AI_CAPABILITY_KEYS = [ "documentAi", "labsOcr", "medicationExtract", + "supplementLabel", ] as const; export type AiCapabilityKey = (typeof AI_CAPABILITY_KEYS)[number]; @@ -49,6 +51,9 @@ export type AiCapabilityKey = (typeof AI_CAPABILITY_KEYS)[number]; * record's own switch or by the edge of the active grant. * - `user_disabled`: the record's own AI opt-out: `disableCoach` for the * Coach, the `insights` module ("AI analysis") for everything else. + * - `usage_level`: the record's AI usage level (v1.43, #1217) is below the + * level the capability needs (`minUsageLevel`). A choice, like the + * opt-out, so it ranks right after it and before the provider layers. * - `no_provider`: no configured provider can serve this capability's input. * - `consent_required`: the provider chain needs a consent receipt and none * is active. @@ -59,6 +64,7 @@ export const AI_UNAVAILABLE_REASONS = [ "not_permitted_for_record", "module_disabled", "user_disabled", + "usage_level", "no_provider", "consent_required", ] as const; @@ -166,6 +172,11 @@ export interface AiCapabilityDefinition { consent: { rule: AiConsentRule; kinds: readonly AiConsentKind[] }; modality: AiModality; providerOrder: AiProviderOrder; + /** + * v1.43 (#1217) — the lowest AI usage level at which the capability is + * offered. Absent: every level. + */ + minUsageLevel?: AiUsageLevel; } /** @@ -289,6 +300,18 @@ export const AI_CAPABILITIES: Readonly< modality: "text", providerOrder: "chain", }, + // v1.43 — reading a supplement's label photo into its ingredients. A photo + // is a document, and an extra model run per label, so it is not offered on + // the `frugal` usage level. + supplementLabel: { + key: "supplementLabel", + operatorSwitch: "documentAi", + modules: { mode: "all", keys: ["medications", "nutrients"] }, + consent: { rule: "document", kinds: EXTRACTION_KINDS }, + modality: "document", + providerOrder: "document", + minUsageLevel: "standard", + }, }); /** @@ -328,6 +351,11 @@ export interface AiProviderState { export interface AiCapabilities { capabilities: Record; provider: AiProviderState; + /** + * v1.43 (#1217) — the record's AI usage level, its bounds and the estimate. + * Absent on servers before v1.43 and on blocks resolved without it. + */ + usage?: AiUsageState; } // ── Refusal codes ────────────────────────────────────────────────────────── @@ -345,3 +373,5 @@ export const AI_RECORD_NOT_PERMITTED_ERROR_CODE = "ai.record.notPermitted"; export const AI_PROVIDER_NONE_ERROR_CODE = "ai.provider.none"; /** The capability's inputs could not be loaded; the answer failed closed. */ export const AI_UNAVAILABLE_ERROR_CODE = "ai.unavailable"; +/** v1.43 — the record's AI usage level is below what the capability needs. */ +export const AI_USAGE_LEVEL_TOO_LOW_ERROR_CODE = "ai.usage.levelTooLow"; diff --git a/src/lib/ai/capabilities/usage.ts b/src/lib/ai/capabilities/usage.ts new file mode 100644 index 000000000..e3eda8f27 --- /dev/null +++ b/src/lib/ai/capabilities/usage.ts @@ -0,0 +1,222 @@ +/** + * v1.43 (#1217) — the reads behind an AI usage level. + * + * The vocabulary, the resolver and the estimate are pure and live in + * `@/lib/ai/usage-level`; this file gathers their inputs: + * + * - the operator's bounds and sync gap: the `AppSettings` singleton, one + * memoised read per request (read afresh inside a background run, so a + * changed ceiling reaches the next account of a nightly pass); + * - the record's stored choice: one point read of `users.ai_usage_level`; + * - the measured use: the record's `coach_usage` rows of the last seven + * full days in its own time zone. + * + * A level is resolved for the RECORD, like every AI capability: a background + * job runs per record, and a delegate or guardian inside somebody else's + * record sees that record's level. Only the account itself can change it + * (`PUT /api/auth/me/ai-usage`); a managed profile has no choice of its own + * and follows the instance default. + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { memoizePerRequest } from "@/lib/request-cache"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { + AI_USAGE_LEVEL_EFFECTS, + AI_USAGE_MEASURE_DAYS, + DEFAULT_AI_USAGE_DEFAULT_LEVEL, + DEFAULT_AI_USAGE_MAX_LEVEL, + DEFAULT_SYNC_REGEN_GAP_HOURS, + buildAiUsageState, + parseSyncRegenGapHours, + resolveAiUsageLevel, + usageLevelFromDb, + type AiUsageLevel, + type AiUsageResolution, + type AiUsageState, +} from "@/lib/ai/usage-level"; + +/** The operator's side of the usage levels. */ +export interface AiUsageSettings { + maxLevel: AiUsageLevel; + defaultLevel: AiUsageLevel; + /** The stored sync gap (1..48), `null` when the operator set none. */ + storedSyncRegenGapHours: number | null; + /** The sync gap in effect: the stored one, else the environment, else six. */ + syncRegenGapHours: number; +} + +/** The environment's sync gap, else six. */ +export function envSyncRegenGapHours(): number { + return ( + parseSyncRegenGapHours(process.env.AI_SYNC_REGEN_GAP_HOURS) ?? + DEFAULT_SYNC_REGEN_GAP_HOURS + ); +} + +/** Pure: the operator's settings from the raw row (or its absence). */ +export function aiUsageSettingsFromRow( + row: { + aiUsageMaxLevel?: string | null; + aiUsageDefaultLevel?: string | null; + aiSyncRegenGapHours?: number | null; + } | null, +): AiUsageSettings { + const stored = parseSyncRegenGapHours(row?.aiSyncRegenGapHours); + return { + maxLevel: + usageLevelFromDb(row?.aiUsageMaxLevel) ?? DEFAULT_AI_USAGE_MAX_LEVEL, + defaultLevel: + usageLevelFromDb(row?.aiUsageDefaultLevel) ?? + DEFAULT_AI_USAGE_DEFAULT_LEVEL, + storedSyncRegenGapHours: stored, + syncRegenGapHours: stored ?? envSyncRegenGapHours(), + }; +} + +/** + * The operator's bounds and sync gap. An unreadable row reads as the + * instance defaults (`standard` / `standard`, the environment's gap): the + * levels bound spend, and the default is what an untouched instance runs at. + */ +export function loadAiUsageSettings(): Promise { + const read = async (): Promise => { + try { + const row = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { + aiUsageMaxLevel: true, + aiUsageDefaultLevel: true, + aiSyncRegenGapHours: true, + }, + }); + return aiUsageSettingsFromRow(row); + } catch { + getEvent()?.addWarning( + "Failed to load the AI usage settings; the instance defaults apply", + ); + return aiUsageSettingsFromRow(null); + } + }; + try { + return memoizePerRequest("ai-usage-settings", read, { + freshInBackground: true, + }); + } catch { + return read(); + } +} + +/** One record's level: its stored choice against the operator's bounds. */ +export interface RecordAiUsage extends AiUsageResolution { + preference: AiUsageLevel | null; + settings: AiUsageSettings; +} + +/** + * The level in effect for one record. A missing row (an account deleted + * under a running job) reads as no choice, so the default applies. + */ +export function loadRecordAiUsage(recordId: string): Promise { + const read = async (): Promise => { + const [settings, row] = await Promise.all([ + loadAiUsageSettings(), + prisma.user.findUnique({ + where: { id: recordId }, + select: { aiUsageLevel: true }, + }), + ]); + const preference = usageLevelFromDb(row?.aiUsageLevel); + return { + ...resolveAiUsageLevel({ + preference, + defaultLevel: settings.defaultLevel, + maxLevel: settings.maxLevel, + }), + preference, + settings, + }; + }; + try { + return memoizePerRequest(`ai-usage:${recordId}`, read, { + freshInBackground: true, + }); + } catch { + return read(); + } +} + +/** Just the effective level, for a job or a gate. */ +export async function loadRecordUsageLevel( + recordId: string, +): Promise { + return (await loadRecordAiUsage(recordId)).level; +} + +/** + * The status snapshot's char cap for this record's level. An unreadable level + * reads as the instance default's cap, never as an uncapped snapshot. + */ +export async function statusSnapshotCharsForRecord( + recordId: string, +): Promise { + try { + const level = await loadRecordUsageLevel(recordId); + return AI_USAGE_LEVEL_EFFECTS[level].statusSnapshotChars; + } catch { + return AI_USAGE_LEVEL_EFFECTS[DEFAULT_AI_USAGE_DEFAULT_LEVEL] + .statusSnapshotChars; + } +} + +/** + * The record's measured AI use: the mean of its `coach_usage` totals over the + * last seven full days in its own time zone (today is still running and does + * not count). `coach_usage` counts the Coach and the background work + * together. A day without a row had no AI use recorded and is not counted, so + * `days` says how many days stand behind the mean. + */ +export async function loadMeasuredAiUsage( + recordId: string, + now: Date = new Date(), +): Promise<{ tokensPerDay: number | null; days: number }> { + const today = userDayKey(now, await resolveUserTimezone(recordId)); + const keys = Array.from({ length: AI_USAGE_MEASURE_DAYS }, (_, i) => + shiftDateKey(today, -(i + 1)), + ); + const rows = await prisma.coachUsage.findMany({ + where: { userId: recordId, dateKey: { in: keys } }, + select: { totalTokens: true }, + }); + if (rows.length === 0) return { tokensPerDay: null, days: 0 }; + const total = rows.reduce((sum, row) => sum + row.totalTokens, 0); + return { tokensPerDay: Math.round(total / rows.length), days: rows.length }; +} + +/** + * `ai.usage` for one record. `withMeasurement: false` publishes the levels + * without the measured use or the estimates: a delegate sees which level the + * record runs at, never how much the owner spends. + */ +export async function loadAiUsageState( + recordId: string, + options: { withMeasurement: boolean }, +): Promise { + const [usage, measured] = await Promise.all([ + loadRecordAiUsage(recordId), + options.withMeasurement + ? loadMeasuredAiUsage(recordId).catch(() => ({ + tokensPerDay: null, + days: 0, + })) + : Promise.resolve({ tokensPerDay: null, days: 0 }), + ]); + return buildAiUsageState({ + preference: usage.preference, + defaultLevel: usage.settings.defaultLevel, + maxLevel: usage.settings.maxLevel, + measuredTokensPerDay: measured.tokensPerDay, + measuredDays: measured.days, + }); +} diff --git a/src/lib/ai/usage-level.ts b/src/lib/ai/usage-level.ts new file mode 100644 index 000000000..e81673b15 --- /dev/null +++ b/src/lib/ai/usage-level.ts @@ -0,0 +1,311 @@ +/** + * v1.43 (#1217) — AI usage in three levels. + * + * One setting per account instead of many knobs. The operator sets the highest + * level an account may choose (`maxLevel`) and the level an account without a + * choice runs at (`defaultLevel`), since the operator pays for the shared key; + * each person picks within that. A level decides how much background model + * work the account asks for. It never decides what data is stored or shown, + * and the interactive Coach is the same at every level (its own budget caps + * apply as before). + * + * This file is the vocabulary: the levels, what each one does, the resolver + * and the estimate. Pure, no I/O, so the account payload, the jobs, the + * routes, the admin readout and the tests all read the same table. The reads + * live in `./capabilities/usage.ts`. + * + * Wire spelling is lower case (`frugal` / `standard` / `full`); the database + * enum (`AiUsageLevel`) is upper case. {@link AI_USAGE_LEVELS} is the ceiling + * order: a level is allowed when it is at or below `maxLevel`. + */ +import { + AI_USAGE_LEVELS, + type AiUsageLevelWire, +} from "@/lib/validations/ai-usage"; + +export { AI_USAGE_LEVELS }; +export type AiUsageLevel = AiUsageLevelWire; + +/** + * Where the effective level comes from: + * + * - `user`: the account's own stored choice; + * - `default`: no stored choice, so the operator's default; + * - `admin_cap`: a stored choice above the operator's ceiling, capped at + * read time. The choice itself is kept, so raising the ceiling again + * restores it. + */ +export const AI_USAGE_SOURCES = ["user", "default", "admin_cap"] as const; +export type AiUsageSource = (typeof AI_USAGE_SOURCES)[number]; + +/** What one level does. Every effect a level has is a row here. */ +export interface AiUsageLevelEffect { + /** + * Minimum hours between two status-card regenerations of one scope after a + * sync (a watch, an integration). `"instance"` reads the operator's sync + * gap (`AppSettings.aiSyncRegenGapHours`, else `AI_SYNC_REGEN_GAP_HOURS`, + * else six). + */ + syncRegenGapHours: number | "instance"; + /** The same gap after a hand entry. */ + manualRegenGapHours: number; + /** + * The status snapshot's char cap, measured on the compact JSON the prompt + * ships (`degradeStatusSnapshotToBudget`). Fourteen thousand holds every + * snapshot that fitted the old 24 000 pretty-printed characters. + */ + statusSnapshotChars: number; + /** + * Whether the background jobs that may reason (the briefing, the period + * narratives, the Coach memory) ask for it. When true the job's own level + * applies (`BACKGROUND_REASONING_LEVEL`), still capped by the operator. + */ + backgroundReasoning: boolean; + /** + * The daily briefing's form. `short`: the lead plus today, at most one + * recommendation; every field of the contract stays present, so a client + * decodes it unchanged. + */ + briefing: "full" | "short"; + /** + * Whether the account's own "read documents automatically" setting may + * act. When false, documents are read on request only. + */ + documentsAutoRead: boolean; + /** + * Relative token use, `full` = 1. Scales the measured use of the effective + * level into an estimate for another level. Calibrated with the generation + * simulation of a watch-synced account + * (`src/lib/insights/__tests__/usage-level-simulation.test.ts`): status-card + * generations a day, the nightly warm included, are 141 on `full`, 44 on + * `standard` (0.31) and 21 on `frugal` (0.15, before its shorter briefing + * and switched-off reasoning). The Coach is part of the measured use and + * does not scale, so the result is published and shown as an estimate. + */ + tokenFactor: number; +} + +export const AI_USAGE_LEVEL_EFFECTS: Readonly< + Record> +> = Object.freeze({ + frugal: { + syncRegenGapHours: 24, + manualRegenGapHours: 6, + statusSnapshotChars: 10_000, + backgroundReasoning: false, + briefing: "short", + documentsAutoRead: false, + tokenFactor: 0.15, + }, + standard: { + syncRegenGapHours: "instance", + manualRegenGapHours: 1, + statusSnapshotChars: 14_000, + backgroundReasoning: true, + briefing: "full", + documentsAutoRead: true, + tokenFactor: 0.3, + }, + full: { + syncRegenGapHours: 1, + manualRegenGapHours: 1, + statusSnapshotChars: 14_000, + backgroundReasoning: true, + briefing: "full", + documentsAutoRead: true, + tokenFactor: 1, + }, +}); + +/** The instance's own defaults, before an operator changes anything. */ +export const DEFAULT_AI_USAGE_MAX_LEVEL: AiUsageLevel = "standard"; +export const DEFAULT_AI_USAGE_DEFAULT_LEVEL: AiUsageLevel = "standard"; + +// ── Order ─────────────────────────────────────────────────────────────────── + +export function usageLevelRank(level: AiUsageLevel): number { + return AI_USAGE_LEVELS.indexOf(level); +} + +/** Whether `level` is at or above `min`. */ +export function usageLevelAtLeast( + level: AiUsageLevel, + min: AiUsageLevel, +): boolean { + return usageLevelRank(level) >= usageLevelRank(min); +} + +/** `level`, lowered to `max` when it lies above it. */ +export function capUsageLevel( + level: AiUsageLevel, + max: AiUsageLevel, +): AiUsageLevel { + return usageLevelRank(level) > usageLevelRank(max) ? max : level; +} + +export function isAiUsageLevel(value: unknown): value is AiUsageLevel { + return ( + typeof value === "string" && + (AI_USAGE_LEVELS as readonly string[]).includes(value) + ); +} + +// ── Database spelling ─────────────────────────────────────────────────────── + +export type AiUsageLevelDb = "FRUGAL" | "STANDARD" | "FULL"; + +export function usageLevelFromDb( + value: string | null | undefined, +): AiUsageLevel | null { + if (value == null) return null; + const lower = value.toLowerCase(); + return isAiUsageLevel(lower) ? lower : null; +} + +export function usageLevelToDb(level: AiUsageLevel): AiUsageLevelDb { + return level.toUpperCase() as AiUsageLevelDb; +} + +// ── Resolution ────────────────────────────────────────────────────────────── + +export interface AiUsageBounds { + /** The account's stored choice; `null` follows the default. */ + preference: AiUsageLevel | null; + defaultLevel: AiUsageLevel; + maxLevel: AiUsageLevel; +} + +export interface AiUsageResolution { + level: AiUsageLevel; + source: AiUsageSource; +} + +/** + * The level in effect. A stored choice at or below the ceiling wins; above it + * the ceiling applies and the choice is kept (`admin_cap`). Without a choice + * the default applies, held to the ceiling too, so a default stored above + * the ceiling (the admin route refuses one, an old row might hold one) can + * never lift an account over it. + */ +export function resolveAiUsageLevel(bounds: AiUsageBounds): AiUsageResolution { + const { preference, defaultLevel, maxLevel } = bounds; + if (preference === null) { + return { level: capUsageLevel(defaultLevel, maxLevel), source: "default" }; + } + if (usageLevelRank(preference) > usageLevelRank(maxLevel)) { + return { level: maxLevel, source: "admin_cap" }; + } + return { level: preference, source: "user" }; +} + +// ── Effects ───────────────────────────────────────────────────────────────── + +/** Where a batch of fresh measurements came from (`status-invalidation.ts`). */ +export type IngestSource = "manual" | "sync"; + +/** Default sync gap when neither the operator nor the environment sets one. */ +export const DEFAULT_SYNC_REGEN_GAP_HOURS = 6; +export const SYNC_REGEN_GAP_HOURS_MIN = 1; +export const SYNC_REGEN_GAP_HOURS_MAX = 48; + +/** A sync gap in whole hours, or `null` when the value is not a usable one. */ +export function parseSyncRegenGapHours(value: unknown): number | null { + const n = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) + : NaN; + if (!Number.isInteger(n)) return null; + if (n < SYNC_REGEN_GAP_HOURS_MIN || n > SYNC_REGEN_GAP_HOURS_MAX) return null; + return n; +} + +/** + * Hours a scope waits after its last regeneration before an ingest of this + * source may regenerate it again. + * + * @param instanceSyncGapHours The operator's resolved sync gap, read by + * `standard`. + */ +export function statusRegenGapHours( + level: AiUsageLevel, + source: IngestSource, + instanceSyncGapHours: number, +): number { + const effect = AI_USAGE_LEVEL_EFFECTS[level]; + if (source === "manual") return effect.manualRegenGapHours; + return effect.syncRegenGapHours === "instance" + ? instanceSyncGapHours + : effect.syncRegenGapHours; +} + +// ── Estimate ──────────────────────────────────────────────────────────────── + +/** Full days behind the measured mean, at most. */ +export const AI_USAGE_MEASURE_DAYS = 7; +/** Days of history an estimate needs. */ +export const AI_USAGE_ESTIMATE_MIN_DAYS = 3; + +/** + * Tokens per day at `target`, scaled from what the account used at `from`. + * `null` without enough history. + */ +export function estimateTokensPerDay(args: { + measuredTokensPerDay: number | null; + measuredDays: number; + from: AiUsageLevel; + target: AiUsageLevel; +}): number | null { + if (args.measuredTokensPerDay === null) return null; + if (args.measuredDays < AI_USAGE_ESTIMATE_MIN_DAYS) return null; + const ratio = + AI_USAGE_LEVEL_EFFECTS[args.target].tokenFactor / + AI_USAGE_LEVEL_EFFECTS[args.from].tokenFactor; + return Math.round(args.measuredTokensPerDay * ratio); +} + +/** `ai.usage` on `GET /api/auth/me` (OpenAPI `AiUsageState`). */ +export interface AiUsageState { + level: AiUsageLevel; + preference: AiUsageLevel | null; + defaultLevel: AiUsageLevel; + maxLevel: AiUsageLevel; + source: AiUsageSource; + options: Array<{ + level: AiUsageLevel; + allowed: boolean; + estimatedTokensPerDay: number | null; + }>; + measuredTokensPerDay: number | null; + measuredDays: number; +} + +/** The whole published state, from the bounds and the measured use. */ +export function buildAiUsageState( + args: AiUsageBounds & { + measuredTokensPerDay: number | null; + measuredDays: number; + }, +): AiUsageState { + const { level, source } = resolveAiUsageLevel(args); + return { + level, + preference: args.preference, + defaultLevel: args.defaultLevel, + maxLevel: args.maxLevel, + source, + options: AI_USAGE_LEVELS.map((option) => ({ + level: option, + allowed: usageLevelRank(option) <= usageLevelRank(args.maxLevel), + estimatedTokensPerDay: estimateTokensPerDay({ + measuredTokensPerDay: args.measuredTokensPerDay, + measuredDays: args.measuredDays, + from: level, + target: option, + }), + })), + measuredTokensPerDay: args.measuredTokensPerDay, + measuredDays: args.measuredDays, + }; +} diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index d2af8a35f..2d46fa5c1 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -180,6 +180,9 @@ const fakeDb = vi.hoisted(() => { encounter: { findMany: none }, // The AI capability loader's consent read: no receipts. consentReceipt: { findMany: none }, + // v1.43 — the loader's usage-level read: no stored choice, so the + // instance default applies. + user: { findUnique: async () => ({ aiUsageLevel: null }) }, }; }); diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index b2acc7860..0ea2d9db3 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -43,7 +43,10 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "ai.provider.none", "ai.record.notPermitted", "ai.unavailable", + "ai.usage.aboveCeiling", + "ai.usage.defaultAboveCeiling", "ai.usage.invalid", + "ai.usage.levelTooLow", "ai.usage.rateLimited", ], ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"], diff --git a/src/lib/openapi/routes/ai-usage.ts b/src/lib/openapi/routes/ai-usage.ts index 58f7f7504..1711b4267 100644 --- a/src/lib/openapi/routes/ai-usage.ts +++ b/src/lib/openapi/routes/ai-usage.ts @@ -11,12 +11,7 @@ import { z } from "zod/v4"; import { AI_USAGE_LEVELS, aiUsagePutSchema } from "@/lib/validations/ai-usage"; -import { - dataEnvelope, - errorEnvelope, - notImplementedResponse, - stdResponses, -} from "./shared"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; export const aiUsageLevel = z.enum(AI_USAGE_LEVELS).meta({ id: "AiUsageLevel", @@ -111,7 +106,6 @@ export const aiUsagePaths: NonNullable = { "`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling).", content: { "application/json": { schema: errorEnvelope } }, }, - ...notImplementedResponse, }, }, }, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 79e693cec..de83b2e61 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -129,7 +129,7 @@ export const moduleAccessMap = z export const aiUnavailableReason = z.enum(AI_UNAVAILABLE_REASONS).meta({ id: "AiUnavailableReason", description: - "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is reported, because it names the layer that would have to change first. `check_failed` — an input could not be read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does not know treats the capability as unavailable.", + "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is reported, because it names the layer that would have to change first. `check_failed` — an input could not be read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. `usage_level` — the record's AI usage level (`ai.usage.level`, v1.43) is below what the capability needs; today only `supplementLabel` on `frugal`. `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does not know treats the capability as unavailable.", }); export const aiCapabilityState = z @@ -141,7 +141,7 @@ export const aiCapabilityState = z onDeviceAllowed: z .boolean() .describe( - "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, `module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for server egress do not concern a model that runs on the device. Resolved here so the client never decides which reasons apply off the server.", + "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, `module_disabled`, `user_disabled`, `usage_level` and `check_failed`: the operator's and the person's decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for server egress do not concern a model that runs on the device. Resolved here so the client never decides which reasons apply off the server.", ), }) .meta({ @@ -164,7 +164,7 @@ export const aiCapabilities = z .meta({ id: "AiCapabilities", description: - "Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule.", + "Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule. `supplementLabel` (v1.43) — reading a supplement's label photo into its ingredients; unavailable on the `frugal` usage level.", }); export const aiProviderState = z diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index f2d059c20..b74579208 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -91,13 +91,13 @@ export const errorEnvelope = z .string() .optional() .describe( - "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`).", + "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`, `supplementLabel`).", ), reason: z .string() .optional() .describe( - "On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own typed code where it has one, otherwise `ai.provider.none` (422), `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` (503).", + "On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own typed code where it has one, otherwise `ai.provider.none` (422), `usage_level` → `ai.usage.levelTooLow` with `requiredLevel`, `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` (503).", ), module: z .string() @@ -105,6 +105,12 @@ export const errorEnvelope = z .describe( "Beside `module.disabled`: the module that is off. On an AI refusal it is the capability's owning module.", ), + requiredLevel: z + .enum(["frugal", "standard", "full"]) + .optional() + .describe( + "Beside `ai.usage.levelTooLow` (v1.43): the lowest AI usage level at which the capability is offered.", + ), }) .optional(), }) From d3770b9010d4216858fd65f1fbe2febcc1c441ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:43:48 +0200 Subject: [PATCH 027/127] feat(admin): set the AI usage bounds and count accounts per level PUT /api/admin/settings/assistant-flags takes an optional aiUsage block: the highest level an account may choose, the default for accounts without a choice (422 ai.usage.defaultAboveCeiling when above the ceiling) and the sync regeneration gap at standard (1..48 hours, null follows AI_SYNC_REGEN_GAP_HOURS, else 6). Lowering the ceiling caps every account above it at once and keeps their stored choice. The provider-health readout counts accounts per effective level. Refs #1217 --- .../provider-health/__tests__/route.test.ts | 42 +++++++++++ src/app/api/admin/provider-health/route.ts | 35 +++++++++ .../assistant-flags/__tests__/route.test.ts | 72 +++++++++++++++++++ .../admin/settings/assistant-flags/route.ts | 59 ++++++++++++++- 4 files changed, 207 insertions(+), 1 deletion(-) diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts index fa9f4259e..75fb1b373 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -25,6 +25,8 @@ vi.mock("@/lib/db", () => ({ }, // v1.42 — the operator key's model check reads the admin settings. appSettings: { findUnique: vi.fn(async () => null) }, + // v1.43 — accounts per effective AI usage level. + user: { groupBy: vi.fn(async () => []) }, }, })); @@ -378,3 +380,43 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", () expect(probeModelListing).not.toHaveBeenCalled(); }); }); + +// #1217 — the operator sees how many accounts run at each level, resolved +// against the current bounds, so lowering the ceiling moves the counts. +describe("usage levels", () => { + const userGroups = vi.mocked(prisma.user.groupBy); + const settingsRow = vi.mocked(prisma.appSettings.findUnique); + + beforeEach(() => { + groupBy.mockResolvedValue([] as never); + usageRows.mockResolvedValue([] as never); + }); + + it("counts each account at its effective level", async () => { + settingsRow.mockResolvedValue({ + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, + } as never); + userGroups.mockResolvedValue([ + { aiUsageLevel: null, _count: { _all: 5 } }, + { aiUsageLevel: "FRUGAL", _count: { _all: 2 } }, + // Above the ceiling: capped to standard. + { aiUsageLevel: "FULL", _count: { _all: 3 } }, + ] as never); + const res = await GET(); + const body = await res.json(); + expect(body.data.usageLevels).toEqual({ frugal: 2, standard: 8, full: 0 }); + }); + + it("reports zero everywhere on an empty instance", async () => { + settingsRow.mockResolvedValue(null); + userGroups.mockResolvedValue([] as never); + const res = await GET(); + expect((await res.json()).data.usageLevels).toEqual({ + frugal: 0, + standard: 0, + full: 0, + }); + }); +}); diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 8d35cada5..38a761efe 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -12,6 +12,13 @@ import { import { decrypt } from "@/lib/crypto"; import { isAnthropicBaseUrl } from "@/lib/ai/provider"; import { isCanonicalOpenAIEndpoint } from "@/lib/ai/openai-capabilities"; +import { loadAiUsageSettings } from "@/lib/ai/capabilities/usage"; +import { + AI_USAGE_LEVELS, + resolveAiUsageLevel, + usageLevelFromDb, + type AiUsageLevel, +} from "@/lib/ai/usage-level"; export const dynamic = "force-dynamic"; @@ -186,9 +193,37 @@ export const GET = apiHandler(async () => { operatorTokens: spend.operator, accounts: spend.accounts, }, + usageLevels: await countAccountsByUsageLevel(), }); }); +/** + * v1.43 (#1217) — accounts per EFFECTIVE AI usage level: each stored choice + * (or none) resolved against the operator's current bounds, so the readout + * moves the moment the operator lowers the ceiling or the default. One + * grouped read over the users' stored choices. + */ +async function countAccountsByUsageLevel(): Promise< + Record +> { + const [settings, groups] = await Promise.all([ + loadAiUsageSettings(), + prisma.user.groupBy({ by: ["aiUsageLevel"], _count: { _all: true } }), + ]); + const counts = Object.fromEntries( + AI_USAGE_LEVELS.map((level) => [level, 0]), + ) as Record; + for (const group of groups) { + const { level } = resolveAiUsageLevel({ + preference: usageLevelFromDb(group.aiUsageLevel), + defaultLevel: settings.defaultLevel, + maxLevel: settings.maxLevel, + }); + counts[level] += group._count._all; + } + return counts; +} + /** * The model check for the operator's key. Never fails the readout: a missing * settings row, a key that does not decrypt, or a probe error is simply no diff --git a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts index 89f60dd8b..350c54de8 100644 --- a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts +++ b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts @@ -48,6 +48,9 @@ const ALL_ON = { assistantDocumentAiEnabled: true, aiReasoningEnabled: true, aiReasoningMaxEffort: "high", + aiUsageMaxLevel: "STANDARD", + aiUsageDefaultLevel: "STANDARD", + aiSyncRegenGapHours: null, }; beforeEach(() => { @@ -211,6 +214,75 @@ describe("PUT /api/admin/settings/assistant-flags", () => { }, ); + // #1217 — the operator's usage bounds. + it("stores the usage bounds and echoes them with the gap in effect", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + ...ALL_ON, + aiUsageMaxLevel: "FULL", + aiUsageDefaultLevel: "FRUGAL", + aiSyncRegenGapHours: 12, + } as never); + const res = await PUT( + putReq({ + aiUsage: { + maxLevel: "full", + defaultLevel: "frugal", + syncRegenGapHours: 12, + }, + }), + ); + expect(res.status).toBe(200); + const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0]; + expect(upsert.update).toEqual({ + aiUsageMaxLevel: "FULL", + aiUsageDefaultLevel: "FRUGAL", + aiSyncRegenGapHours: 12, + }); + const body = (await res.json()) as { data: { aiUsage: unknown } }; + expect(body.data.aiUsage).toEqual({ + maxLevel: "full", + defaultLevel: "frugal", + syncRegenGapHours: 12, + effectiveSyncRegenGapHours: 12, + }); + }); + + it("refuses a default above the ceiling", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PUT( + putReq({ + aiUsage: { + maxLevel: "standard", + defaultLevel: "full", + syncRegenGapHours: null, + }, + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta: { errorCode: string } }; + expect(body.meta.errorCode).toBe("ai.usage.defaultAboveCeiling"); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); + + it.each([0, 49, 2.5])( + "refuses %s as the sync gap", + async (syncRegenGapHours) => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PUT( + putReq({ + aiUsage: { + maxLevel: "standard", + defaultLevel: "standard", + syncRegenGapHours, + }, + }), + ); + expect(res.status).toBe(422); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }, + ); + it("rejects an empty body", async () => { vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); const res = await PUT(putReq({})); diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts index 8190a70ba..a372b5e88 100644 --- a/src/app/api/admin/settings/assistant-flags/route.ts +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -36,6 +36,16 @@ * switching reasoning off stops no AI capability, it only stops the model * thinking longer before it answers. * + * v1.43 (#1217) — and the AI usage bounds, optional `aiUsage`: + * `{ maxLevel, defaultLevel, syncRegenGapHours }`. `maxLevel` is the highest + * level an account may choose, `defaultLevel` the level an account without a + * choice runs at (never above `maxLevel`: 422 `ai.usage.defaultAboveCeiling`), + * `syncRegenGapHours` the minimum gap between status-card regenerations after + * a sync at `standard` (1..48, `null` follows `AI_SYNC_REGEN_GAP_HOURS`, else + * six). Lowering `maxLevel` caps every account above it at once; their stored + * choice is kept. Echoed as `aiUsage` with the resolved gap beside the stored + * one. + * * `requireAdmin()` gates the route — non-admins get 403. */ import type { NextRequest } from "next/server"; @@ -55,6 +65,13 @@ import { resolveAssistantFlags } from "@/lib/feature-flags"; import { annotate } from "@/lib/logging/context"; import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels"; import { parseReasoningMaxEffort } from "@/lib/ai/reasoning/resolve"; +import { aiUsageSettingsFromRow } from "@/lib/ai/capabilities/usage"; +import { + usageLevelRank, + usageLevelToDb, + type AiUsageLevelDb, +} from "@/lib/ai/usage-level"; +import { adminAiUsageSchema } from "@/lib/validations/ai-usage"; const assistantFlagsSchema = z .object({ @@ -65,6 +82,7 @@ const assistantFlagsSchema = z assistantDocumentAiEnabled: z.boolean().optional(), aiReasoningEnabled: z.boolean().optional(), aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(), + aiUsage: adminAiUsageSchema.optional(), }) .strict(); @@ -76,6 +94,9 @@ type AssistantFlagsRow = { assistantDocumentAiEnabled: boolean; aiReasoningEnabled: boolean; aiReasoningMaxEffort: string; + aiUsageMaxLevel: AiUsageLevelDb; + aiUsageDefaultLevel: AiUsageLevelDb; + aiSyncRegenGapHours: number | null; }; const ROW_SELECT = { @@ -86,6 +107,9 @@ const ROW_SELECT = { assistantDocumentAiEnabled: true, aiReasoningEnabled: true, aiReasoningMaxEffort: true, + aiUsageMaxLevel: true, + aiUsageDefaultLevel: true, + aiSyncRegenGapHours: true, } as const; function buildResponseShape(row: AssistantFlagsRow) { @@ -109,6 +133,18 @@ function buildResponseShape(row: AssistantFlagsRow) { enabled: row.aiReasoningEnabled, maxEffort: parseReasoningMaxEffort(row.aiReasoningMaxEffort), }, + aiUsage: aiUsageShape(row), + }; +} + +/** The usage bounds as the admin panel edits them, the gap in effect beside. */ +function aiUsageShape(row: AssistantFlagsRow) { + const settings = aiUsageSettingsFromRow(row); + return { + maxLevel: settings.maxLevel, + defaultLevel: settings.defaultLevel, + syncRegenGapHours: settings.storedSyncRegenGapHours, + effectiveSyncRegenGapHours: settings.syncRegenGapHours, }; } @@ -132,6 +168,9 @@ export const GET = apiHandler(async () => { assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true, aiReasoningEnabled: settings?.aiReasoningEnabled ?? true, aiReasoningMaxEffort: settings?.aiReasoningMaxEffort ?? "high", + aiUsageMaxLevel: settings?.aiUsageMaxLevel ?? "STANDARD", + aiUsageDefaultLevel: settings?.aiUsageDefaultLevel ?? "STANDARD", + aiSyncRegenGapHours: settings?.aiSyncRegenGapHours ?? null, }; return apiSuccess(buildResponseShape(row)); @@ -154,13 +193,28 @@ export const PUT = apiHandler(async (request: NextRequest) => { // Field by field from the parsed body; the schema is strict, so every key // here is one the route owns. + const { aiUsage, ...flags } = parsed.data; + if ( + aiUsage && + usageLevelRank(aiUsage.defaultLevel) > usageLevelRank(aiUsage.maxLevel) + ) { + return apiError("The default level is above the highest level", 422, { + errorCode: "ai.usage.defaultAboveCeiling", + }); + } const updates: Partial = {}; - for (const [key, value] of Object.entries(parsed.data)) { + for (const [key, value] of Object.entries(flags)) { if (value !== undefined) { (updates as Record)[key] = value; } } const auditDetails: Record = { ...updates }; + if (aiUsage) { + updates.aiUsageMaxLevel = usageLevelToDb(aiUsage.maxLevel); + updates.aiUsageDefaultLevel = usageLevelToDb(aiUsage.defaultLevel); + updates.aiSyncRegenGapHours = aiUsage.syncRegenGapHours; + auditDetails.aiUsage = aiUsage; + } if (Object.keys(updates).length === 0) { return apiError("No valid fields", 422); @@ -187,6 +241,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled, aiReasoningEnabled: settings.aiReasoningEnabled, aiReasoningMaxEffort: settings.aiReasoningMaxEffort, + aiUsageMaxLevel: settings.aiUsageMaxLevel, + aiUsageDefaultLevel: settings.aiUsageDefaultLevel, + aiSyncRegenGapHours: settings.aiSyncRegenGapHours, }; return apiSuccess(buildResponseShape(row)); From 24c74bd3ebd4d9c07d37258c673a87a7cd78c596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:54:17 +0200 Subject: [PATCH 028/127] test(integration): keep the requestId out of the mcp refusal it does not carry --- tests/integration/encryption-key-check.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/encryption-key-check.test.ts b/tests/integration/encryption-key-check.test.ts index 51daa67cb..eae7d1f87 100644 --- a/tests/integration/encryption-key-check.test.ts +++ b/tests/integration/encryption-key-check.test.ts @@ -628,7 +628,6 @@ describe("routes outside apiHandler refuse while the key does not match", () => ); expect(res.status).toBe(503); expect((await res.json()).meta).toEqual({ - requestId: expect.any(String), errorCode: "encryption.key_mismatch", }); }); From 9c01e179ca4bfe89c732f33bd2a135f5c2034da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:55:03 +0200 Subject: [PATCH 029/127] test(integration): compare the oidc refusals without their request ids --- tests/integration/oidc-pending-link.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integration/oidc-pending-link.test.ts b/tests/integration/oidc-pending-link.test.ts index 19b97c781..4bc34c225 100644 --- a/tests/integration/oidc-pending-link.test.ts +++ b/tests/integration/oidc-pending-link.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; process.env.API_TOKEN_HMAC_KEY ??= "test-hmac-key-oidc-pending-link-32-bytes-minimum-1234567890"; @@ -186,7 +187,9 @@ describe("under an SSO-only policy", () => { const wrong = await passwordLogin("acct-b", "not the password"); expect(right.status).toBe(403); expect(wrong.status).toBe(403); - expect(await wrong.json()).toEqual(await right.json()); + expect(await bodyWithoutRequestId(wrong)).toBe( + await bodyWithoutRequestId(right), + ); }); it("keeps it closed for any other account", async () => { From e65ada2961f141b9a4cfc2ea3fd16f791c118afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:55:08 +0200 Subject: [PATCH 030/127] feat(labs): merge markers, edit their aliases and pick a marker on review The marker list offers a merge with the server's preview: what moves, what is converted, which names become aliases, and a refusal when a unit cannot be converted; it says a merge cannot be undone. The marker page shows and edits the other spellings and follows a merged marker to its target. Scan and document review name the matched marker or offer its suggestions and a new marker; the choice is remembered. The biomarkerMerge feature flag is on. --- messages/de.json | 51 +++- messages/en.json | 51 +++- messages/es.json | 51 +++- messages/fr.json | 51 +++- messages/it.json | 51 +++- messages/ko.json | 51 +++- messages/pl.json | 51 +++- src/app/api/auth/me/route.ts | 2 +- .../documents/document-facts-review.tsx | 52 ++++ .../documents/use-document-facts.ts | 7 +- .../labs/__tests__/biomarker-aliases.test.tsx | 116 +++++++ .../__tests__/lab-biomarker-detail.test.tsx | 4 +- .../labs/__tests__/ocr-marker-match.test.tsx | 139 +++++++++ .../labs/__tests__/ocr-review-dialog.test.tsx | 24 +- .../__tests__/ocr-review-validation.test.ts | 4 + .../labs/__tests__/ocr-row-editor.test.tsx | 4 + src/components/labs/biomarker-aliases.tsx | 154 ++++++++++ src/components/labs/biomarker-manager.tsx | 39 ++- src/components/labs/biomarker-merge-sheet.tsx | 282 ++++++++++++++++++ src/components/labs/lab-biomarker-detail.tsx | 28 +- src/components/labs/marker-match-field.tsx | 124 ++++++++ src/components/labs/ocr-review-dialog.tsx | 25 +- src/components/labs/ocr-review-types.ts | 43 +++ src/components/labs/ocr-review-validation.ts | 13 +- src/components/labs/ocr-row-editor.tsx | 61 ++-- src/components/labs/types.ts | 23 ++ src/components/labs/use-ocr-extract.ts | 5 + 27 files changed, 1454 insertions(+), 52 deletions(-) create mode 100644 src/components/labs/__tests__/biomarker-aliases.test.tsx create mode 100644 src/components/labs/__tests__/ocr-marker-match.test.tsx create mode 100644 src/components/labs/biomarker-aliases.tsx create mode 100644 src/components/labs/biomarker-merge-sheet.tsx create mode 100644 src/components/labs/marker-match-field.tsx diff --git a/messages/de.json b/messages/de.json index fc3592090..ed7059ac9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9687,6 +9687,54 @@ "fromReport": "Befundbereich {range}", "catalogDiffers": "dein Bereich {range}", "printed": "auf dem Befund in {unit}: {range}" + }, + "match": { + "viaAlias": "über einen Namen, den du früher zugeordnet hast", + "viaCatalog": "als übliche Abkürzung erkannt", + "chooseLabel": "Gehört zu", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "Ein neuer Biomarker", + "rememberHint": "Der Name wird für den nächsten Befund gemerkt.", + "chooseHint": "Wähle einen Biomarker, wenn du ihn schon erfasst." + }, + "aliases": { + "title": "Auch geschrieben als", + "taken": "{alias} gehört schon zu einem anderen Biomarker.", + "saveError": "Die Namen konnten nicht gespeichert werden. Versuche es erneut.", + "remove": "{alias} entfernen", + "empty": "Ergänze die Namen, die andere Labore für {name} drucken, damit ihre Befunde hier landen.", + "placeholder": "z. B. Alk.P", + "addLabel": "Weiterer Name für diesen Biomarker", + "add": "Hinzufügen", + "alsoWritten": "Auch geschrieben als {aliases}" + }, + "merge": { + "open": "Zusammenführen", + "title": "Biomarker zusammenführen", + "description": "Fasse Biomarker zusammen, die derselbe Test unter anderem Namen sind.", + "pickHint": "Wähle mindestens zwei Biomarker aus.", + "keepLabel": "Behalten", + "keepHint": "Die anderen gehen in diesen auf, und ihre Namen werden seine weiteren Namen.", + "showPreview": "Vorschau zeigen", + "back": "Zurück", + "confirm": "Jetzt zusammenführen", + "previewSummaryOne": "{count} Messung wechselt zu {name} ({unit}).", + "previewSummaryFew": "{count} Messungen wechseln zu {name} ({unit}).", + "previewSummaryOther": "{count} Messungen wechseln zu {name} ({unit}).", + "movesOne": "{count} Messung, schon in derselben Einheit", + "movesFew": "{count} Messungen, schon in derselben Einheit", + "movesOther": "{count} Messungen, schon in derselben Einheit", + "convertedOne": "{count} Messung, umgerechnet von {unit} in {targetUnit}", + "convertedFew": "{count} Messungen, umgerechnet von {unit} in {targetUnit}", + "convertedOther": "{count} Messungen, umgerechnet von {unit} in {targetUnit}", + "refused": "{unit} lässt sich nicht in {targetUnit} umrechnen.", + "aliasesAdded": "Diese Namen führen danach zum behaltenen Biomarker:", + "blocked": "Solange sich eine Einheit nicht umrechnen lässt, wird nichts zusammengeführt. Lass diesen Biomarker weg oder behalte einen anderen.", + "noUndo": "Das Zusammenführen lässt sich nicht rückgängig machen.", + "doneToastOne": "In {name} zusammengeführt, {count} Messung verschoben", + "doneToastFew": "In {name} zusammengeführt, {count} Messungen verschoben", + "doneToastOther": "In {name} zusammengeführt, {count} Messungen verschoben", + "error": "Die Biomarker konnten nicht zusammengeführt werden. Versuche es erneut." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "Aus diesem Dokument konnten keine Werte ausgelesen werden.", "failUnitRequired": "Ein Zahlenwert braucht eine Einheit, bevor er gespeichert werden kann.", "failUnitMismatch": "Die angegebene Einheit passt nicht zur gespeicherten Einheit dieses Markers. Passe sie an und schließe die Prüfung erneut ab.", - "failGeneric": "Dieser Wert konnte nicht gespeichert werden." + "failGeneric": "Dieser Wert konnte nicht gespeichert werden.", + "failUnknownBiomarker": "Den gewählten Biomarker gibt es nicht mehr. Wähle einen anderen." }, "contentIndex": { "indexPrompt": "Dokumentinhalte durchsuchbar machen.", diff --git a/messages/en.json b/messages/en.json index 98a57afc3..317fe14df 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9687,6 +9687,54 @@ "fromReport": "Report range {range}", "catalogDiffers": "your range {range}", "printed": "as printed in {unit}: {range}" + }, + "match": { + "viaAlias": "by a name you chose before", + "viaCatalog": "recognised as a common abbreviation", + "chooseLabel": "Belongs to", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "A new biomarker", + "rememberHint": "This name will be remembered for the next report.", + "chooseHint": "Pick a biomarker if this is one you already track." + }, + "aliases": { + "title": "Also written as", + "taken": "{alias} already belongs to another biomarker.", + "saveError": "The names could not be saved. Try again.", + "remove": "Remove {alias}", + "empty": "Add the names other labs print for {name}, so their reports land here.", + "placeholder": "e.g. Alk.P", + "addLabel": "Another name for this biomarker", + "add": "Add", + "alsoWritten": "Also written as {aliases}" + }, + "merge": { + "open": "Merge", + "title": "Merge biomarkers", + "description": "Combine markers that are the same test written differently.", + "pickHint": "Select at least two biomarkers.", + "keepLabel": "Keep", + "keepHint": "The others move into this one and their names become its other names.", + "showPreview": "Show preview", + "back": "Back", + "confirm": "Merge now", + "previewSummaryOne": "{count} reading moves to {name} ({unit}).", + "previewSummaryFew": "{count} readings move to {name} ({unit}).", + "previewSummaryOther": "{count} readings move to {name} ({unit}).", + "movesOne": "{count} reading, already in the same unit", + "movesFew": "{count} readings, already in the same unit", + "movesOther": "{count} readings, already in the same unit", + "convertedOne": "{count} reading, converted from {unit} to {targetUnit}", + "convertedFew": "{count} readings, converted from {unit} to {targetUnit}", + "convertedOther": "{count} readings, converted from {unit} to {targetUnit}", + "refused": "{unit} cannot be converted to {targetUnit}.", + "aliasesAdded": "These names will lead to the kept biomarker:", + "blocked": "Nothing can be merged while a unit cannot be converted. Leave that biomarker out or keep another one.", + "noUndo": "A merge cannot be undone.", + "doneToastOne": "Merged into {name}, {count} reading moved", + "doneToastFew": "Merged into {name}, {count} readings moved", + "doneToastOther": "Merged into {name}, {count} readings moved", + "error": "The biomarkers could not be merged. Try again." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "No values could be extracted from this document.", "failUnitRequired": "A numeric value needs a unit before it can be saved.", "failUnitMismatch": "The stated unit does not match the saved unit for this marker. Adjust it, then finish the review again.", - "failGeneric": "This value could not be saved." + "failGeneric": "This value could not be saved.", + "failUnknownBiomarker": "The chosen biomarker no longer exists. Pick another one." }, "contentIndex": { "indexPrompt": "Make document contents searchable.", diff --git a/messages/es.json b/messages/es.json index 4f8622fc0..c14403377 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9687,6 +9687,54 @@ "fromReport": "Rango del informe {range}", "catalogDiffers": "tu rango {range}", "printed": "impreso en {unit}: {range}" + }, + "match": { + "viaAlias": "por un nombre que elegiste antes", + "viaCatalog": "reconocido como una abreviatura habitual", + "chooseLabel": "Pertenece a", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "Un biomarcador nuevo", + "rememberHint": "Este nombre se recordará para el próximo informe.", + "chooseHint": "Elige un biomarcador si ya lo registras." + }, + "aliases": { + "title": "También escrito como", + "taken": "{alias} ya pertenece a otro biomarcador.", + "saveError": "No se pudieron guardar los nombres. Inténtalo de nuevo.", + "remove": "Quitar {alias}", + "empty": "Añade los nombres que otros laboratorios imprimen para {name}, para que sus informes lleguen aquí.", + "placeholder": "p. ej. FA", + "addLabel": "Otro nombre para este biomarcador", + "add": "Añadir", + "alsoWritten": "También escrito como {aliases}" + }, + "merge": { + "open": "Combinar", + "title": "Combinar biomarcadores", + "description": "Une los biomarcadores que son la misma prueba escrita de otra forma.", + "pickHint": "Selecciona al menos dos biomarcadores.", + "keepLabel": "Conservar", + "keepHint": "Los demás pasan a este y sus nombres se convierten en sus otros nombres.", + "showPreview": "Ver vista previa", + "back": "Atrás", + "confirm": "Combinar ahora", + "previewSummaryOne": "{count} medición pasa a {name} ({unit}).", + "previewSummaryFew": "{count} mediciones pasan a {name} ({unit}).", + "previewSummaryOther": "{count} mediciones pasan a {name} ({unit}).", + "movesOne": "{count} medición, ya en la misma unidad", + "movesFew": "{count} mediciones, ya en la misma unidad", + "movesOther": "{count} mediciones, ya en la misma unidad", + "convertedOne": "{count} medición, convertida de {unit} a {targetUnit}", + "convertedFew": "{count} mediciones, convertidas de {unit} a {targetUnit}", + "convertedOther": "{count} mediciones, convertidas de {unit} a {targetUnit}", + "refused": "{unit} no se puede convertir a {targetUnit}.", + "aliasesAdded": "Estos nombres llevarán al biomarcador conservado:", + "blocked": "No se combina nada mientras una unidad no se pueda convertir. Deja fuera ese biomarcador o conserva otro.", + "noUndo": "Una combinación no se puede deshacer.", + "doneToastOne": "Combinado en {name}, {count} medición movida", + "doneToastFew": "Combinado en {name}, {count} mediciones movidas", + "doneToastOther": "Combinado en {name}, {count} mediciones movidas", + "error": "No se pudieron combinar los biomarcadores. Inténtalo de nuevo." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "No se pudieron extraer valores de este documento.", "failUnitRequired": "Un valor numérico necesita una unidad antes de poder guardarse.", "failUnitMismatch": "La unidad indicada no coincide con la unidad guardada de este marcador. Ajústala y termina la revisión de nuevo.", - "failGeneric": "Este valor no se pudo guardar." + "failGeneric": "Este valor no se pudo guardar.", + "failUnknownBiomarker": "El biomarcador elegido ya no existe. Elige otro." }, "contentIndex": { "indexPrompt": "Haz que el contenido de los documentos sea buscable.", diff --git a/messages/fr.json b/messages/fr.json index 48b16f632..172d45319 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9687,6 +9687,54 @@ "fromReport": "Plage du compte rendu {range}", "catalogDiffers": "votre plage {range}", "printed": "imprimé en {unit} : {range}" + }, + "match": { + "viaAlias": "par un nom que tu as choisi auparavant", + "viaCatalog": "reconnu comme une abréviation courante", + "chooseLabel": "Correspond à", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "Un nouveau biomarqueur", + "rememberHint": "Ce nom sera retenu pour le prochain compte rendu.", + "chooseHint": "Choisis un biomarqueur si tu le suis déjà." + }, + "aliases": { + "title": "Aussi écrit", + "taken": "{alias} appartient déjà à un autre biomarqueur.", + "saveError": "Les noms n'ont pas pu être enregistrés. Réessaie.", + "remove": "Retirer {alias}", + "empty": "Ajoute les noms que d'autres laboratoires impriment pour {name}, pour que leurs comptes rendus arrivent ici.", + "placeholder": "p. ex. PAL", + "addLabel": "Autre nom pour ce biomarqueur", + "add": "Ajouter", + "alsoWritten": "Aussi écrit {aliases}" + }, + "merge": { + "open": "Fusionner", + "title": "Fusionner des biomarqueurs", + "description": "Réunis les biomarqueurs qui sont le même examen écrit autrement.", + "pickHint": "Sélectionne au moins deux biomarqueurs.", + "keepLabel": "Conserver", + "keepHint": "Les autres sont fondus dans celui-ci et leurs noms deviennent ses autres noms.", + "showPreview": "Voir l'aperçu", + "back": "Retour", + "confirm": "Fusionner maintenant", + "previewSummaryOne": "{count} mesure passe à {name} ({unit}).", + "previewSummaryFew": "{count} mesures passent à {name} ({unit}).", + "previewSummaryOther": "{count} mesures passent à {name} ({unit}).", + "movesOne": "{count} mesure, déjà dans la même unité", + "movesFew": "{count} mesures, déjà dans la même unité", + "movesOther": "{count} mesures, déjà dans la même unité", + "convertedOne": "{count} mesure, convertie de {unit} en {targetUnit}", + "convertedFew": "{count} mesures, converties de {unit} en {targetUnit}", + "convertedOther": "{count} mesures, converties de {unit} en {targetUnit}", + "refused": "{unit} ne peut pas être converti en {targetUnit}.", + "aliasesAdded": "Ces noms mèneront au biomarqueur conservé :", + "blocked": "Rien n'est fusionné tant qu'une unité ne peut pas être convertie. Laisse ce biomarqueur de côté ou conserve-en un autre.", + "noUndo": "Une fusion ne peut pas être annulée.", + "doneToastOne": "Fusionné dans {name}, {count} mesure déplacée", + "doneToastFew": "Fusionné dans {name}, {count} mesures déplacées", + "doneToastOther": "Fusionné dans {name}, {count} mesures déplacées", + "error": "Les biomarqueurs n'ont pas pu être fusionnés. Réessaie." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "Aucune valeur n’a pu être extraite de ce document.", "failUnitRequired": "Une valeur numérique a besoin d’une unité avant de pouvoir être enregistrée.", "failUnitMismatch": "L’unité indiquée ne correspond pas à l’unité enregistrée pour ce marqueur. Ajustez-la, puis terminez à nouveau la vérification.", - "failGeneric": "Cette valeur n’a pas pu être enregistrée." + "failGeneric": "Cette valeur n’a pas pu être enregistrée.", + "failUnknownBiomarker": "Le biomarqueur choisi n'existe plus. Choisis-en un autre." }, "contentIndex": { "indexPrompt": "Rendre le contenu des documents consultable.", diff --git a/messages/it.json b/messages/it.json index a716cd7d7..725cabf55 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9687,6 +9687,54 @@ "fromReport": "Intervallo del referto {range}", "catalogDiffers": "il tuo intervallo {range}", "printed": "stampato in {unit}: {range}" + }, + "match": { + "viaAlias": "tramite un nome che hai scelto in precedenza", + "viaCatalog": "riconosciuto come abbreviazione comune", + "chooseLabel": "Appartiene a", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "Un nuovo biomarcatore", + "rememberHint": "Questo nome verrà ricordato per il prossimo referto.", + "chooseHint": "Scegli un biomarcatore se lo registri già." + }, + "aliases": { + "title": "Scritto anche come", + "taken": "{alias} appartiene già a un altro biomarcatore.", + "saveError": "Non è stato possibile salvare i nomi. Riprova.", + "remove": "Rimuovi {alias}", + "empty": "Aggiungi i nomi che altri laboratori stampano per {name}, così i loro referti arrivano qui.", + "placeholder": "es. ALP", + "addLabel": "Altro nome per questo biomarcatore", + "add": "Aggiungi", + "alsoWritten": "Scritto anche come {aliases}" + }, + "merge": { + "open": "Unisci", + "title": "Unisci biomarcatori", + "description": "Riunisci i biomarcatori che sono lo stesso esame scritto in modo diverso.", + "pickHint": "Seleziona almeno due biomarcatori.", + "keepLabel": "Mantieni", + "keepHint": "Gli altri confluiscono in questo e i loro nomi diventano i suoi altri nomi.", + "showPreview": "Mostra anteprima", + "back": "Indietro", + "confirm": "Unisci ora", + "previewSummaryOne": "{count} misurazione passa a {name} ({unit}).", + "previewSummaryFew": "{count} misurazioni passano a {name} ({unit}).", + "previewSummaryOther": "{count} misurazioni passano a {name} ({unit}).", + "movesOne": "{count} misurazione, già nella stessa unità", + "movesFew": "{count} misurazioni, già nella stessa unità", + "movesOther": "{count} misurazioni, già nella stessa unità", + "convertedOne": "{count} misurazione, convertita da {unit} a {targetUnit}", + "convertedFew": "{count} misurazioni, convertite da {unit} a {targetUnit}", + "convertedOther": "{count} misurazioni, convertite da {unit} a {targetUnit}", + "refused": "{unit} non può essere convertito in {targetUnit}.", + "aliasesAdded": "Questi nomi porteranno al biomarcatore mantenuto:", + "blocked": "Non si unisce nulla finché un'unità non può essere convertita. Escludi quel biomarcatore o mantienine un altro.", + "noUndo": "Un'unione non può essere annullata.", + "doneToastOne": "Unito in {name}, {count} misurazione spostata", + "doneToastFew": "Unito in {name}, {count} misurazioni spostate", + "doneToastOther": "Unito in {name}, {count} misurazioni spostate", + "error": "Non è stato possibile unire i biomarcatori. Riprova." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "Non è stato possibile estrarre valori da questo documento.", "failUnitRequired": "Un valore numerico ha bisogno di un’unità prima di poter essere salvato.", "failUnitMismatch": "L’unità indicata non corrisponde all’unità salvata per questo marcatore. Correggila e concludi di nuovo la revisione.", - "failGeneric": "Questo valore non è stato salvato." + "failGeneric": "Questo valore non è stato salvato.", + "failUnknownBiomarker": "Il biomarcatore scelto non esiste più. Scegline un altro." }, "contentIndex": { "indexPrompt": "Rendi ricercabile il contenuto dei documenti.", diff --git a/messages/ko.json b/messages/ko.json index ebf33b659..93077aa28 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9687,6 +9687,54 @@ "fromReport": "결과지 범위 {range}", "catalogDiffers": "내 범위 {range}", "printed": "결과지 원문({unit}): {range}" + }, + "match": { + "viaAlias": "전에 고른 이름으로 연결됨", + "viaCatalog": "흔히 쓰는 약어로 인식됨", + "chooseLabel": "연결할 항목", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "새 바이오마커", + "rememberHint": "다음 결과지를 위해 이 이름을 기억해요.", + "chooseHint": "이미 기록 중인 바이오마커라면 골라 주세요." + }, + "aliases": { + "title": "다른 표기", + "taken": "{alias}은(는) 이미 다른 바이오마커에 연결돼 있어요.", + "saveError": "이름을 저장하지 못했어요. 다시 시도해 주세요.", + "remove": "{alias} 삭제", + "empty": "다른 검사기관이 {name}에 쓰는 이름을 추가하면 그 결과지도 여기로 모여요.", + "placeholder": "예: ALP", + "addLabel": "이 바이오마커의 다른 이름", + "add": "추가", + "alsoWritten": "다른 표기: {aliases}" + }, + "merge": { + "open": "합치기", + "title": "바이오마커 합치기", + "description": "이름만 다르게 적힌 같은 검사 항목을 하나로 모아요.", + "pickHint": "바이오마커를 두 개 이상 골라 주세요.", + "keepLabel": "남길 항목", + "keepHint": "나머지는 이 항목으로 옮겨지고, 그 이름은 이 항목의 다른 표기가 돼요.", + "showPreview": "미리 보기", + "back": "뒤로", + "confirm": "지금 합치기", + "previewSummaryOne": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.", + "previewSummaryFew": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.", + "previewSummaryOther": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.", + "movesOne": "측정값 {count}개, 이미 같은 단위", + "movesFew": "측정값 {count}개, 이미 같은 단위", + "movesOther": "측정값 {count}개, 이미 같은 단위", + "convertedOne": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산", + "convertedFew": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산", + "convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산", + "refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.", + "aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:", + "blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.", + "noUndo": "합친 뒤에는 되돌릴 수 없어요.", + "doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", + "doneToastFew": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", + "doneToastOther": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", + "error": "바이오마커를 합치지 못했어요. 다시 시도해 주세요." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "이 문서에서 추출할 수 있는 값이 없었어요.", "failUnitRequired": "숫자 값을 저장하려면 단위가 필요해요.", "failUnitMismatch": "기재된 단위가 이 지표에 저장된 단위와 달라요. 수정한 뒤 검토를 다시 완료해 주세요.", - "failGeneric": "이 값을 저장하지 못했어요." + "failGeneric": "이 값을 저장하지 못했어요.", + "failUnknownBiomarker": "고른 바이오마커가 더 이상 없어요. 다른 항목을 골라 주세요." }, "contentIndex": { "indexPrompt": "문서 내용을 검색할 수 있게 만들어요.", diff --git a/messages/pl.json b/messages/pl.json index 7a2f70e17..3b7ea5962 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9687,6 +9687,54 @@ "fromReport": "Zakres z wyniku {range}", "catalogDiffers": "twój zakres {range}", "printed": "wydrukowano w {unit}: {range}" + }, + "match": { + "viaAlias": "według nazwy, którą wybrałeś wcześniej", + "viaCatalog": "rozpoznano jako popularny skrót", + "chooseLabel": "Należy do", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "Nowy biomarker", + "rememberHint": "Ta nazwa zostanie zapamiętana na następny wynik.", + "chooseHint": "Wybierz biomarker, jeśli już go śledzisz." + }, + "aliases": { + "title": "Zapisywany też jako", + "taken": "{alias} należy już do innego biomarkera.", + "saveError": "Nie udało się zapisać nazw. Spróbuj ponownie.", + "remove": "Usuń {alias}", + "empty": "Dodaj nazwy, które inne laboratoria drukują dla {name}, aby ich wyniki trafiały tutaj.", + "placeholder": "np. ALP", + "addLabel": "Inna nazwa tego biomarkera", + "add": "Dodaj", + "alsoWritten": "Zapisywany też jako {aliases}" + }, + "merge": { + "open": "Scal", + "title": "Scal biomarkery", + "description": "Połącz biomarkery, które są tym samym badaniem zapisanym inaczej.", + "pickHint": "Zaznacz co najmniej dwa biomarkery.", + "keepLabel": "Zachowaj", + "keepHint": "Pozostałe zostaną włączone do tego, a ich nazwy staną się jego innymi nazwami.", + "showPreview": "Pokaż podgląd", + "back": "Wstecz", + "confirm": "Scal teraz", + "previewSummaryOne": "{count} pomiar przechodzi do {name} ({unit}).", + "previewSummaryFew": "{count} pomiary przechodzą do {name} ({unit}).", + "previewSummaryOther": "{count} pomiarów przechodzi do {name} ({unit}).", + "movesOne": "{count} pomiar, już w tej samej jednostce", + "movesFew": "{count} pomiary, już w tej samej jednostce", + "movesOther": "{count} pomiarów, już w tej samej jednostce", + "convertedOne": "{count} pomiar, przeliczony z {unit} na {targetUnit}", + "convertedFew": "{count} pomiary, przeliczone z {unit} na {targetUnit}", + "convertedOther": "{count} pomiarów, przeliczonych z {unit} na {targetUnit}", + "refused": "Nie można przeliczyć {unit} na {targetUnit}.", + "aliasesAdded": "Te nazwy będą prowadzić do zachowanego biomarkera:", + "blocked": "Nic nie zostanie scalone, dopóki jednej jednostki nie da się przeliczyć. Pomiń ten biomarker albo zachowaj inny.", + "noUndo": "Scalenia nie można cofnąć.", + "doneToastOne": "Scalono w {name}, przeniesiono {count} pomiar", + "doneToastFew": "Scalono w {name}, przeniesiono {count} pomiary", + "doneToastOther": "Scalono w {name}, przeniesiono {count} pomiarów", + "error": "Nie udało się scalić biomarkerów. Spróbuj ponownie." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "Z tego dokumentu nie udało się odczytać żadnych wartości.", "failUnitRequired": "Wartość liczbowa potrzebuje jednostki, zanim będzie można ją zapisać.", "failUnitMismatch": "Podana jednostka nie zgadza się z zapisaną jednostką tego wskaźnika. Popraw ją i zakończ przegląd ponownie.", - "failGeneric": "Nie udało się zapisać tej wartości." + "failGeneric": "Nie udało się zapisać tej wartości.", + "failUnknownBiomarker": "Wybrany biomarker już nie istnieje. Wybierz inny." }, "contentIndex": { "indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.", diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 601873978..590730f6c 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -89,7 +89,7 @@ const SERVER_FEATURES = { // form work; until then a client must not send it (contract §0.6). cumulativeHourBuckets: false, syncTriggerBackfill: false, - biomarkerMerge: false, + biomarkerMerge: true, lifeCircumstances: false, } as const; diff --git a/src/components/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx index c17019dad..3904846cc 100644 --- a/src/components/documents/document-facts-review.tsx +++ b/src/components/documents/document-facts-review.tsx @@ -26,6 +26,7 @@ */ import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { FlaskConical, Loader2, ScanSearch } from "lucide-react"; import { toast } from "sonner"; @@ -35,7 +36,11 @@ import { Checkbox } from "@/components/ui/checkbox"; import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { MarkerMatchField } from "@/components/labs/marker-match-field"; +import type { BiomarkerListResponse } from "@/components/labs/types"; +import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import type { ConditionFactData, @@ -61,6 +66,8 @@ function commitFailureKey(reason: string): string { return "documents.review.failUnitRequired"; case "observation.unitMismatch": return "documents.review.failUnitMismatch"; + case "observation.unknownBiomarker": + return "documents.review.failUnknownBiomarker"; default: return "documents.review.failGeneric"; } @@ -202,6 +209,9 @@ function FactRow({ checked, onCheckedChange, failure, + chosenBiomarkerId, + onChooseBiomarker, + markerName, }: { documentId: string; fact: ExtractedFactDto; @@ -209,6 +219,11 @@ function FactRow({ onCheckedChange: (checked: boolean) => void; /** A per-fact commit refusal from the last confirm attempt, or null. */ failure: string | null; + /** v1.43 — the suggestion the person picked for a lab value, or null. */ + chosenBiomarkerId: string | null; + onChooseBiomarker: (biomarkerId: string | null) => void; + /** The account's marker name by id, for the matched marker. */ + markerName: (biomarkerId: string) => string | null; }) { const { t } = useTranslations(); const calendarDate = useCalendarDate(); @@ -264,6 +279,25 @@ function FactRow({ {typeLabel} {date ? `, ${calendarDate(date)}` : ""}

+ {isObservation && + (fact.biomarkerId !== undefined || + (fact.suggestions?.length ?? 0) > 0) ? ( + + onChooseBiomarker(suggestion?.biomarkerId ?? null) + } + slot="document-fact-marker" + /> + ) : null} {isObservation && (fact.data as ObservationFactData).referenceText ? (

{t("documents.review.statedRange", { @@ -460,6 +494,18 @@ export function DocumentFactsSection({ // until it is verified (which checks it). const [included, setIncluded] = useState>({}); const [failures, setFailures] = useState>({}); + // v1.43 — the marker the person picked per lab value, keyed by fact. + const [chosen, setChosen] = useState>({}); + const hasObservations = doc.facts.some( + (fact) => fact.factType === "OBSERVATION" && fact.status === "PENDING", + ); + const markers = useQuery({ + queryKey: queryKeys.biomarkers(), + queryFn: () => apiGet("/api/biomarkers"), + enabled: canManage && hasObservations && open, + }); + const markerName = (id: string) => + markers.data?.biomarkers.find((m) => m.id === id)?.name ?? null; const pending = doc.facts.filter((fact) => fact.status === "PENDING"); const approvedCount = doc.facts.filter( @@ -482,6 +528,7 @@ export function DocumentFactsSection({ const decisions = decidable.map((fact) => ({ factId: fact.id, action: isChecked(fact) ? ("approve" as const) : ("reject" as const), + ...(chosen[fact.id] ? { biomarkerId: chosen[fact.id] as string } : {}), })); confirmFacts.mutate( { documentId: doc.id, decisions }, @@ -566,6 +613,11 @@ export function DocumentFactsSection({ checked={isChecked(fact)} onCheckedChange={(checked) => setChecked(fact.id, checked)} failure={failures[fact.id] ?? null} + chosenBiomarkerId={chosen[fact.id] ?? null} + onChooseBiomarker={(id) => + setChosen((prev) => ({ ...prev, [fact.id]: id })) + } + markerName={markerName} /> ))} diff --git a/src/components/documents/use-document-facts.ts b/src/components/documents/use-document-facts.ts index 469155b9b..2ab18b787 100644 --- a/src/components/documents/use-document-facts.ts +++ b/src/components/documents/use-document-facts.ts @@ -114,7 +114,12 @@ export function useConfirmFacts() { Error, { documentId: string; - decisions: { factId: string; action: "approve" | "reject" }[]; + decisions: { + factId: string; + action: "approve" | "reject"; + /** v1.43 — the marker the person chose for a lab value. */ + biomarkerId?: string; + }[]; } >({ mutationFn: ({ documentId, decisions }) => diff --git a/src/components/labs/__tests__/biomarker-aliases.test.tsx b/src/components/labs/__tests__/biomarker-aliases.test.tsx new file mode 100644 index 000000000..faf6b5608 --- /dev/null +++ b/src/components/labs/__tests__/biomarker-aliases.test.tsx @@ -0,0 +1,116 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManageDomain: () => true }), +})); + +import { BiomarkerAliases } from "../biomarker-aliases"; +import { BiomarkerMergeSheet } from "../biomarker-merge-sheet"; +import type { BiomarkerDto } from "../types"; + +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ + children, + title, + footer, + }: { + children: React.ReactNode; + title: string; + footer?: React.ReactNode; + }) => ( +

+

{title}

+ {children} + {footer} +
+ ), +})); + +function wrap(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + {node} + , + ); +} + +const marker = (overrides: Partial): BiomarkerDto => ({ + id: "bm-1", + name: "Alkaline Phosphatase", + unit: "U/L", + lowerBound: null, + upperBound: null, + panel: null, + analyteKey: null, + hasContext: false, + context: null, + hidden: false, + aliases: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, +}); + +describe("", () => { + it("shows the other spellings as chips, removable for a manager", () => { + const html = wrap( + , + ); + expect(html).toContain("Also written as"); + expect(html).toContain("Alk.P"); + expect(html).toContain('aria-label="Remove AP"'); + expect(html).toContain("Another name for this biomarker"); + }); + + it("is read-only without the manage right, and absent when there is nothing to show", () => { + const readOnly = wrap( + , + ); + expect(readOnly).toContain("Alk.P"); + expect(readOnly).not.toContain("Remove Alk.P"); + expect(readOnly).not.toContain(")).toBe( + "", + ); + }); + + it("speaks German too", () => { + expect( + wrap(, "de"), + ).toContain("Auch geschrieben als"); + }); +}); + +describe("", () => { + it("opens on the pick step with every marker and no preview yet", () => { + const html = wrap( + {}} + markers={[ + marker({ id: "a", name: "Alkaline Phosphatase" }), + marker({ id: "b", name: "Alk.P" }), + ]} + />, + ); + expect(html).toContain("Merge biomarkers"); + expect(html).toContain('data-slot="biomarker-merge-select"'); + expect(html).toContain("Alk.P"); + expect(html).toContain("Select at least two biomarkers."); + // The preview button waits for a selection. + expect(html).toMatch( + /]*data-slot="biomarker-merge-preview"[^>]*disabled=""/, + ); + expect(html).not.toContain('data-slot="biomarker-merge-confirm"'); + }); +}); diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index f2f966b9b..86092e3cb 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -26,7 +26,9 @@ vi.mock("@/hooks/use-ai-capability", () => ({ onDeviceAllowed: true, }), })); -vi.mock("@/lib/api/api-fetch", () => ({ +vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({ + ApiError: (await importOriginal()) + .ApiError, apiGet: () => new Promise(() => {}), apiDelete: vi.fn(), apiPut: vi.fn(), diff --git a/src/components/labs/__tests__/ocr-marker-match.test.tsx b/src/components/labs/__tests__/ocr-marker-match.test.tsx new file mode 100644 index 000000000..6e89bba6b --- /dev/null +++ b/src/components/labs/__tests__/ocr-marker-match.test.tsx @@ -0,0 +1,139 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr"; + +import { + chooseMarker, + seedReviewRows, + type OcrReviewRow, +} from "../ocr-review-types"; +import { toCommitRow } from "../ocr-review-validation"; +import { OcrRowEditor } from "../ocr-row-editor"; + +const dto: OcrExtractedRowDto = { + analyte: "Bilirubin ges.", + value: 0.8, + valueText: null, + unit: "mg/dL", + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: "2026-06-01", + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + biomarkerMatch: "new", + markerUnit: null, + markerAnalyteKey: null, + duplicateOf: null, + biomarkerId: null, + matchKind: null, + suggestions: [ + { + biomarkerId: "bm-bili", + name: "Bilirubin (Total)", + unit: "µmol/L", + matchKind: "similar", + }, + ], +}; + +const lookup = (id: string) => + id === "bm-bili" + ? { name: "Bilirubin (Total)", analyteKey: "bilirubin-total" } + : id === "bm-alp" + ? { name: "Alkaline Phosphatase", analyteKey: null } + : null; + +function render(row: OcrReviewRow, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + {}} markerLookup={lookup} /> + , + ); +} + +describe("the scan review's marker choice (D1223)", () => { + it("seeds the match and the suggestions, with nothing chosen", () => { + const [row] = seedReviewRows([dto], null); + expect(row).toMatchObject({ + biomarkerId: null, + matchKind: null, + chosenBiomarkerId: null, + suggestions: dto.suggestions, + }); + }); + + it("reads a scan from before the fields existed as no match", () => { + const legacy = { ...dto } as Partial; + delete legacy.biomarkerId; + delete legacy.matchKind; + delete legacy.suggestions; + const [row] = seedReviewRows([legacy as OcrExtractedRowDto], null); + expect(row.suggestions).toEqual([]); + expect(row.biomarkerId).toBeNull(); + }); + + it("offers the suggestions and a new marker, new by default", () => { + const [row] = seedReviewRows([dto], null); + const html = render(row); + expect(html).toContain('data-slot="ocr-row-marker-choice"'); + expect(html).toContain("Bilirubin (Total) (µmol/L)"); + expect(html).toContain("A new biomarker"); + expect(html).toMatch(/