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,