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/messages/de.json b/messages/de.json
index f467801d0..f2f5c30ba 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -9624,12 +9624,16 @@
"apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht, besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.",
"lp-a": "Lipoprotein(a) ist ein LDL-ähnliches Partikel mit einem zusätzlich angehefteten Protein, dessen Spiegel weitgehend genetisch festgelegt ist und ein Leben lang recht stabil bleibt. Ein erhöhtes Lp(a) ist ein unabhängiger, vererbter Risikofaktor für Herz-Kreislauf-Erkrankungen und eine Verengung der Aortenklappe. Da es sich kaum verändert, wird es meist einmal bestimmt, um das lebenslange Risiko einer Person festzulegen, statt es wiederholt zu verfolgen. Es wird in molaren Einheiten angegeben und als Teil des gesamten kardiovaskulären Risikos gelesen.",
"fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins (des Hormons, das Glukose aus dem Blut in die Zellen schleust), gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.",
- "omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA (den beiden langkettigen marinen Omega-3-Fettsäuren) an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert."
+ "omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA (den beiden langkettigen marinen Omega-3-Fettsäuren) an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert.",
+ "alkaline-phosphatase": "Die alkalische Phosphatase (AP) ist ein Enzym, das vor allem in Leber, Gallenwegen und Knochen vorkommt. Sie steigt, wenn der Gallenfluss gestört ist, und in Phasen aktiven Knochenaufbaus, etwa im Wachstum von Kindern und Jugendlichen oder bei der Heilung eines Bruchs. Weil beide Organe sie abgeben, wird ein erhöhter Wert meist zusammen mit der GGT und den übrigen Leberwerten gelesen, um eine Ursache in der Leber von einer im Knochen zu unterscheiden. Sie gehört zu den üblichen Leberwerten.",
+ "bilirubin-total": "Das Gesamtbilirubin ist das gelbe Abbauprodukt des Hämoglobins aus gealterten roten Blutkörperchen, gemessen als Summe seiner unkonjugierten und konjugierten Form. Die Leber nimmt es auf, bindet es und scheidet es mit der Galle aus, daher zeigt der Wert, wie gut dieser Weg funktioniert. Er kann bei Lebererkrankungen, verschlossenen Gallenwegen oder vermehrtem Abbau roter Blutkörperchen steigen, harmlos auch beim Gilbert-Syndrom. Er wird zusammen mit den übrigen Leberwerten gelesen."
},
"apob": "Apolipoprotein B (ApoB)",
"lp-a": "Lipoprotein(a)",
"fasting-insulin": "Nüchtern-Insulin",
- "omega-3-index": "Omega-3-Index"
+ "omega-3-index": "Omega-3-Index",
+ "alkaline-phosphatase": "Alkalische Phosphatase (AP)",
+ "bilirubin-total": "Bilirubin gesamt"
},
"noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.",
"showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt",
@@ -9703,6 +9707,54 @@
"fromReport": "Befundbereich {range}",
"catalogDiffers": "dein Bereich {range}",
"printed": "auf dem Befund in {unit}: {range}"
+ },
+ "match": {
+ "viaAlias": "über einen Namen, den du früher zugeordnet hast",
+ "viaCatalog": "als übliche Abkürzung erkannt",
+ "chooseLabel": "Gehört zu",
+ "suggestionOption": "{name} ({unit})",
+ "newMarkerOption": "Ein neuer Biomarker",
+ "rememberHint": "Der Name wird für den nächsten Befund gemerkt.",
+ "chooseHint": "Wähle einen Biomarker, wenn du ihn schon erfasst."
+ },
+ "aliases": {
+ "title": "Auch geschrieben als",
+ "taken": "{alias} gehört schon zu einem anderen Biomarker.",
+ "saveError": "Die Namen konnten nicht gespeichert werden. Versuche es erneut.",
+ "remove": "{alias} entfernen",
+ "empty": "Ergänze die Namen, die andere Labore für {name} drucken, damit ihre Befunde hier landen.",
+ "placeholder": "z. B. Alk.P",
+ "addLabel": "Weiterer Name für diesen Biomarker",
+ "add": "Hinzufügen",
+ "alsoWritten": "Auch geschrieben als {aliases}"
+ },
+ "merge": {
+ "open": "Zusammenführen",
+ "title": "Biomarker zusammenführen",
+ "description": "Fasse Biomarker zusammen, die derselbe Test unter anderem Namen sind.",
+ "pickHint": "Wähle mindestens zwei Biomarker aus.",
+ "keepLabel": "Behalten",
+ "keepHint": "Die anderen gehen in diesen auf, und ihre Namen werden seine weiteren Namen.",
+ "showPreview": "Vorschau zeigen",
+ "back": "Zurück",
+ "confirm": "Jetzt zusammenführen",
+ "previewSummaryOne": "{count} Messung wechselt zu {name} ({unit}).",
+ "previewSummaryFew": "{count} Messungen wechseln zu {name} ({unit}).",
+ "previewSummaryOther": "{count} Messungen wechseln zu {name} ({unit}).",
+ "movesOne": "{count} Messung, schon in derselben Einheit",
+ "movesFew": "{count} Messungen, schon in derselben Einheit",
+ "movesOther": "{count} Messungen, schon in derselben Einheit",
+ "convertedOne": "{count} Messung, umgerechnet von {unit} in {targetUnit}",
+ "convertedFew": "{count} Messungen, umgerechnet von {unit} in {targetUnit}",
+ "convertedOther": "{count} Messungen, umgerechnet von {unit} in {targetUnit}",
+ "refused": "{unit} lässt sich nicht in {targetUnit} umrechnen.",
+ "aliasesAdded": "Diese Namen führen danach zum behaltenen Biomarker:",
+ "blocked": "Solange sich eine Einheit nicht umrechnen lässt, wird nichts zusammengeführt. Lass diesen Biomarker weg oder behalte einen anderen.",
+ "noUndo": "Das Zusammenführen lässt sich nicht rückgängig machen.",
+ "doneToastOne": "In {name} zusammengeführt, {count} Messung verschoben",
+ "doneToastFew": "In {name} zusammengeführt, {count} Messungen verschoben",
+ "doneToastOther": "In {name} zusammengeführt, {count} Messungen verschoben",
+ "error": "Die Biomarker konnten nicht zusammengeführt werden. Versuche es erneut."
}
},
"modules": {
@@ -11142,7 +11194,8 @@
"extractEmpty": "Aus diesem Dokument konnten keine Werte ausgelesen werden.",
"failUnitRequired": "Ein Zahlenwert braucht eine Einheit, bevor er gespeichert werden kann.",
"failUnitMismatch": "Die angegebene Einheit passt nicht zur gespeicherten Einheit dieses Markers. Passe sie an und schließe die Prüfung erneut ab.",
- "failGeneric": "Dieser Wert konnte nicht gespeichert werden."
+ "failGeneric": "Dieser Wert konnte nicht gespeichert werden.",
+ "failUnknownBiomarker": "Den gewählten Biomarker gibt es nicht mehr. Wähle einen anderen."
},
"contentIndex": {
"indexPrompt": "Dokumentinhalte durchsuchbar machen.",
@@ -12028,7 +12081,8 @@
"customVaccineDelete": "{name} hat einen eigenen Impfstoff entfernt",
"lifeEventCreate": "{name} hat ein Lebensereignis hinzugefügt",
"lifeEventUpdate": "{name} hat ein Lebensereignis geändert",
- "lifeEventDelete": "{name} hat ein Lebensereignis entfernt"
+ "lifeEventDelete": "{name} hat ein Lebensereignis entfernt",
+ "biomarkerMerge": "{name} hat Biomarker zusammengeführt"
},
"section": {
"measurements": {
@@ -12247,7 +12301,10 @@
"removed": "Verknüpfung mit {name} gelöst",
"linked": "Verknüpft",
"loadNewer": "Neuere laden",
- "loadOlder": "Ältere laden"
+ "loadOlder": "Ältere laden",
+ "period": {
+ "showing": "Zeitraum: {period}"
+ }
}
},
"bodySites": {
diff --git a/messages/en.json b/messages/en.json
index 6afd60c39..0d53008ba 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -9624,12 +9624,16 @@
"apob": "Apolipoprotein B is the structural protein carried by each of the atherogenic lipoprotein particles, so measuring it counts those particles directly rather than the cholesterol they carry. Because one ApoB molecule sits on each LDL, VLDL, and related particle, it can capture cardiovascular risk that an LDL-cholesterol value misses, particularly when particle number and cholesterol content diverge. For this reason it is increasingly used to refine lipid-based risk assessment. It is interpreted alongside the standard lipid panel.",
"lp-a": "Lipoprotein(a) is an LDL-like particle with an extra attached protein, and its level is largely set by genetics and stays fairly stable through life. Elevated Lp(a) is an independent, inherited risk factor for cardiovascular disease and aortic-valve narrowing. Because it changes little, it is usually measured once to establish a person's lifelong risk rather than tracked repeatedly. It is reported in molar units and read as part of overall cardiovascular risk.",
"fasting-insulin": "Fasting insulin is the level of insulin (the hormone that moves glucose from the blood into cells) measured after an overnight fast. Paired with fasting glucose, it helps estimate insulin resistance, the state in which the body needs more insulin to keep glucose in range. A high fasting insulin alongside normal glucose can be an early sign that this compensation is under way. It is read as part of a metabolic-health picture rather than in isolation.",
- "omega-3-index": "The omega-3 index is the share of EPA and DHA (the two long-chain marine omega-3 fatty acids) among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months."
+ "omega-3-index": "The omega-3 index is the share of EPA and DHA (the two long-chain marine omega-3 fatty acids) among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months.",
+ "alkaline-phosphatase": "Alkaline phosphatase (ALP) is an enzyme found mainly in the liver, the bile ducts and bone. It rises when bile flow is blocked and during phases of active bone building, such as growth in children and adolescents or bone healing. Because both organs release it, a raised value is usually read together with GGT and the other liver markers to tell a liver source from a bone source. It is one of the standard liver-panel markers.",
+ "bilirubin-total": "Total bilirubin is the yellow breakdown product of haemoglobin from worn-out red blood cells, measured as the sum of its unconjugated and conjugated forms. The liver takes it up, conjugates it and excretes it in bile, so the level reflects how well this pathway is working. It can rise with liver disease, blocked bile ducts or increased breakdown of red cells, and harmlessly in Gilbert's syndrome. It is read alongside the other liver markers."
},
"apob": "Apolipoprotein B (ApoB)",
"lp-a": "Lipoprotein(a)",
"fasting-insulin": "Fasting insulin",
- "omega-3-index": "Omega-3 index"
+ "omega-3-index": "Omega-3 index",
+ "alkaline-phosphatase": "Alkaline phosphatase (ALP)",
+ "bilirubin-total": "Total bilirubin"
},
"noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.",
"showingLatestOf": "Showing the latest {shown} of {total} readings",
@@ -9703,6 +9707,54 @@
"fromReport": "Report range {range}",
"catalogDiffers": "your range {range}",
"printed": "as printed in {unit}: {range}"
+ },
+ "match": {
+ "viaAlias": "by a name you chose before",
+ "viaCatalog": "recognised as a common abbreviation",
+ "chooseLabel": "Belongs to",
+ "suggestionOption": "{name} ({unit})",
+ "newMarkerOption": "A new biomarker",
+ "rememberHint": "This name will be remembered for the next report.",
+ "chooseHint": "Pick a biomarker if this is one you already track."
+ },
+ "aliases": {
+ "title": "Also written as",
+ "taken": "{alias} already belongs to another biomarker.",
+ "saveError": "The names could not be saved. Try again.",
+ "remove": "Remove {alias}",
+ "empty": "Add the names other labs print for {name}, so their reports land here.",
+ "placeholder": "e.g. Alk.P",
+ "addLabel": "Another name for this biomarker",
+ "add": "Add",
+ "alsoWritten": "Also written as {aliases}"
+ },
+ "merge": {
+ "open": "Merge",
+ "title": "Merge biomarkers",
+ "description": "Combine markers that are the same test written differently.",
+ "pickHint": "Select at least two biomarkers.",
+ "keepLabel": "Keep",
+ "keepHint": "The others move into this one and their names become its other names.",
+ "showPreview": "Show preview",
+ "back": "Back",
+ "confirm": "Merge now",
+ "previewSummaryOne": "{count} reading moves to {name} ({unit}).",
+ "previewSummaryFew": "{count} readings move to {name} ({unit}).",
+ "previewSummaryOther": "{count} readings move to {name} ({unit}).",
+ "movesOne": "{count} reading, already in the same unit",
+ "movesFew": "{count} readings, already in the same unit",
+ "movesOther": "{count} readings, already in the same unit",
+ "convertedOne": "{count} reading, converted from {unit} to {targetUnit}",
+ "convertedFew": "{count} readings, converted from {unit} to {targetUnit}",
+ "convertedOther": "{count} readings, converted from {unit} to {targetUnit}",
+ "refused": "{unit} cannot be converted to {targetUnit}.",
+ "aliasesAdded": "These names will lead to the kept biomarker:",
+ "blocked": "Nothing can be merged while a unit cannot be converted. Leave that biomarker out or keep another one.",
+ "noUndo": "A merge cannot be undone.",
+ "doneToastOne": "Merged into {name}, {count} reading moved",
+ "doneToastFew": "Merged into {name}, {count} readings moved",
+ "doneToastOther": "Merged into {name}, {count} readings moved",
+ "error": "The biomarkers could not be merged. Try again."
}
},
"modules": {
@@ -11142,7 +11194,8 @@
"extractEmpty": "No values could be extracted from this document.",
"failUnitRequired": "A numeric value needs a unit before it can be saved.",
"failUnitMismatch": "The stated unit does not match the saved unit for this marker. Adjust it, then finish the review again.",
- "failGeneric": "This value could not be saved."
+ "failGeneric": "This value could not be saved.",
+ "failUnknownBiomarker": "The chosen biomarker no longer exists. Pick another one."
},
"contentIndex": {
"indexPrompt": "Make document contents searchable.",
@@ -12028,7 +12081,8 @@
"customVaccineDelete": "{name} removed an own vaccine",
"lifeEventCreate": "{name} added a life event",
"lifeEventUpdate": "{name} changed a life event",
- "lifeEventDelete": "{name} removed a life event"
+ "lifeEventDelete": "{name} removed a life event",
+ "biomarkerMerge": "{name} merged biomarkers"
},
"section": {
"measurements": {
@@ -12247,7 +12301,10 @@
"removed": "Unlinked {name}",
"linked": "Linked",
"loadNewer": "Load newer",
- "loadOlder": "Load older"
+ "loadOlder": "Load older",
+ "period": {
+ "showing": "Showing {period}"
+ }
}
},
"bodySites": {
diff --git a/messages/es.json b/messages/es.json
index 8a5ba0bf7..0b50e9a0f 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -9624,12 +9624,16 @@
"apob": "La apolipoproteína B es la proteína estructural que lleva cada una de las partículas de lipoproteína aterogénicas, de modo que medirla cuenta directamente esas partículas en lugar del colesterol que transportan. Como hay una molécula de ApoB en cada partícula de LDL, VLDL y afines, puede captar un riesgo cardiovascular que un valor de colesterol LDL pasa por alto, sobre todo cuando el número de partículas y su contenido de colesterol divergen. Por eso se usa cada vez más para afinar la evaluación del riesgo basada en los lípidos. Se interpreta junto con el perfil lipídico habitual.",
"lp-a": "La lipoproteína(a) es una partícula similar al LDL con una proteína adicional unida, cuyo nivel viene determinado en gran medida por la genética y se mantiene bastante estable a lo largo de la vida. Una Lp(a) elevada es un factor de riesgo independiente y hereditario de enfermedad cardiovascular y de estrechamiento de la válvula aórtica. Como cambia poco, suele medirse una sola vez para establecer el riesgo vital de una persona, en lugar de seguirla de forma repetida. Se expresa en unidades molares y se lee como parte del riesgo cardiovascular global.",
"fasting-insulin": "La insulina en ayunas es el nivel de insulina —la hormona que traslada la glucosa de la sangre a las células— medido tras un ayuno nocturno. Junto con la glucosa en ayunas, ayuda a estimar la resistencia a la insulina, el estado en el que el cuerpo necesita más insulina para mantener la glucosa dentro de su rango. Una insulina en ayunas alta con una glucosa normal puede ser un signo temprano de que esa compensación está en marcha. Se lee como parte de un cuadro de salud metabólica y no de forma aislada.",
- "omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses."
+ "omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses.",
+ "alkaline-phosphatase": "La fosfatasa alcalina es una enzima presente sobre todo en el hígado, las vías biliares y el hueso. Sube cuando se obstruye el flujo de la bilis y en fases de formación ósea activa, como el crecimiento en niños y adolescentes o la consolidación de una fractura. Como la liberan ambos órganos, un valor alto suele leerse junto con la GGT y los demás marcadores hepáticos para distinguir un origen hepático de uno óseo. Es uno de los marcadores habituales del perfil hepático.",
+ "bilirubin-total": "La bilirrubina total es el producto amarillo de la degradación de la hemoglobina de los glóbulos rojos envejecidos, medido como la suma de sus formas no conjugada y conjugada. El hígado la capta, la conjuga y la elimina por la bilis, de modo que su nivel refleja cómo funciona esa vía. Puede subir con enfermedades del hígado, obstrucción de las vías biliares o mayor destrucción de glóbulos rojos, y de forma inofensiva en el síndrome de Gilbert. Se interpreta junto con los demás marcadores hepáticos."
},
"apob": "Apolipoproteína B (ApoB)",
"lp-a": "Lipoproteína(a)",
"fasting-insulin": "Insulina en ayunas",
- "omega-3-index": "Índice omega-3"
+ "omega-3-index": "Índice omega-3",
+ "alkaline-phosphatase": "Fosfatasa alcalina",
+ "bilirubin-total": "Bilirrubina total"
},
"noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.",
"showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}",
@@ -9703,6 +9707,54 @@
"fromReport": "Rango del informe {range}",
"catalogDiffers": "tu rango {range}",
"printed": "impreso en {unit}: {range}"
+ },
+ "match": {
+ "viaAlias": "por un nombre que elegiste antes",
+ "viaCatalog": "reconocido como una abreviatura habitual",
+ "chooseLabel": "Pertenece a",
+ "suggestionOption": "{name} ({unit})",
+ "newMarkerOption": "Un biomarcador nuevo",
+ "rememberHint": "Este nombre se recordará para el próximo informe.",
+ "chooseHint": "Elige un biomarcador si ya lo registras."
+ },
+ "aliases": {
+ "title": "También escrito como",
+ "taken": "{alias} ya pertenece a otro biomarcador.",
+ "saveError": "No se pudieron guardar los nombres. Inténtalo de nuevo.",
+ "remove": "Quitar {alias}",
+ "empty": "Añade los nombres que otros laboratorios imprimen para {name}, para que sus informes lleguen aquí.",
+ "placeholder": "p. ej. FA",
+ "addLabel": "Otro nombre para este biomarcador",
+ "add": "Añadir",
+ "alsoWritten": "También escrito como {aliases}"
+ },
+ "merge": {
+ "open": "Combinar",
+ "title": "Combinar biomarcadores",
+ "description": "Une los biomarcadores que son la misma prueba escrita de otra forma.",
+ "pickHint": "Selecciona al menos dos biomarcadores.",
+ "keepLabel": "Conservar",
+ "keepHint": "Los demás pasan a este y sus nombres se convierten en sus otros nombres.",
+ "showPreview": "Ver vista previa",
+ "back": "Atrás",
+ "confirm": "Combinar ahora",
+ "previewSummaryOne": "{count} medición pasa a {name} ({unit}).",
+ "previewSummaryFew": "{count} mediciones pasan a {name} ({unit}).",
+ "previewSummaryOther": "{count} mediciones pasan a {name} ({unit}).",
+ "movesOne": "{count} medición, ya en la misma unidad",
+ "movesFew": "{count} mediciones, ya en la misma unidad",
+ "movesOther": "{count} mediciones, ya en la misma unidad",
+ "convertedOne": "{count} medición, convertida de {unit} a {targetUnit}",
+ "convertedFew": "{count} mediciones, convertidas de {unit} a {targetUnit}",
+ "convertedOther": "{count} mediciones, convertidas de {unit} a {targetUnit}",
+ "refused": "{unit} no se puede convertir a {targetUnit}.",
+ "aliasesAdded": "Estos nombres llevarán al biomarcador conservado:",
+ "blocked": "No se combina nada mientras una unidad no se pueda convertir. Deja fuera ese biomarcador o conserva otro.",
+ "noUndo": "Una combinación no se puede deshacer.",
+ "doneToastOne": "Combinado en {name}, {count} medición movida",
+ "doneToastFew": "Combinado en {name}, {count} mediciones movidas",
+ "doneToastOther": "Combinado en {name}, {count} mediciones movidas",
+ "error": "No se pudieron combinar los biomarcadores. Inténtalo de nuevo."
}
},
"modules": {
@@ -11142,7 +11194,8 @@
"extractEmpty": "No se pudieron extraer valores de este documento.",
"failUnitRequired": "Un valor numérico necesita una unidad antes de poder guardarse.",
"failUnitMismatch": "La unidad indicada no coincide con la unidad guardada de este marcador. Ajústala y termina la revisión de nuevo.",
- "failGeneric": "Este valor no se pudo guardar."
+ "failGeneric": "Este valor no se pudo guardar.",
+ "failUnknownBiomarker": "El biomarcador elegido ya no existe. Elige otro."
},
"contentIndex": {
"indexPrompt": "Haz que el contenido de los documentos sea buscable.",
@@ -12028,7 +12081,8 @@
"customVaccineDelete": "{name} quitó una vacuna propia",
"lifeEventCreate": "{name} añadió un acontecimiento vital",
"lifeEventUpdate": "{name} cambió un acontecimiento vital",
- "lifeEventDelete": "{name} eliminó un acontecimiento vital"
+ "lifeEventDelete": "{name} eliminó un acontecimiento vital",
+ "biomarkerMerge": "{name} combinó biomarcadores"
},
"section": {
"measurements": {
@@ -12247,7 +12301,10 @@
"removed": "Se desvinculó {name}",
"linked": "Vinculados",
"loadNewer": "Cargar más recientes",
- "loadOlder": "Cargar anteriores"
+ "loadOlder": "Cargar anteriores",
+ "period": {
+ "showing": "Mostrando {period}"
+ }
}
},
"bodySites": {
diff --git a/messages/fr.json b/messages/fr.json
index 5d3bd5fd1..72666ef78 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -9624,12 +9624,16 @@
"apob": "L'apolipoprotéine B est la protéine structurale portée par chacune des particules de lipoprotéines athérogènes ; la doser revient donc à compter directement ces particules plutôt que le cholestérol qu'elles transportent. Comme il y a une molécule d'ApoB sur chaque particule de LDL, de VLDL et apparentée, elle peut capter un risque cardiovasculaire qu'une valeur de cholestérol LDL manque, en particulier lorsque le nombre de particules et leur teneur en cholestérol divergent. C'est pourquoi elle est de plus en plus utilisée pour affiner l'évaluation du risque fondée sur les lipides. Elle s'interprète avec le bilan lipidique habituel.",
"lp-a": "La lipoprotéine(a) est une particule semblable au LDL avec une protéine supplémentaire attachée, dont le taux est en grande partie déterminé par la génétique et reste assez stable toute la vie. Une Lp(a) élevée est un facteur de risque indépendant et héréditaire de maladie cardiovasculaire et de rétrécissement de la valve aortique. Comme elle varie peu, on la mesure généralement une seule fois pour établir le risque à vie d'une personne, plutôt que de la suivre de façon répétée. Elle s'exprime en unités molaires et se lit dans le cadre du risque cardiovasculaire global.",
"fasting-insulin": "L'insuline à jeun est le taux d'insuline (l'hormone qui fait passer le glucose du sang vers les cellules) mesuré après un jeûne nocturne. Associée à la glycémie à jeun, elle aide à estimer la résistance à l'insuline, l'état dans lequel le corps a besoin de plus d'insuline pour maintenir le glucose dans sa plage. Une insuline à jeun élevée avec une glycémie normale peut être un signe précoce que cette compensation est en cours. Elle se lit dans le cadre d'un bilan de santé métabolique et non isolément.",
- "omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA (les deux acides gras oméga-3 marins à longue chaîne) parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois."
+ "omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA (les deux acides gras oméga-3 marins à longue chaîne) parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois.",
+ "alkaline-phosphatase": "Les phosphatases alcalines (PAL) sont des enzymes présentes surtout dans le foie, les voies biliaires et l'os. Elles augmentent lorsque l'écoulement de la bile est entravé et pendant les phases de formation osseuse active, comme la croissance chez l'enfant et l'adolescent ou la consolidation d'une fracture. Comme les deux organes les libèrent, une valeur élevée se lit en général avec la GGT et les autres marqueurs hépatiques pour distinguer une origine hépatique d'une origine osseuse. Elles font partie du bilan hépatique habituel.",
+ "bilirubin-total": "La bilirubine totale est le produit jaune de la dégradation de l'hémoglobine des globules rouges usés, mesuré comme la somme de ses formes non conjuguée et conjuguée. Le foie la capte, la conjugue et l'élimine dans la bile, si bien que son taux reflète le bon fonctionnement de cette voie. Elle peut augmenter en cas de maladie du foie, d'obstruction des voies biliaires ou de destruction accrue des globules rouges, et sans gravité dans le syndrome de Gilbert. Elle se lit avec les autres marqueurs hépatiques."
},
"apob": "Apolipoprotéine B (ApoB)",
"lp-a": "Lipoprotéine(a)",
"fasting-insulin": "Insuline à jeun",
- "omega-3-index": "Indice oméga-3"
+ "omega-3-index": "Indice oméga-3",
+ "alkaline-phosphatase": "Phosphatases alcalines (PAL)",
+ "bilirubin-total": "Bilirubine totale"
},
"noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.",
"showingLatestOf": "Affichage des {shown} derniers résultats sur {total}",
@@ -9703,6 +9707,54 @@
"fromReport": "Plage du compte rendu {range}",
"catalogDiffers": "votre plage {range}",
"printed": "imprimé en {unit} : {range}"
+ },
+ "match": {
+ "viaAlias": "par un nom que tu as choisi auparavant",
+ "viaCatalog": "reconnu comme une abréviation courante",
+ "chooseLabel": "Correspond à",
+ "suggestionOption": "{name} ({unit})",
+ "newMarkerOption": "Un nouveau biomarqueur",
+ "rememberHint": "Ce nom sera retenu pour le prochain compte rendu.",
+ "chooseHint": "Choisis un biomarqueur si tu le suis déjà."
+ },
+ "aliases": {
+ "title": "Aussi écrit",
+ "taken": "{alias} appartient déjà à un autre biomarqueur.",
+ "saveError": "Les noms n'ont pas pu être enregistrés. Réessaie.",
+ "remove": "Retirer {alias}",
+ "empty": "Ajoute les noms que d'autres laboratoires impriment pour {name}, pour que leurs comptes rendus arrivent ici.",
+ "placeholder": "p. ex. PAL",
+ "addLabel": "Autre nom pour ce biomarqueur",
+ "add": "Ajouter",
+ "alsoWritten": "Aussi écrit {aliases}"
+ },
+ "merge": {
+ "open": "Fusionner",
+ "title": "Fusionner des biomarqueurs",
+ "description": "Réunis les biomarqueurs qui sont le même examen écrit autrement.",
+ "pickHint": "Sélectionne au moins deux biomarqueurs.",
+ "keepLabel": "Conserver",
+ "keepHint": "Les autres sont fondus dans celui-ci et leurs noms deviennent ses autres noms.",
+ "showPreview": "Voir l'aperçu",
+ "back": "Retour",
+ "confirm": "Fusionner maintenant",
+ "previewSummaryOne": "{count} mesure passe à {name} ({unit}).",
+ "previewSummaryFew": "{count} mesures passent à {name} ({unit}).",
+ "previewSummaryOther": "{count} mesures passent à {name} ({unit}).",
+ "movesOne": "{count} mesure, déjà dans la même unité",
+ "movesFew": "{count} mesures, déjà dans la même unité",
+ "movesOther": "{count} mesures, déjà dans la même unité",
+ "convertedOne": "{count} mesure, convertie de {unit} en {targetUnit}",
+ "convertedFew": "{count} mesures, converties de {unit} en {targetUnit}",
+ "convertedOther": "{count} mesures, converties de {unit} en {targetUnit}",
+ "refused": "{unit} ne peut pas être converti en {targetUnit}.",
+ "aliasesAdded": "Ces noms mèneront au biomarqueur conservé :",
+ "blocked": "Rien n'est fusionné tant qu'une unité ne peut pas être convertie. Laisse ce biomarqueur de côté ou conserve-en un autre.",
+ "noUndo": "Une fusion ne peut pas être annulée.",
+ "doneToastOne": "Fusionné dans {name}, {count} mesure déplacée",
+ "doneToastFew": "Fusionné dans {name}, {count} mesures déplacées",
+ "doneToastOther": "Fusionné dans {name}, {count} mesures déplacées",
+ "error": "Les biomarqueurs n'ont pas pu être fusionnés. Réessaie."
}
},
"modules": {
@@ -11142,7 +11194,8 @@
"extractEmpty": "Aucune valeur n’a pu être extraite de ce document.",
"failUnitRequired": "Une valeur numérique a besoin d’une unité avant de pouvoir être enregistrée.",
"failUnitMismatch": "L’unité indiquée ne correspond pas à l’unité enregistrée pour ce marqueur. Ajustez-la, puis terminez à nouveau la vérification.",
- "failGeneric": "Cette valeur n’a pas pu être enregistrée."
+ "failGeneric": "Cette valeur n’a pas pu être enregistrée.",
+ "failUnknownBiomarker": "Le biomarqueur choisi n'existe plus. Choisis-en un autre."
},
"contentIndex": {
"indexPrompt": "Rendre le contenu des documents consultable.",
@@ -12028,7 +12081,8 @@
"customVaccineDelete": "{name} a retiré un vaccin personnel",
"lifeEventCreate": "{name} a ajouté un événement de vie",
"lifeEventUpdate": "{name} a modifié un événement de vie",
- "lifeEventDelete": "{name} a supprimé un événement de vie"
+ "lifeEventDelete": "{name} a supprimé un événement de vie",
+ "biomarkerMerge": "{name} a fusionné des biomarqueurs"
},
"section": {
"measurements": {
@@ -12247,7 +12301,10 @@
"removed": "{name} dissocié",
"linked": "Liés",
"loadNewer": "Charger les plus récents",
- "loadOlder": "Charger les plus anciens"
+ "loadOlder": "Charger les plus anciens",
+ "period": {
+ "showing": "Période : {period}"
+ }
}
},
"bodySites": {
diff --git a/messages/it.json b/messages/it.json
index adaf75f83..2bb621f5c 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -9624,12 +9624,16 @@
"apob": "L'apolipoproteina B è la proteina strutturale presente su ciascuna delle particelle di lipoproteine aterogene, quindi misurarla significa contare direttamente quelle particelle anziché il colesterolo che trasportano. Poiché c'è una molecola di ApoB su ogni particella di LDL, VLDL e affini, può cogliere un rischio cardiovascolare che un valore di colesterolo LDL non rileva, soprattutto quando il numero di particelle e il loro contenuto di colesterolo divergono. Per questo è sempre più usata per affinare la valutazione del rischio basata sui lipidi. Si interpreta insieme al consueto profilo lipidico.",
"lp-a": "La lipoproteina(a) è una particella simile all'LDL con una proteina aggiuntiva attaccata, il cui livello è in gran parte determinato dalla genetica e resta piuttosto stabile per tutta la vita. Una Lp(a) elevata è un fattore di rischio indipendente ed ereditario di malattia cardiovascolare e di restringimento della valvola aortica. Poiché varia poco, di solito si misura una sola volta per stabilire il rischio di una persona lungo l'arco della vita, anziché seguirla ripetutamente. Si esprime in unità molari e si legge nell'ambito del rischio cardiovascolare complessivo.",
"fasting-insulin": "L'insulina a digiuno è il livello di insulina (l'ormone che fa passare il glucosio dal sangue alle cellule) misurato dopo un digiuno notturno. Insieme alla glicemia a digiuno, aiuta a stimare la resistenza all'insulina, lo stato in cui il corpo ha bisogno di più insulina per mantenere il glucosio nel suo intervallo. Un'insulina a digiuno alta con una glicemia normale può essere un segno precoce che questa compensazione è in atto. Si legge nell'ambito di un quadro di salute metabolica e non isolatamente.",
- "omega-3-index": "L'indice omega-3 è la quota di EPA e DHA (i due acidi grassi omega-3 marini a catena lunga) tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi."
+ "omega-3-index": "L'indice omega-3 è la quota di EPA e DHA (i due acidi grassi omega-3 marini a catena lunga) tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi.",
+ "alkaline-phosphatase": "La fosfatasi alcalina (ALP) è un enzima presente soprattutto nel fegato, nelle vie biliari e nell'osso. Sale quando il flusso della bile è ostacolato e nelle fasi di formazione ossea attiva, come la crescita nei bambini e negli adolescenti o la guarigione di una frattura. Poiché la rilasciano entrambi gli organi, un valore alto si legge di solito insieme alla GGT e agli altri indici epatici per distinguere un'origine epatica da una ossea. È uno degli indici abituali del profilo epatico.",
+ "bilirubin-total": "La bilirubina totale è il prodotto giallo della degradazione dell'emoglobina dei globuli rossi invecchiati, misurato come somma delle sue forme non coniugata e coniugata. Il fegato la capta, la coniuga e la elimina con la bile, quindi il suo livello riflette il funzionamento di questa via. Può salire con malattie del fegato, ostruzione delle vie biliari o aumentata distruzione dei globuli rossi, e in modo innocuo nella sindrome di Gilbert. Si legge insieme agli altri indici epatici."
},
"apob": "Apolipoproteina B (ApoB)",
"lp-a": "Lipoproteina(a)",
"fasting-insulin": "Insulina a digiuno",
- "omega-3-index": "Indice omega-3"
+ "omega-3-index": "Indice omega-3",
+ "alkaline-phosphatase": "Fosfatasi alcalina (ALP)",
+ "bilirubin-total": "Bilirubina totale"
},
"noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.",
"showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}",
@@ -9703,6 +9707,54 @@
"fromReport": "Intervallo del referto {range}",
"catalogDiffers": "il tuo intervallo {range}",
"printed": "stampato in {unit}: {range}"
+ },
+ "match": {
+ "viaAlias": "tramite un nome che hai scelto in precedenza",
+ "viaCatalog": "riconosciuto come abbreviazione comune",
+ "chooseLabel": "Appartiene a",
+ "suggestionOption": "{name} ({unit})",
+ "newMarkerOption": "Un nuovo biomarcatore",
+ "rememberHint": "Questo nome verrà ricordato per il prossimo referto.",
+ "chooseHint": "Scegli un biomarcatore se lo registri già."
+ },
+ "aliases": {
+ "title": "Scritto anche come",
+ "taken": "{alias} appartiene già a un altro biomarcatore.",
+ "saveError": "Non è stato possibile salvare i nomi. Riprova.",
+ "remove": "Rimuovi {alias}",
+ "empty": "Aggiungi i nomi che altri laboratori stampano per {name}, così i loro referti arrivano qui.",
+ "placeholder": "es. ALP",
+ "addLabel": "Altro nome per questo biomarcatore",
+ "add": "Aggiungi",
+ "alsoWritten": "Scritto anche come {aliases}"
+ },
+ "merge": {
+ "open": "Unisci",
+ "title": "Unisci biomarcatori",
+ "description": "Riunisci i biomarcatori che sono lo stesso esame scritto in modo diverso.",
+ "pickHint": "Seleziona almeno due biomarcatori.",
+ "keepLabel": "Mantieni",
+ "keepHint": "Gli altri confluiscono in questo e i loro nomi diventano i suoi altri nomi.",
+ "showPreview": "Mostra anteprima",
+ "back": "Indietro",
+ "confirm": "Unisci ora",
+ "previewSummaryOne": "{count} misurazione passa a {name} ({unit}).",
+ "previewSummaryFew": "{count} misurazioni passano a {name} ({unit}).",
+ "previewSummaryOther": "{count} misurazioni passano a {name} ({unit}).",
+ "movesOne": "{count} misurazione, già nella stessa unità",
+ "movesFew": "{count} misurazioni, già nella stessa unità",
+ "movesOther": "{count} misurazioni, già nella stessa unità",
+ "convertedOne": "{count} misurazione, convertita da {unit} a {targetUnit}",
+ "convertedFew": "{count} misurazioni, convertite da {unit} a {targetUnit}",
+ "convertedOther": "{count} misurazioni, convertite da {unit} a {targetUnit}",
+ "refused": "{unit} non può essere convertito in {targetUnit}.",
+ "aliasesAdded": "Questi nomi porteranno al biomarcatore mantenuto:",
+ "blocked": "Non si unisce nulla finché un'unità non può essere convertita. Escludi quel biomarcatore o mantienine un altro.",
+ "noUndo": "Un'unione non può essere annullata.",
+ "doneToastOne": "Unito in {name}, {count} misurazione spostata",
+ "doneToastFew": "Unito in {name}, {count} misurazioni spostate",
+ "doneToastOther": "Unito in {name}, {count} misurazioni spostate",
+ "error": "Non è stato possibile unire i biomarcatori. Riprova."
}
},
"modules": {
@@ -11142,7 +11194,8 @@
"extractEmpty": "Non è stato possibile estrarre valori da questo documento.",
"failUnitRequired": "Un valore numerico ha bisogno di un’unità prima di poter essere salvato.",
"failUnitMismatch": "L’unità indicata non corrisponde all’unità salvata per questo marcatore. Correggila e concludi di nuovo la revisione.",
- "failGeneric": "Questo valore non è stato salvato."
+ "failGeneric": "Questo valore non è stato salvato.",
+ "failUnknownBiomarker": "Il biomarcatore scelto non esiste più. Scegline un altro."
},
"contentIndex": {
"indexPrompt": "Rendi ricercabile il contenuto dei documenti.",
@@ -12028,7 +12081,8 @@
"customVaccineDelete": "{name} ha rimosso un vaccino personale",
"lifeEventCreate": "{name} ha aggiunto un evento della vita",
"lifeEventUpdate": "{name} ha modificato un evento della vita",
- "lifeEventDelete": "{name} ha rimosso un evento della vita"
+ "lifeEventDelete": "{name} ha rimosso un evento della vita",
+ "biomarkerMerge": "{name} ha unito dei biomarcatori"
},
"section": {
"measurements": {
@@ -12247,7 +12301,10 @@
"removed": "Collegamento a {name} rimosso",
"linked": "Collegati",
"loadNewer": "Carica più recenti",
- "loadOlder": "Carica meno recenti"
+ "loadOlder": "Carica meno recenti",
+ "period": {
+ "showing": "Periodo: {period}"
+ }
}
},
"bodySites": {
diff --git a/messages/ko.json b/messages/ko.json
index e958a666a..6129f6db7 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -9624,12 +9624,16 @@
"apob": "아포지단백 B는 죽상경화를 일으키는 지단백 입자마다 하나씩 붙어 있는 구조 단백질이라, 이 값을 재면 입자가 나르는 콜레스테롤이 아니라 입자 수를 직접 세는 셈이에요. LDL과 VLDL, 관련 입자 하나에 ApoB 분자가 하나씩 있기 때문에, 입자 수와 콜레스테롤 양이 어긋날 때 LDL 콜레스테롤이 놓치는 심혈관 위험을 잡아낼 수 있어요. 그래서 지질 기반 위험 평가를 다듬는 데 점점 많이 쓰여요. 기본 지질 검사와 함께 해석해요.",
"lp-a": "지단백(a)는 LDL과 비슷한 입자에 단백질이 하나 더 붙은 형태로, 수치는 대체로 유전으로 정해지고 평생 꽤 일정하게 유지돼요. Lp(a)가 높으면 심혈관 질환과 대동맥판막 협착의 독립적인 유전 위험 요인이에요. 거의 변하지 않기 때문에 반복해서 추적하기보다 평생 위험을 파악하려고 보통 한 번만 재요. 몰 단위로 보고되고 전체 심혈관 위험의 일부로 읽어요.",
"fasting-insulin": "공복 인슐린은 혈액 속 포도당을 세포로 옮기는 호르몬인 인슐린을 밤새 금식한 뒤 잰 값이에요. 공복혈당과 함께 보면 혈당을 유지하려고 몸이 더 많은 인슐린을 필요로 하는 상태, 즉 인슐린 저항성을 가늠할 수 있어요. 혈당은 정상인데 공복 인슐린이 높다면 이런 보상이 이미 진행 중이라는 이른 신호일 수 있어요. 단독으로 보기보다 대사 건강 전체 그림의 일부로 읽어요.",
- "omega-3-index": "오메가3 지수는 적혈구 막의 지방산 가운데 긴사슬 해양 오메가3 지방산인 EPA와 DHA가 차지하는 비율이에요. 적혈구는 천천히 교체되기 때문에 최근 식사가 아니라 장기적인 오메가3 섭취를 반영해요. 지수가 높을수록 관찰연구에서 심혈관 위험이 낮은 것과 연관됐어요. 몇 달에 걸쳐 식사에 반응하는 안정적인 오메가3 상태 지표로 기록해요."
+ "omega-3-index": "오메가3 지수는 적혈구 막의 지방산 가운데 긴사슬 해양 오메가3 지방산인 EPA와 DHA가 차지하는 비율이에요. 적혈구는 천천히 교체되기 때문에 최근 식사가 아니라 장기적인 오메가3 섭취를 반영해요. 지수가 높을수록 관찰연구에서 심혈관 위험이 낮은 것과 연관됐어요. 몇 달에 걸쳐 식사에 반응하는 안정적인 오메가3 상태 지표로 기록해요.",
+ "alkaline-phosphatase": "알칼리성 인산분해효소(ALP)는 주로 간, 담관, 뼈에 있는 효소예요. 담즙 흐름이 막히거나, 어린이와 청소년의 성장기나 골절이 아무는 시기처럼 뼈가 활발히 만들어질 때 올라가요. 두 기관 모두에서 나오기 때문에 높은 값은 보통 GGT와 다른 간 지표와 함께 읽어서 간에서 온 것인지 뼈에서 온 것인지 구분해요. 기본 간 검사 지표 중 하나예요.",
+ "bilirubin-total": "총빌리루빈은 수명이 다한 적혈구의 헤모글로빈이 분해되면서 생기는 노란 물질로, 비포합형과 포합형을 합친 값으로 측정해요. 간이 이를 받아들여 포합한 뒤 담즙으로 내보내기 때문에, 수치는 이 경로가 얼마나 잘 작동하는지를 보여 줘요. 간 질환, 담관 폐쇄, 적혈구 파괴 증가 때 올라갈 수 있고, 길버트 증후군에서는 해롭지 않게 올라가기도 해요. 다른 간 지표와 함께 읽어요."
},
"apob": "아포지단백 B (ApoB)",
"lp-a": "지단백(a)",
"fasting-insulin": "공복 인슐린",
- "omega-3-index": "오메가3 지수"
+ "omega-3-index": "오메가3 지수",
+ "alkaline-phosphatase": "알칼리성 인산분해효소(ALP)",
+ "bilirubin-total": "총빌리루빈"
},
"noteLoadFailed": "메모를 불러오지 못했어요. 저장해도 메모는 그대로 유지돼요.",
"showingLatestOf": "측정값 {total}개 중 최근 {shown}개를 보여주고 있어요",
@@ -9703,6 +9707,54 @@
"fromReport": "결과지 범위 {range}",
"catalogDiffers": "내 범위 {range}",
"printed": "결과지 원문({unit}): {range}"
+ },
+ "match": {
+ "viaAlias": "전에 고른 이름으로 연결됨",
+ "viaCatalog": "흔히 쓰는 약어로 인식됨",
+ "chooseLabel": "연결할 항목",
+ "suggestionOption": "{name} ({unit})",
+ "newMarkerOption": "새 바이오마커",
+ "rememberHint": "다음 결과지를 위해 이 이름을 기억해요.",
+ "chooseHint": "이미 기록 중인 바이오마커라면 골라 주세요."
+ },
+ "aliases": {
+ "title": "다른 표기",
+ "taken": "{alias}은(는) 이미 다른 바이오마커에 연결돼 있어요.",
+ "saveError": "이름을 저장하지 못했어요. 다시 시도해 주세요.",
+ "remove": "{alias} 삭제",
+ "empty": "다른 검사기관이 {name}에 쓰는 이름을 추가하면 그 결과지도 여기로 모여요.",
+ "placeholder": "예: ALP",
+ "addLabel": "이 바이오마커의 다른 이름",
+ "add": "추가",
+ "alsoWritten": "다른 표기: {aliases}"
+ },
+ "merge": {
+ "open": "합치기",
+ "title": "바이오마커 합치기",
+ "description": "이름만 다르게 적힌 같은 검사 항목을 하나로 모아요.",
+ "pickHint": "바이오마커를 두 개 이상 골라 주세요.",
+ "keepLabel": "남길 항목",
+ "keepHint": "나머지는 이 항목으로 옮겨지고, 그 이름은 이 항목의 다른 표기가 돼요.",
+ "showPreview": "미리 보기",
+ "back": "뒤로",
+ "confirm": "지금 합치기",
+ "previewSummaryOne": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.",
+ "previewSummaryFew": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.",
+ "previewSummaryOther": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.",
+ "movesOne": "측정값 {count}개, 이미 같은 단위",
+ "movesFew": "측정값 {count}개, 이미 같은 단위",
+ "movesOther": "측정값 {count}개, 이미 같은 단위",
+ "convertedOne": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
+ "convertedFew": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
+ "convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
+ "refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.",
+ "aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:",
+ "blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.",
+ "noUndo": "합친 뒤에는 되돌릴 수 없어요.",
+ "doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
+ "doneToastFew": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
+ "doneToastOther": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
+ "error": "바이오마커를 합치지 못했어요. 다시 시도해 주세요."
}
},
"modules": {
@@ -11142,7 +11194,8 @@
"extractEmpty": "이 문서에서 추출할 수 있는 값이 없었어요.",
"failUnitRequired": "숫자 값을 저장하려면 단위가 필요해요.",
"failUnitMismatch": "기재된 단위가 이 지표에 저장된 단위와 달라요. 수정한 뒤 검토를 다시 완료해 주세요.",
- "failGeneric": "이 값을 저장하지 못했어요."
+ "failGeneric": "이 값을 저장하지 못했어요.",
+ "failUnknownBiomarker": "고른 바이오마커가 더 이상 없어요. 다른 항목을 골라 주세요."
},
"contentIndex": {
"indexPrompt": "문서 내용을 검색할 수 있게 만들어요.",
@@ -12028,7 +12081,8 @@
"customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요",
"lifeEventCreate": "{name} 님이 삶의 사건을 추가했어요",
"lifeEventUpdate": "{name} 님이 삶의 사건을 수정했어요",
- "lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요"
+ "lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요",
+ "biomarkerMerge": "{name} 님이 바이오마커를 합쳤어요"
},
"section": {
"measurements": {
@@ -12247,7 +12301,10 @@
"removed": "{name} 연결 해제됨",
"linked": "연결됨",
"loadNewer": "최근 항목 더 보기",
- "loadOlder": "이전 항목 더 보기"
+ "loadOlder": "이전 항목 더 보기",
+ "period": {
+ "showing": "{period} 표시 중"
+ }
}
},
"bodySites": {
diff --git a/messages/pl.json b/messages/pl.json
index 4cf8b75a8..6d71a7276 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -9624,12 +9624,16 @@
"apob": "Apolipoproteina B to białko strukturalne obecne na każdej z aterogennych cząstek lipoprotein, więc jej pomiar zlicza te cząstki bezpośrednio, a nie cholesterol, który przenoszą. Ponieważ na każdej cząstce LDL, VLDL i pokrewnej znajduje się jedna cząsteczka ApoB, może ona wychwycić ryzyko sercowo-naczyniowe, które umyka wartości cholesterolu LDL, zwłaszcza gdy liczba cząstek i ich zawartość cholesterolu się rozchodzą. Z tego powodu coraz częściej używa się jej do uściślenia oceny ryzyka opartej na lipidach. Interpretuje się ją wraz ze zwykłym profilem lipidowym.",
"lp-a": "Lipoproteina(a) to cząstka podobna do LDL z dodatkowo przyłączonym białkiem, której poziom jest w dużej mierze uwarunkowany genetycznie i pozostaje dość stabilny przez całe życie. Podwyższona Lp(a) jest niezależnym, dziedzicznym czynnikiem ryzyka chorób sercowo-naczyniowych i zwężenia zastawki aortalnej. Ponieważ niewiele się zmienia, zwykle oznacza się ją raz, aby ustalić ryzyko danej osoby na całe życie, a nie śledzić wielokrotnie. Podaje się ją w jednostkach molowych i odczytuje w ramach całościowego ryzyka sercowo-naczyniowego.",
"fasting-insulin": "Insulina na czczo to poziom insuliny (hormonu, który przenosi glukozę z krwi do komórek) mierzony po nocnym poście. W połączeniu z glukozą na czczo pomaga oszacować insulinooporność, czyli stan, w którym organizm potrzebuje więcej insuliny, aby utrzymać glukozę w zakresie. Wysoka insulina na czczo przy prawidłowej glukozie może być wczesnym znakiem, że ta kompensacja jest w toku. Odczytuje się ją w ramach obrazu zdrowia metabolicznego, a nie w oderwaniu.",
- "omega-3-index": "Indeks omega-3 to udział EPA i DHA (dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3) wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy."
+ "omega-3-index": "Indeks omega-3 to udział EPA i DHA (dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3) wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy.",
+ "alkaline-phosphatase": "Fosfataza alkaliczna (ALP) to enzym występujący głównie w wątrobie, drogach żółciowych i kościach. Rośnie przy utrudnionym odpływie żółci oraz w okresach aktywnej przebudowy kości, na przykład podczas wzrostu u dzieci i młodzieży albo gojenia złamania. Ponieważ uwalniają ją oba narządy, podwyższony wynik czyta się zwykle razem z GGTP i pozostałymi próbami wątrobowymi, aby odróżnić przyczynę wątrobową od kostnej. Należy do standardowych prób wątrobowych.",
+ "bilirubin-total": "Bilirubina całkowita to żółty produkt rozpadu hemoglobiny ze zużytych czerwonych krwinek, mierzony jako suma jej postaci niesprzężonej i sprzężonej. Wątroba ją wychwytuje, sprzęga i wydala z żółcią, więc jej poziom pokazuje, jak działa ta droga. Może rosnąć przy chorobach wątroby, zablokowanych drogach żółciowych lub nasilonym rozpadzie czerwonych krwinek, a nieszkodliwie w zespole Gilberta. Odczytuje się ją razem z pozostałymi próbami wątrobowymi."
},
"apob": "Apolipoproteina B (ApoB)",
"lp-a": "Lipoproteina(a)",
"fasting-insulin": "Insulina na czczo",
- "omega-3-index": "Indeks omega-3"
+ "omega-3-index": "Indeks omega-3",
+ "alkaline-phosphatase": "Fosfataza alkaliczna (ALP)",
+ "bilirubin-total": "Bilirubina całkowita"
},
"noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.",
"showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników",
@@ -9703,6 +9707,54 @@
"fromReport": "Zakres z wyniku {range}",
"catalogDiffers": "twój zakres {range}",
"printed": "wydrukowano w {unit}: {range}"
+ },
+ "match": {
+ "viaAlias": "według nazwy, którą wybrałeś wcześniej",
+ "viaCatalog": "rozpoznano jako popularny skrót",
+ "chooseLabel": "Należy do",
+ "suggestionOption": "{name} ({unit})",
+ "newMarkerOption": "Nowy biomarker",
+ "rememberHint": "Ta nazwa zostanie zapamiętana na następny wynik.",
+ "chooseHint": "Wybierz biomarker, jeśli już go śledzisz."
+ },
+ "aliases": {
+ "title": "Zapisywany też jako",
+ "taken": "{alias} należy już do innego biomarkera.",
+ "saveError": "Nie udało się zapisać nazw. Spróbuj ponownie.",
+ "remove": "Usuń {alias}",
+ "empty": "Dodaj nazwy, które inne laboratoria drukują dla {name}, aby ich wyniki trafiały tutaj.",
+ "placeholder": "np. ALP",
+ "addLabel": "Inna nazwa tego biomarkera",
+ "add": "Dodaj",
+ "alsoWritten": "Zapisywany też jako {aliases}"
+ },
+ "merge": {
+ "open": "Scal",
+ "title": "Scal biomarkery",
+ "description": "Połącz biomarkery, które są tym samym badaniem zapisanym inaczej.",
+ "pickHint": "Zaznacz co najmniej dwa biomarkery.",
+ "keepLabel": "Zachowaj",
+ "keepHint": "Pozostałe zostaną włączone do tego, a ich nazwy staną się jego innymi nazwami.",
+ "showPreview": "Pokaż podgląd",
+ "back": "Wstecz",
+ "confirm": "Scal teraz",
+ "previewSummaryOne": "{count} pomiar przechodzi do {name} ({unit}).",
+ "previewSummaryFew": "{count} pomiary przechodzą do {name} ({unit}).",
+ "previewSummaryOther": "{count} pomiarów przechodzi do {name} ({unit}).",
+ "movesOne": "{count} pomiar, już w tej samej jednostce",
+ "movesFew": "{count} pomiary, już w tej samej jednostce",
+ "movesOther": "{count} pomiarów, już w tej samej jednostce",
+ "convertedOne": "{count} pomiar, przeliczony z {unit} na {targetUnit}",
+ "convertedFew": "{count} pomiary, przeliczone z {unit} na {targetUnit}",
+ "convertedOther": "{count} pomiarów, przeliczonych z {unit} na {targetUnit}",
+ "refused": "Nie można przeliczyć {unit} na {targetUnit}.",
+ "aliasesAdded": "Te nazwy będą prowadzić do zachowanego biomarkera:",
+ "blocked": "Nic nie zostanie scalone, dopóki jednej jednostki nie da się przeliczyć. Pomiń ten biomarker albo zachowaj inny.",
+ "noUndo": "Scalenia nie można cofnąć.",
+ "doneToastOne": "Scalono w {name}, przeniesiono {count} pomiar",
+ "doneToastFew": "Scalono w {name}, przeniesiono {count} pomiary",
+ "doneToastOther": "Scalono w {name}, przeniesiono {count} pomiarów",
+ "error": "Nie udało się scalić biomarkerów. Spróbuj ponownie."
}
},
"modules": {
@@ -11142,7 +11194,8 @@
"extractEmpty": "Z tego dokumentu nie udało się odczytać żadnych wartości.",
"failUnitRequired": "Wartość liczbowa potrzebuje jednostki, zanim będzie można ją zapisać.",
"failUnitMismatch": "Podana jednostka nie zgadza się z zapisaną jednostką tego wskaźnika. Popraw ją i zakończ przegląd ponownie.",
- "failGeneric": "Nie udało się zapisać tej wartości."
+ "failGeneric": "Nie udało się zapisać tej wartości.",
+ "failUnknownBiomarker": "Wybrany biomarker już nie istnieje. Wybierz inny."
},
"contentIndex": {
"indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.",
@@ -12028,7 +12081,8 @@
"customVaccineDelete": "{name} usunął własną szczepionkę",
"lifeEventCreate": "{name} dodał wydarzenie życiowe",
"lifeEventUpdate": "{name} zmienił wydarzenie życiowe",
- "lifeEventDelete": "{name} usunął wydarzenie życiowe"
+ "lifeEventDelete": "{name} usunął wydarzenie życiowe",
+ "biomarkerMerge": "{name} scalił biomarkery"
},
"section": {
"measurements": {
@@ -12247,7 +12301,10 @@
"removed": "Odłączono: {name}",
"linked": "Połączone",
"loadNewer": "Wczytaj nowsze",
- "loadOlder": "Wczytaj starsze"
+ "loadOlder": "Wczytaj starsze",
+ "period": {
+ "showing": "Okres: {period}"
+ }
}
},
"bodySites": {
diff --git a/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 0d71bcd34..daf6c0df4 100644
--- a/src/__tests__/contract-stub-inventory.test.ts
+++ b/src/__tests__/contract-stub-inventory.test.ts
@@ -39,7 +39,6 @@ const CONTRACT_STUBS: Readonly> = {
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
"auth/claim/sso/route.ts": ["profile_claim.sso.start"],
"auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"],
- "biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"],
"dawarich/connect/route.ts": ["dawarich.connect"],
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
"dawarich/places/route.ts": ["dawarich.places.get"],
diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts
index cf8be76b0..a00e4ca65 100644
--- a/src/__tests__/sharing-surface-guard.test.ts
+++ b/src/__tests__/sharing-surface-guard.test.ts
@@ -859,6 +859,10 @@ const DELEGABLE_ROUTES: Record = {
domain: "labs",
why: "One biomarker of the record, fetch-then-guard against the resolved user.",
},
+ "app/api/biomarkers/[id]/merge/route.ts": {
+ domain: "labs",
+ why: "Merging markers of the record into one (v1.43): every read and write is narrowed to the resolved user, and the sources must be that record's markers.",
+ },
"app/api/allergies/route.ts": {
domain: "profile",
why: "The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows. The POST beside it is NOT delegable and is deliberately absent from the write literal below: the only surface that posts to it lives under `/settings`, which a switch closes, so admitting the write would freeze a permission ahead of any caller for it. The route comment carries the argument.",
@@ -1612,6 +1616,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = {
conditions: ["C3", "C4"],
why: "Editing an analyte's reference range, the one admitted edit whose error is silent, which is why C4 puts it in the feed. The DELETE beside it resolves through the same labs authority since v1.43 instead of a bare requireAuth; it hard-deletes the marker with its readings, so C3 files the name, unit and reading count it took.",
},
+ "app/api/biomarkers/[id]/merge/route.ts": {
+ domain: "labs",
+ conditions: ["C3"],
+ why: "Merging markers moves their readings into one and deletes the sources, with no undo, so it sits at the same level as the delete beside it. C3: each deleted source files its id and how many readings went with it; the name stays in the merge ledger. The shared record-write ceiling is charged to the actor, like every record write.",
+ },
"app/api/allergies/route.ts": {
domain: "profile",
conditions: [],
@@ -2117,8 +2126,11 @@ const ACTOR_ROUTES: Record = {
* facts (`profile`) and the nutrient catalogue beside the nutrient reads
* (`measurements`). The biomarker DELETE joins the manage literal on a module
* that was already there. 256 -> 258.
+ *
+ * v1.43 -- merging biomarkers joins the record list and the manage literal
+ * (D1223). 258 -> 260.
*/
-const FROZEN_ENTRY_COUNT = 258;
+const FROZEN_ENTRY_COUNT = 260;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -2879,7 +2891,7 @@ describe("(g) the MANAGE route set is frozen", () => {
it("keeps the admitted mutation inventory complete and discoverable", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
- expect(ADMITTED_MUTATING_HANDLERS.length).toBe(88);
+ expect(ADMITTED_MUTATING_HANDLERS.length).toBe(89);
const expected = ADMITTED_MUTATING_HANDLERS.map(
({ handlerModule, action, level }) =>
diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts
index 6ef3fb5ba..6a9646dc2 100644
--- a/src/__tests__/v137-wave-zero-fixtures.test.ts
+++ b/src/__tests__/v137-wave-zero-fixtures.test.ts
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
- expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(88);
+ expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(89);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index 601873978..590730f6c 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -89,7 +89,7 @@ const SERVER_FEATURES = {
// form work; until then a client must not send it (contract §0.6).
cumulativeHourBuckets: false,
syncTriggerBackfill: false,
- biomarkerMerge: false,
+ biomarkerMerge: true,
lifeCircumstances: false,
} as const;
diff --git a/src/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 1f143900a..066fcae20 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/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts
index bd0a478da..115bf4bc6 100644
--- a/src/app/api/export/__tests__/per-type-routes.test.ts
+++ b/src/app/api/export/__tests__/per-type-routes.test.ts
@@ -86,6 +86,8 @@ vi.mock("@/lib/db", () => ({
// helper now also reads (`buildRecordsBackupSection`).
labResult: { findMany: vi.fn() },
biomarker: { findMany: vi.fn() },
+ biomarkerAlias: { findMany: vi.fn() },
+ biomarkerMerge: { findMany: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
@@ -135,6 +137,8 @@ beforeEach(() => {
// `resetAllMocks` clears the inline defaults, and the full-backup payload
// builder reads these unconditionally.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.biomarkerMerge.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationCategoryLabel.findMany).mockResolvedValue(
[] as never,
);
diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts
index 288b99d8e..edbfc9d0b 100644
--- a/src/app/api/export/__tests__/soft-delete-filter.test.ts
+++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts
@@ -67,6 +67,8 @@ vi.mock("@/lib/db", () => ({
cycleDayLog: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
biomarker: { findMany: vi.fn() },
+ biomarkerAlias: { findMany: vi.fn() },
+ biomarkerMerge: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
@@ -205,6 +207,8 @@ beforeEach(() => {
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.biomarkerMerge.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts
index a96e6ac92..7ea794bdd 100644
--- a/src/app/api/export/encrypted/__tests__/route.test.ts
+++ b/src/app/api/export/encrypted/__tests__/route.test.ts
@@ -77,6 +77,8 @@ vi.mock("@/lib/db", () => ({
// builder now also reads (`buildRecordsBackupSection`).
labResult: { findMany: vi.fn().mockResolvedValue([]) },
biomarker: { findMany: vi.fn().mockResolvedValue([]) },
+ biomarkerAlias: { findMany: vi.fn().mockResolvedValue([]) },
+ biomarkerMerge: { findMany: vi.fn().mockResolvedValue([]) },
illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) },
allergy: { findMany: vi.fn().mockResolvedValue([]) },
familyHistoryEntry: { findMany: vi.fn().mockResolvedValue([]) },
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/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx
index 209e10413..5d980d908 100644
--- a/src/components/documents/__tests__/document-record-links.test.tsx
+++ b/src/components/documents/__tests__/document-record-links.test.tsx
@@ -18,6 +18,7 @@ vi.mock("@/components/links/use-link-picker-pages", async (importOriginal) => ({
setTerm: vi.fn(),
searchPending: false,
filterLocally: true,
+ period: null,
hasOlder: false,
hasNewer: false,
loadingOlder: false,
diff --git a/src/components/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx
index 2bf29dc82..692e2b58a 100644
--- a/src/components/documents/document-facts-review.tsx
+++ b/src/components/documents/document-facts-review.tsx
@@ -26,6 +26,7 @@
*/
import { useCalendarDate } from "@/hooks/use-calendar-date";
import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
import { FlaskConical, Loader2, ScanSearch } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
@@ -36,7 +37,11 @@ import { Checkbox } from "@/components/ui/checkbox";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { MarkerMatchField } from "@/components/labs/marker-match-field";
+import type { BiomarkerListResponse } from "@/components/labs/types";
+import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type {
ConditionFactData,
@@ -62,6 +67,8 @@ function commitFailureKey(reason: string): string {
return "documents.review.failUnitRequired";
case "observation.unitMismatch":
return "documents.review.failUnitMismatch";
+ case "observation.unknownBiomarker":
+ return "documents.review.failUnknownBiomarker";
default:
return "documents.review.failGeneric";
}
@@ -203,6 +210,9 @@ function FactRow({
checked,
onCheckedChange,
failure,
+ chosenBiomarkerId,
+ onChooseBiomarker,
+ markerName,
}: {
documentId: string;
fact: ExtractedFactDto;
@@ -210,6 +220,11 @@ function FactRow({
onCheckedChange: (checked: boolean) => void;
/** A per-fact commit refusal from the last confirm attempt, or null. */
failure: string | null;
+ /** v1.43 — the suggestion the person picked for a lab value, or null. */
+ chosenBiomarkerId: string | null;
+ onChooseBiomarker: (biomarkerId: string | null) => void;
+ /** The account's marker name by id, for the matched marker. */
+ markerName: (biomarkerId: string) => string | null;
}) {
const { t } = useTranslations();
const calendarDate = useCalendarDate();
@@ -265,6 +280,25 @@ function FactRow({
{typeLabel}
{date ? `, ${calendarDate(date)}` : ""}
+ )}
+
+ );
+}
diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx
index 36573bea5..24fdd8c7f 100644
--- a/src/components/labs/lab-biomarker-detail.tsx
+++ b/src/components/labs/lab-biomarker-detail.tsx
@@ -34,7 +34,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
-import { apiDelete, apiGet } from "@/lib/api/api-fetch";
+import { ApiError, apiDelete, apiGet } from "@/lib/api/api-fetch";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { useTranslations } from "@/lib/i18n/context";
@@ -52,6 +52,7 @@ import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
+import { BiomarkerAliases } from "./biomarker-aliases";
import { BiomarkerForm } from "./biomarker-form";
import { LabForm } from "./lab-form";
import type {
@@ -158,12 +159,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const {
data: marker,
isError: markerError,
+ error: markerFailure,
refetch: refetchMarker,
} = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
queryFn: () => apiGet(`/api/biomarkers/${biomarkerId}`),
});
+ // v1.43 (D1223) — a marker merged into another answers 404 with the
+ // marker it went into; a bookmark or a stale screen follows it there.
+ const mergedInto =
+ markerFailure instanceof ApiError &&
+ markerFailure.status === 404 &&
+ typeof markerFailure.meta?.mergedInto === "string"
+ ? markerFailure.meta.mergedInto
+ : null;
+ useEffect(() => {
+ if (!mergedInto) return;
+ queryClient.removeQueries({
+ queryKey: queryKeys.biomarkerDetail(biomarkerId),
+ });
+ router.replace(`/labs/${mergedInto}`);
+ }, [mergedInto, biomarkerId, queryClient, router]);
+
// v1.25 — offset-paginated reading feed. The page no longer fetches a single
// `limit=500` window (which silently truncated a marker once it crossed 500
// readings); it loads a page at a time and a "Load more" control reveals the
@@ -305,7 +323,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
// v1.25.1 (H2-M2) — the error state keeps the heading + description and adds a
// retry, mirroring the insights `SubPageShell` recovery affordance rather than
// collapsing to a bare line with no context and no way back.
- if (markerError || listError) {
+ // A merged marker is on its way to its target (see above), not failed.
+ if ((markerError && !mergedInto) || listError) {
return (
) : null}
+ {/* v1.43 (D1223) — the other spellings this marker answers to. */}
+ {marker ? (
+
+ ) : null}
+
{/* Bridge to the source report PDFs. There is no per-value document
relation in the data model yet, so this links to the vault filtered
to lab-result documents rather than the exact scan behind one value.
diff --git a/src/components/labs/marker-match-field.tsx b/src/components/labs/marker-match-field.tsx
new file mode 100644
index 000000000..e81a3f3c6
--- /dev/null
+++ b/src/components/labs/marker-match-field.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+/**
+ * v1.43 (D1223) — which marker a reviewed lab value goes to.
+ *
+ * Shared by the scan review and the document review. The server resolves a
+ * printed name to one of the account's markers (its name, an alias, or a
+ * catalog abbreviation such as "T.Bili") and, when nothing resolves, offers up
+ * to three markers to choose from. This renders that resolution: the marker a
+ * value was matched to, or a choice between the suggestions and a new marker.
+ * A choice is remembered by the server as another spelling of the marker, so
+ * the next report from the same lab matches on its own.
+ *
+ * Nothing is computed here: the match, its kind and the suggestions all come
+ * from the review DTO.
+ */
+import { useId } from "react";
+import { FilePlus2, Link2 } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { useTranslations } from "@/lib/i18n/context";
+
+export interface MarkerSuggestionDto {
+ biomarkerId: string;
+ name: string;
+ unit: string;
+ matchKind: "catalog" | "similar";
+}
+
+/** The value for "a new marker" in the choice. */
+const NEW_MARKER = "";
+
+export function MarkerMatchField({
+ matchedName,
+ matchKind,
+ suggestions,
+ chosenId,
+ onChoose,
+ slot,
+}: {
+ /** The marker the server matched, by name; null when nothing matched. */
+ matchedName: string | null;
+ matchKind: "name" | "alias" | "catalog" | null;
+ suggestions: readonly MarkerSuggestionDto[];
+ /** The suggestion the person picked, or null for a new marker. */
+ chosenId: string | null;
+ onChoose: (suggestion: MarkerSuggestionDto | null) => void;
+ /** `data-slot` prefix for tests. */
+ slot: string;
+}) {
+ const { t } = useTranslations();
+ const selectId = useId();
+
+ if (matchedName !== null) {
+ return (
+