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] 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,