feat(labs): OpenAPI contract, i18n, and module-gate inventory

Add the Biomarker + restore routes to the OpenAPI registry and carry
biomarkerId on the LabResult schema (additive; iOS resolves the unit +
range from the linked marker, never recomputes). Regenerate the YAML.
Add the labs.* / labs.catalog.* keys across en/de/es/fr/it/pl. List the
biomarker catalog tree + restore route on the module-gate inventory's
data-layer exemptions (the module gates the surfaces, not the row store).
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:06:16 +02:00
1 parent 04c95e5ba1
commit 90e0f2191c
11 files changed
+1357 -28

No files matched your search

+410 -4
View File
@@ -5690,6 +5690,12 @@ paths:
date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted
note itself is not echoed).
parameters:
- in: query
name: biomarkerId
schema:
type: string
minLength: 1
maxLength: 64
- in: query
name: analyte
schema:
@@ -5759,6 +5765,10 @@ paths:
schema:
type: object
properties:
biomarkerId:
type: string
minLength: 1
maxLength: 64
panel:
anyOf:
- type: string
@@ -5791,9 +5801,7 @@ paths:
- type: string
const: ""
required:
- analyte
- value
- unit
- takenAt
responses:
"201":
@@ -5924,6 +5932,212 @@ paths:
"404": *a8
"422": *a3
"429": *a4
/api/labs/restore:
post:
tags:
- Labs
summary: Restore soft-deleted lab results
description: Un-tombstones 1..200 of the caller's soft-deleted lab results in one pass (the delete-Undo affordance). A
foreign / live id is a silent no-op. Audits via the wide-event ledger.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RestoreLabResultsRequest"
responses:
"200":
description: Restore count.
content:
application/json:
schema:
$ref: "#/components/schemas/RestoreLabResultsResponse"
"401": *a1
"422": *a3
"429": *a4
/api/biomarkers:
get:
tags:
- Labs
summary: List the caller's biomarker catalog
description: Returns every biomarker the caller has defined, name-ordered. Each carries its unit, reference bounds, and
decrypted context.
responses:
"200":
description: Biomarker catalog.
content:
application/json:
schema:
$ref: "#/components/schemas/ListBiomarkersEnvelope"
"401": *a1
"422": *a3
"429": *a4
post:
tags:
- Labs
summary: Define a biomarker
description: Creates a user-scoped biomarker. The optional context note is AES-256-GCM encrypted before write. Audits as
`biomarker.create`.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
minLength: 1
maxLength: 120
unit:
type: string
minLength: 1
maxLength: 40
lowerBound:
type: number
upperBound:
type: number
context:
type: string
maxLength: 2000
panel:
anyOf:
- type: string
minLength: 1
maxLength: 120
- type: string
const: ""
required:
- name
- unit
responses:
"201":
description: Created biomarker.
content:
application/json:
schema:
$ref: "#/components/schemas/CreateBiomarkerResponse"
"401": *a1
"409": &a10
description: A biomarker with this name already exists for the caller.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/biomarkers/{id}:
get:
tags:
- Labs
summary: Fetch a single biomarker
description: Returns the biomarker including its decrypted `context`. Cross-user rows surface as 404.
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: Biomarker detail.
content:
application/json:
schema:
$ref: "#/components/schemas/GetBiomarkerResponse"
"401": *a1
"404": &a9
description: Biomarker not found (or owned by another user).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
put:
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`.
parameters:
- in: path
name: id
schema:
type: string
required: true
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
minLength: 1
maxLength: 120
unit:
type: string
minLength: 1
maxLength: 40
lowerBound:
anyOf:
- type: number
- type: "null"
upperBound:
anyOf:
- type: number
- type: "null"
context:
anyOf:
- type: string
maxLength: 2000
- type: "null"
panel:
anyOf:
- anyOf:
- type: string
minLength: 1
maxLength: 120
- type: string
const: ""
- type: "null"
responses:
"200":
description: Updated biomarker.
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateBiomarkerResponse"
"401": *a1
"404": *a9
"409": *a10
"422": *a3
"429": *a4
delete:
tags:
- Labs
summary: Delete a biomarker
description: "Hard-deletes the catalog definition. The `onDelete: SetNull` FK unlinks every reading (they keep their
legacy `analyte` / `unit`), so the history survives. Audits as `biomarker.delete`."
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: Deletion succeeded.
content:
application/json:
schema:
$ref: "#/components/schemas/DeleteBiomarkerResponse"
"401": *a1
"404": *a9
"422": *a3
"429": *a4
components:
schemas:
PasskeyLoginVerifyRequest:
@@ -7230,6 +7444,17 @@ components:
type: boolean
description: Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side
after the patch applies.
RestoreLabResultsRequest:
type: object
properties:
ids:
minItems: 1
maxItems: 200
type: array
items:
type: string
required:
- ids
MedicationTreatmentClass:
type: string
enum:
@@ -17117,6 +17342,10 @@ components:
properties:
id:
type: string
biomarkerId:
anyOf:
- type: string
- type: "null"
panel:
anyOf:
- type: string
@@ -17149,6 +17378,7 @@ components:
type: string
required:
- id
- biomarkerId
- panel
- analyte
- value
@@ -17162,8 +17392,10 @@ components:
- createdAt
- updatedAt
additionalProperties: false
description: A stored lab result. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the
single-resource GET returns the decrypted `note`.
description: A stored lab result. `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows);
when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker —
the client renders them and never recomputes the range. The encrypted note is never echoed in list rows;
`hasNote` flags its presence and the single-resource GET returns the decrypted `note`.
LabReferenceRangeStatus:
type: string
enum:
@@ -17213,6 +17445,10 @@ components:
properties:
id:
type: string
biomarkerId:
anyOf:
- type: string
- type: "null"
panel:
anyOf:
- type: string
@@ -17247,6 +17483,7 @@ components:
- type: "null"
required:
- id
- biomarkerId
- panel
- analyte
- value
@@ -17301,6 +17538,175 @@ components:
- data
- error
additionalProperties: false
RestoreLabResultsResponse:
type: object
properties:
data:
type: object
properties:
restored:
type: number
required:
- restored
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
ListBiomarkersEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/ListBiomarkersResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
ListBiomarkersResponse:
type: object
properties:
biomarkers:
type: array
items:
$ref: "#/components/schemas/Biomarker"
required:
- biomarkers
additionalProperties: false
Biomarker:
type: object
properties:
id:
type: string
name:
type: string
unit:
type: string
lowerBound:
anyOf:
- type: number
- type: "null"
upperBound:
anyOf:
- type: number
- type: "null"
panel:
anyOf:
- type: string
- type: "null"
hasContext:
type: boolean
context:
anyOf:
- type: string
- type: "null"
createdAt:
type: string
updatedAt:
type: string
required:
- id
- name
- unit
- lowerBound
- upperBound
- panel
- hasContext
- context
- createdAt
- updatedAt
additionalProperties: false
description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its
presence. A `LabResult` linking this marker resolves its unit + reference bounds from here.
CreateBiomarkerResponse:
type: object
properties:
data:
$ref: "#/components/schemas/Biomarker"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
GetBiomarkerResponse:
type: object
properties:
data:
$ref: "#/components/schemas/Biomarker"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
UpdateBiomarkerResponse:
type: object
properties:
data:
$ref: "#/components/schemas/Biomarker"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
DeleteBiomarkerResponse:
type: object
properties:
data:
type: object
properties:
deleted:
type: boolean
required:
- deleted
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MedicationScheduleOutput:
type: object
properties:
+120 -4
View File
@@ -6504,13 +6504,13 @@
"labs": {
"title": "Laborwerte",
"subtitle": "Blutwerte und Biomarker",
"addResult": "Hinzufügen",
"addResult": "Wert hinzufügen",
"addDescription": "Erfasse einen Biomarker aus deinem Laborbefund.",
"addFirst": "Ersten Wert erfassen",
"emptyTitle": "Noch keine Laborwerte",
"emptyDescription": "Erfasse Blutwerte wie HbA1c, LDL oder Ferritin, um sie über die Zeit zu verfolgen und mit deiner Ärztin oder deinem Arzt zu teilen.",
"referenceLabel": "Referenz",
"readingsCount": "{count} Werte",
"readingsCount": "{count} Messungen",
"deleteLatest": "Letzten Wert löschen",
"deleteConfirmTitle": "Laborwert löschen?",
"deleteConfirmDescription": "Dieser Laborwert wird dauerhaft gelöscht.",
@@ -6539,11 +6539,127 @@
"referenceHint": "Übernimm den Referenzbereich aus deinem Laborbefund. Beide Grenzen sind optional.",
"note": "Notiz",
"notePlaceholder": "Optionaler Kontext zu diesem Wert",
"requiredError": "Biomarker, Wert und Einheit sind erforderlich.",
"requiredError": "Bitte einen Wert eingeben.",
"rangeOrderError": "Die untere Referenz darf nicht über der oberen liegen.",
"save": "Speichern",
"savedToast": "Wert gespeichert",
"saveError": "Wert konnte nicht gespeichert werden"
"saveError": "Wert konnte nicht gespeichert werden",
"biomarker": "Biomarker",
"biomarkerPlaceholder": "Biomarker auswählen",
"defineNew": "+ Neuen Biomarker anlegen",
"noBiomarkersHint": "Lege zuerst einen Biomarker an, um einen Wert zu erfassen.",
"pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen."
},
"backToLabs": "Zurück zu den Laborwerten",
"hasNote": "Notiz",
"editTitle": "Wert bearbeiten",
"editDescription": "Wert, Datum oder Notiz dieser Messung korrigieren.",
"editedToast": "Wert aktualisiert",
"editError": "Wert konnte nicht aktualisiert werden",
"editTimestampError": "Bitte ein gültiges Datum eingeben.",
"editReading": "Wert bearbeiten",
"deleteReading": "Wert löschen",
"restoredToast": "Wert wiederhergestellt",
"restoreError": "Wert konnte nicht wiederhergestellt werden",
"detail": {
"history": "Verlauf",
"emptyTitle": "Noch keine Werte",
"emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen."
},
"chart": {
"empty": "Keine Werte in diesem Zeitraum.",
"valueLabel": "Wert",
"range": {
"30": "30 T",
"90": "90 T",
"365": "1 J",
"all": "Alle"
}
},
"biomarker": {
"manage": "Biomarker",
"manageTitle": "Biomarker",
"manageSheetDescription": "Lege die Marker an, die du verfolgst. Jeder wird einmal mit Einheit und Referenzbereich eingerichtet.",
"managerDescription": "Marker einmal definieren — Einheit und Bereich gelten für jede Messung.",
"define": "Anlegen",
"defineFirst": "Ersten Biomarker anlegen",
"defineTitle": "Biomarker anlegen",
"defineDescription": "Name, Einheit und Referenzbereich einmalig festlegen. Beim Erfassen wählst du nur den Marker.",
"editTitle": "Biomarker bearbeiten",
"edit": "Bearbeiten",
"delete": "Löschen",
"deletedToast": "Biomarker gelöscht",
"deleteError": "Biomarker konnte nicht gelöscht werden",
"deleteConfirmTitle": "Biomarker löschen?",
"deleteConfirmDescription": "Die Definition wird entfernt. Bestehende Werte bleiben erhalten, aber ohne Verknüpfung.",
"emptyTitle": "Noch keine Biomarker",
"emptyDescription": "Lege Marker wie LDL oder HbA1c an, um Werte zu erfassen.",
"loadError": "Biomarker konnten nicht geladen werden",
"noRange": "Kein Referenzbereich",
"form": {
"seedLabel": "Von einem gängigen Marker starten",
"seedPlaceholder": "Marker wählen (optional)",
"seedHint": "Eine Auswahl füllt die Felder unten — passe den Bereich an dein Labor an.",
"name": "Name",
"namePlaceholder": "z. B. LDL-Cholesterin",
"unit": "Einheit",
"unitPlaceholder": "z. B. mg/dl",
"panel": "Panel",
"panelPlaceholder": "z. B. Lipide",
"lowerBound": "Referenz unten",
"upperBound": "Referenz oben",
"lowerPlaceholder": "Untere Grenze",
"upperPlaceholder": "Obere Grenze",
"context": "Kontext",
"contextPlaceholder": "Optionale Notiz zu diesem Marker",
"rangeHint": "Beide Grenzen sind optional — eine leer lassen für einen offenen Bereich (z. B. LDL < 116).",
"requiredError": "Name und Einheit sind erforderlich.",
"rangeOrderError": "Die untere Grenze darf die obere nicht überschreiten.",
"save": "Biomarker speichern",
"savedToast": "Biomarker gespeichert",
"updatedToast": "Biomarker aktualisiert",
"saveError": "Biomarker konnte nicht gespeichert werden"
}
},
"catalog": {
"total-cholesterol": "Gesamtcholesterin",
"ldl": "LDL-Cholesterin",
"hdl": "HDL-Cholesterin",
"triglycerides": "Triglyceride",
"fasting-glucose": "Nüchternglukose",
"hba1c": "HbA1c",
"tsh": "TSH",
"ft3": "Freies T3",
"ft4": "Freies T4",
"ferritin": "Ferritin",
"transferrin-saturation": "Transferrinsättigung",
"vitamin-d": "Vitamin D (25-OH)",
"vitamin-b12": "Vitamin B12",
"folate": "Folsäure",
"hs-crp": "hs-CRP",
"creatinine": "Kreatinin",
"egfr": "eGFR",
"alt": "ALT (GPT)",
"ast": "AST (GOT)",
"ggt": "GGT",
"sodium": "Natrium",
"potassium": "Kalium",
"hemoglobin": "Hämoglobin",
"hematocrit": "Hämatokrit",
"wbc": "Leukozyten",
"platelets": "Thrombozyten",
"panel": {
"lipids": "Lipide",
"glucose": "Glukosestoffwechsel",
"thyroid": "Schilddrüse",
"iron": "Eisen",
"vitamins": "Vitamine",
"inflammation": "Entzündung",
"renal": "Niere",
"liver": "Leber",
"electrolytes": "Elektrolyte",
"bloodCount": "Blutbild"
}
}
},
"modules": {
+119 -3
View File
@@ -6504,7 +6504,7 @@
"labs": {
"title": "Lab results",
"subtitle": "Bloodwork and biomarker readings",
"addResult": "Add",
"addResult": "Add value",
"addDescription": "Record a biomarker reading from a lab report.",
"addFirst": "Add your first result",
"emptyTitle": "No lab results yet",
@@ -6539,11 +6539,127 @@
"referenceHint": "Copy the reference range from your lab report. Either bound is optional.",
"note": "Note",
"notePlaceholder": "Optional context for this result",
"requiredError": "Biomarker, value and unit are required.",
"requiredError": "Enter a value.",
"rangeOrderError": "The reference low must not exceed the reference high.",
"save": "Save",
"savedToast": "Result saved",
"saveError": "Could not save the result"
"saveError": "Could not save the result",
"biomarker": "Biomarker",
"biomarkerPlaceholder": "Pick a biomarker",
"defineNew": "+ Define new biomarker",
"noBiomarkersHint": "Define a biomarker first to record a value against it.",
"pickBiomarkerError": "Pick a biomarker first."
},
"backToLabs": "Back to lab results",
"hasNote": "Note",
"editTitle": "Edit reading",
"editDescription": "Correct the value, date or note of this reading.",
"editedToast": "Reading updated",
"editError": "Could not update the reading",
"editTimestampError": "Enter a valid date.",
"editReading": "Edit reading",
"deleteReading": "Delete reading",
"restoredToast": "Reading restored",
"restoreError": "Could not restore the reading",
"detail": {
"history": "History",
"emptyTitle": "No readings yet",
"emptyDescription": "Add a value to start tracking this biomarker over time."
},
"chart": {
"empty": "No readings in this period.",
"valueLabel": "Value",
"range": {
"30": "30d",
"90": "90d",
"365": "1y",
"all": "All"
}
},
"biomarker": {
"manage": "Biomarkers",
"manageTitle": "Biomarkers",
"manageSheetDescription": "Define the markers you track. Each is set up once with its unit and reference range.",
"managerDescription": "Define a marker once — its unit and range are reused for every reading.",
"define": "Define",
"defineFirst": "Define your first biomarker",
"defineTitle": "Define biomarker",
"defineDescription": "Set the name, unit and reference range once. You pick the marker when adding a value.",
"editTitle": "Edit biomarker",
"edit": "Edit",
"delete": "Delete",
"deletedToast": "Biomarker deleted",
"deleteError": "Could not delete the biomarker",
"deleteConfirmTitle": "Delete biomarker?",
"deleteConfirmDescription": "The definition is removed. Existing readings are kept but unlinked.",
"emptyTitle": "No biomarkers yet",
"emptyDescription": "Define markers like LDL or HbA1c to record values against them.",
"loadError": "Could not load biomarkers",
"noRange": "No reference range",
"form": {
"seedLabel": "Start from a common marker",
"seedPlaceholder": "Choose a marker (optional)",
"seedHint": "Picking one fills the fields below — adjust the range to match your lab.",
"name": "Name",
"namePlaceholder": "e.g. LDL cholesterol",
"unit": "Unit",
"unitPlaceholder": "e.g. mg/dL",
"panel": "Panel",
"panelPlaceholder": "e.g. Lipids",
"lowerBound": "Reference low",
"upperBound": "Reference high",
"lowerPlaceholder": "Lower bound",
"upperPlaceholder": "Upper bound",
"context": "Context",
"contextPlaceholder": "Optional note about this marker",
"rangeHint": "Either bound is optional — leave one blank for an open-ended range (e.g. LDL < 116).",
"requiredError": "Name and unit are required.",
"rangeOrderError": "The lower bound must not exceed the upper bound.",
"save": "Save biomarker",
"savedToast": "Biomarker saved",
"updatedToast": "Biomarker updated",
"saveError": "Could not save the biomarker"
}
},
"catalog": {
"total-cholesterol": "Total cholesterol",
"ldl": "LDL cholesterol",
"hdl": "HDL cholesterol",
"triglycerides": "Triglycerides",
"fasting-glucose": "Fasting glucose",
"hba1c": "HbA1c",
"tsh": "TSH",
"ft3": "Free T3",
"ft4": "Free T4",
"ferritin": "Ferritin",
"transferrin-saturation": "Transferrin saturation",
"vitamin-d": "Vitamin D (25-OH)",
"vitamin-b12": "Vitamin B12",
"folate": "Folate",
"hs-crp": "hs-CRP",
"creatinine": "Creatinine",
"egfr": "eGFR",
"alt": "ALT",
"ast": "AST",
"ggt": "GGT",
"sodium": "Sodium",
"potassium": "Potassium",
"hemoglobin": "Hemoglobin",
"hematocrit": "Hematocrit",
"wbc": "White blood cells",
"platelets": "Platelets",
"panel": {
"lipids": "Lipids",
"glucose": "Glucose metabolism",
"thyroid": "Thyroid",
"iron": "Iron",
"vitamins": "Vitamins",
"inflammation": "Inflammation",
"renal": "Kidney",
"liver": "Liver",
"electrolytes": "Electrolytes",
"bloodCount": "Blood count"
}
}
},
"modules": {
+120 -4
View File
@@ -6504,13 +6504,13 @@
"labs": {
"title": "Resultados de análisis",
"subtitle": "Analíticas de sangre y biomarcadores",
"addResult": "Añadir",
"addResult": "Añadir valor",
"addDescription": "Registra un biomarcador a partir de un informe de laboratorio.",
"addFirst": "Añade tu primer resultado",
"emptyTitle": "Aún no hay resultados",
"emptyDescription": "Registra analíticas como HbA1c, LDL o ferritina para seguirlas a lo largo del tiempo y compartirlas con tu médico.",
"referenceLabel": "Referencia",
"readingsCount": "{count} valores",
"readingsCount": "{count} mediciones",
"deleteLatest": "Eliminar el último valor",
"deleteConfirmTitle": "¿Eliminar resultado de laboratorio?",
"deleteConfirmDescription": "Este resultado de laboratorio se eliminará de forma permanente.",
@@ -6539,11 +6539,127 @@
"referenceHint": "Copia el rango de referencia de tu informe de laboratorio. Cualquiera de los límites es opcional.",
"note": "Nota",
"notePlaceholder": "Contexto opcional para este resultado",
"requiredError": "El biomarcador, el valor y la unidad son obligatorios.",
"requiredError": "Introduce un valor.",
"rangeOrderError": "La referencia mínima no puede superar la máxima.",
"save": "Guardar",
"savedToast": "Resultado guardado",
"saveError": "No se pudo guardar el resultado"
"saveError": "No se pudo guardar el resultado",
"biomarker": "Biomarcador",
"biomarkerPlaceholder": "Elige un biomarcador",
"defineNew": "+ Definir nuevo biomarcador",
"noBiomarkersHint": "Define primero un biomarcador para registrar un valor.",
"pickBiomarkerError": "Elige primero un biomarcador."
},
"backToLabs": "Volver a los resultados",
"hasNote": "Nota",
"editTitle": "Editar medición",
"editDescription": "Corrige el valor, la fecha o la nota de esta medición.",
"editedToast": "Medición actualizada",
"editError": "No se pudo actualizar la medición",
"editTimestampError": "Introduce una fecha válida.",
"editReading": "Editar medición",
"deleteReading": "Eliminar medición",
"restoredToast": "Medición restaurada",
"restoreError": "No se pudo restaurar la medición",
"detail": {
"history": "Historial",
"emptyTitle": "Aún no hay mediciones",
"emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo."
},
"chart": {
"empty": "Sin mediciones en este periodo.",
"valueLabel": "Valor",
"range": {
"30": "30 d",
"90": "90 d",
"365": "1 a",
"all": "Todo"
}
},
"biomarker": {
"manage": "Biomarcadores",
"manageTitle": "Biomarcadores",
"manageSheetDescription": "Define los marcadores que sigues. Cada uno se configura una vez con su unidad y rango de referencia.",
"managerDescription": "Define un marcador una vez: su unidad y rango se reutilizan en cada medición.",
"define": "Definir",
"defineFirst": "Define tu primer biomarcador",
"defineTitle": "Definir biomarcador",
"defineDescription": "Configura el nombre, la unidad y el rango una vez. Eliges el marcador al añadir un valor.",
"editTitle": "Editar biomarcador",
"edit": "Editar",
"delete": "Eliminar",
"deletedToast": "Biomarcador eliminado",
"deleteError": "No se pudo eliminar el biomarcador",
"deleteConfirmTitle": "¿Eliminar biomarcador?",
"deleteConfirmDescription": "Se elimina la definición. Las mediciones existentes se conservan pero se desvinculan.",
"emptyTitle": "Aún no hay biomarcadores",
"emptyDescription": "Define marcadores como LDL o HbA1c para registrar valores.",
"loadError": "No se pudieron cargar los biomarcadores",
"noRange": "Sin rango de referencia",
"form": {
"seedLabel": "Partir de un marcador común",
"seedPlaceholder": "Elige un marcador (opcional)",
"seedHint": "Al elegir uno se rellenan los campos: ajusta el rango a tu laboratorio.",
"name": "Nombre",
"namePlaceholder": "p. ej. Colesterol LDL",
"unit": "Unidad",
"unitPlaceholder": "p. ej. mg/dL",
"panel": "Panel",
"panelPlaceholder": "p. ej. Lípidos",
"lowerBound": "Referencia mínima",
"upperBound": "Referencia máxima",
"lowerPlaceholder": "Límite inferior",
"upperPlaceholder": "Límite superior",
"context": "Contexto",
"contextPlaceholder": "Nota opcional sobre este marcador",
"rangeHint": "Ambos límites son opcionales: deja uno en blanco para un rango abierto (p. ej. LDL < 116).",
"requiredError": "El nombre y la unidad son obligatorios.",
"rangeOrderError": "El límite inferior no debe superar al superior.",
"save": "Guardar biomarcador",
"savedToast": "Biomarcador guardado",
"updatedToast": "Biomarcador actualizado",
"saveError": "No se pudo guardar el biomarcador"
}
},
"catalog": {
"total-cholesterol": "Colesterol total",
"ldl": "Colesterol LDL",
"hdl": "Colesterol HDL",
"triglycerides": "Triglicéridos",
"fasting-glucose": "Glucosa en ayunas",
"hba1c": "HbA1c",
"tsh": "TSH",
"ft3": "T3 libre",
"ft4": "T4 libre",
"ferritin": "Ferritina",
"transferrin-saturation": "Saturación de transferrina",
"vitamin-d": "Vitamina D (25-OH)",
"vitamin-b12": "Vitamina B12",
"folate": "Folato",
"hs-crp": "PCR ultrasensible",
"creatinine": "Creatinina",
"egfr": "TFGe",
"alt": "ALT",
"ast": "AST",
"ggt": "GGT",
"sodium": "Sodio",
"potassium": "Potasio",
"hemoglobin": "Hemoglobina",
"hematocrit": "Hematocrito",
"wbc": "Leucocitos",
"platelets": "Plaquetas",
"panel": {
"lipids": "Lípidos",
"glucose": "Metabolismo de la glucosa",
"thyroid": "Tiroides",
"iron": "Hierro",
"vitamins": "Vitaminas",
"inflammation": "Inflamación",
"renal": "Riñón",
"liver": "Hígado",
"electrolytes": "Electrolitos",
"bloodCount": "Hemograma"
}
}
},
"modules": {
+120 -4
View File
@@ -6504,13 +6504,13 @@
"labs": {
"title": "Résultats d'analyses",
"subtitle": "Analyses sanguines et biomarqueurs",
"addResult": "Ajouter",
"addResult": "Ajouter une valeur",
"addDescription": "Enregistrez un biomarqueur à partir d'un compte rendu de laboratoire.",
"addFirst": "Ajouter votre premier résultat",
"emptyTitle": "Aucun résultat pour l'instant",
"emptyDescription": "Enregistrez des analyses comme l'HbA1c, le LDL ou la ferritine pour les suivre dans le temps et les partager avec votre médecin.",
"referenceLabel": "Référence",
"readingsCount": "{count} valeurs",
"readingsCount": "{count} mesures",
"deleteLatest": "Supprimer la dernière valeur",
"deleteConfirmTitle": "Supprimer le résultat d'analyse ?",
"deleteConfirmDescription": "Ce résultat d'analyse sera définitivement supprimé.",
@@ -6539,11 +6539,127 @@
"referenceHint": "Reprenez l'intervalle de référence de votre compte rendu. Chaque borne est facultative.",
"note": "Note",
"notePlaceholder": "Contexte facultatif pour ce résultat",
"requiredError": "Le biomarqueur, la valeur et l'unité sont obligatoires.",
"requiredError": "Saisissez une valeur.",
"rangeOrderError": "La référence basse ne doit pas dépasser la référence haute.",
"save": "Enregistrer",
"savedToast": "Résultat enregistré",
"saveError": "Impossible d'enregistrer le résultat"
"saveError": "Impossible d'enregistrer le résultat",
"biomarker": "Biomarqueur",
"biomarkerPlaceholder": "Choisir un biomarqueur",
"defineNew": "+ Définir un nouveau biomarqueur",
"noBiomarkersHint": "Définissez d'abord un biomarqueur pour enregistrer une valeur.",
"pickBiomarkerError": "Choisissez d'abord un biomarqueur."
},
"backToLabs": "Retour aux résultats",
"hasNote": "Note",
"editTitle": "Modifier la mesure",
"editDescription": "Corrigez la valeur, la date ou la note de cette mesure.",
"editedToast": "Mesure mise à jour",
"editError": "Impossible de mettre à jour la mesure",
"editTimestampError": "Saisissez une date valide.",
"editReading": "Modifier la mesure",
"deleteReading": "Supprimer la mesure",
"restoredToast": "Mesure restaurée",
"restoreError": "Impossible de restaurer la mesure",
"detail": {
"history": "Historique",
"emptyTitle": "Aucune mesure pour l'instant",
"emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps."
},
"chart": {
"empty": "Aucune mesure sur cette période.",
"valueLabel": "Valeur",
"range": {
"30": "30 j",
"90": "90 j",
"365": "1 a",
"all": "Tout"
}
},
"biomarker": {
"manage": "Biomarqueurs",
"manageTitle": "Biomarqueurs",
"manageSheetDescription": "Définissez les marqueurs que vous suivez. Chacun est configuré une fois avec son unité et sa plage de référence.",
"managerDescription": "Définissez un marqueur une fois — son unité et sa plage sont réutilisées pour chaque mesure.",
"define": "Définir",
"defineFirst": "Définir votre premier biomarqueur",
"defineTitle": "Définir un biomarqueur",
"defineDescription": "Réglez le nom, l'unité et la plage une fois. Vous choisissez le marqueur à l'ajout d'une valeur.",
"editTitle": "Modifier le biomarqueur",
"edit": "Modifier",
"delete": "Supprimer",
"deletedToast": "Biomarqueur supprimé",
"deleteError": "Impossible de supprimer le biomarqueur",
"deleteConfirmTitle": "Supprimer le biomarqueur ?",
"deleteConfirmDescription": "La définition est supprimée. Les mesures existantes sont conservées mais dissociées.",
"emptyTitle": "Aucun biomarqueur pour l'instant",
"emptyDescription": "Définissez des marqueurs comme LDL ou HbA1c pour enregistrer des valeurs.",
"loadError": "Impossible de charger les biomarqueurs",
"noRange": "Pas de plage de référence",
"form": {
"seedLabel": "Partir d'un marqueur courant",
"seedPlaceholder": "Choisir un marqueur (facultatif)",
"seedHint": "En choisir un remplit les champs — ajustez la plage à votre laboratoire.",
"name": "Nom",
"namePlaceholder": "ex. Cholestérol LDL",
"unit": "Unité",
"unitPlaceholder": "ex. mg/dL",
"panel": "Panel",
"panelPlaceholder": "ex. Lipides",
"lowerBound": "Référence basse",
"upperBound": "Référence haute",
"lowerPlaceholder": "Borne inférieure",
"upperPlaceholder": "Borne supérieure",
"context": "Contexte",
"contextPlaceholder": "Note facultative sur ce marqueur",
"rangeHint": "Les deux bornes sont facultatives — laissez-en une vide pour une plage ouverte (ex. LDL < 116).",
"requiredError": "Le nom et l'unité sont requis.",
"rangeOrderError": "La borne inférieure ne doit pas dépasser la borne supérieure.",
"save": "Enregistrer le biomarqueur",
"savedToast": "Biomarqueur enregistré",
"updatedToast": "Biomarqueur mis à jour",
"saveError": "Impossible d'enregistrer le biomarqueur"
}
},
"catalog": {
"total-cholesterol": "Cholestérol total",
"ldl": "Cholestérol LDL",
"hdl": "Cholestérol HDL",
"triglycerides": "Triglycérides",
"fasting-glucose": "Glycémie à jeun",
"hba1c": "HbA1c",
"tsh": "TSH",
"ft3": "T3 libre",
"ft4": "T4 libre",
"ferritin": "Ferritine",
"transferrin-saturation": "Saturation de la transferrine",
"vitamin-d": "Vitamine D (25-OH)",
"vitamin-b12": "Vitamine B12",
"folate": "Folate",
"hs-crp": "CRP us",
"creatinine": "Créatinine",
"egfr": "DFGe",
"alt": "ALAT",
"ast": "ASAT",
"ggt": "GGT",
"sodium": "Sodium",
"potassium": "Potassium",
"hemoglobin": "Hémoglobine",
"hematocrit": "Hématocrite",
"wbc": "Leucocytes",
"platelets": "Plaquettes",
"panel": {
"lipids": "Lipides",
"glucose": "Métabolisme du glucose",
"thyroid": "Thyroïde",
"iron": "Fer",
"vitamins": "Vitamines",
"inflammation": "Inflammation",
"renal": "Rein",
"liver": "Foie",
"electrolytes": "Électrolytes",
"bloodCount": "Numération sanguine"
}
}
},
"modules": {
+120 -4
View File
@@ -6504,13 +6504,13 @@
"labs": {
"title": "Risultati degli esami",
"subtitle": "Analisi del sangue e biomarcatori",
"addResult": "Aggiungi",
"addResult": "Aggiungi valore",
"addDescription": "Registra un biomarcatore da un referto di laboratorio.",
"addFirst": "Aggiungi il primo risultato",
"emptyTitle": "Ancora nessun risultato",
"emptyDescription": "Registra esami come HbA1c, LDL o ferritina per seguirli nel tempo e condividerli con il tuo medico.",
"referenceLabel": "Riferimento",
"readingsCount": "{count} valori",
"readingsCount": "{count} misurazioni",
"deleteLatest": "Elimina l'ultimo valore",
"deleteConfirmTitle": "Eliminare il risultato di laboratorio?",
"deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato definitivamente.",
@@ -6539,11 +6539,127 @@
"referenceHint": "Copia l'intervallo di riferimento dal referto. Ciascun limite è facoltativo.",
"note": "Nota",
"notePlaceholder": "Contesto facoltativo per questo risultato",
"requiredError": "Biomarcatore, valore e unità sono obbligatori.",
"requiredError": "Inserisci un valore.",
"rangeOrderError": "Il riferimento minimo non può superare quello massimo.",
"save": "Salva",
"savedToast": "Risultato salvato",
"saveError": "Impossibile salvare il risultato"
"saveError": "Impossibile salvare il risultato",
"biomarker": "Biomarcatore",
"biomarkerPlaceholder": "Scegli un biomarcatore",
"defineNew": "+ Definisci nuovo biomarcatore",
"noBiomarkersHint": "Definisci prima un biomarcatore per registrare un valore.",
"pickBiomarkerError": "Scegli prima un biomarcatore."
},
"backToLabs": "Torna ai risultati",
"hasNote": "Nota",
"editTitle": "Modifica misurazione",
"editDescription": "Correggi valore, data o nota di questa misurazione.",
"editedToast": "Misurazione aggiornata",
"editError": "Impossibile aggiornare la misurazione",
"editTimestampError": "Inserisci una data valida.",
"editReading": "Modifica misurazione",
"deleteReading": "Elimina misurazione",
"restoredToast": "Misurazione ripristinata",
"restoreError": "Impossibile ripristinare la misurazione",
"detail": {
"history": "Cronologia",
"emptyTitle": "Ancora nessuna misurazione",
"emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo."
},
"chart": {
"empty": "Nessuna misurazione in questo periodo.",
"valueLabel": "Valore",
"range": {
"30": "30 g",
"90": "90 g",
"365": "1 a",
"all": "Tutto"
}
},
"biomarker": {
"manage": "Biomarcatori",
"manageTitle": "Biomarcatori",
"manageSheetDescription": "Definisci i marcatori che segui. Ognuno si imposta una volta con la sua unità e l'intervallo di riferimento.",
"managerDescription": "Definisci un marcatore una volta — unità e intervallo valgono per ogni misurazione.",
"define": "Definisci",
"defineFirst": "Definisci il tuo primo biomarcatore",
"defineTitle": "Definisci biomarcatore",
"defineDescription": "Imposta nome, unità e intervallo una volta. Scegli il marcatore quando aggiungi un valore.",
"editTitle": "Modifica biomarcatore",
"edit": "Modifica",
"delete": "Elimina",
"deletedToast": "Biomarcatore eliminato",
"deleteError": "Impossibile eliminare il biomarcatore",
"deleteConfirmTitle": "Eliminare il biomarcatore?",
"deleteConfirmDescription": "La definizione viene rimossa. Le misurazioni esistenti restano ma vengono scollegate.",
"emptyTitle": "Ancora nessun biomarcatore",
"emptyDescription": "Definisci marcatori come LDL o HbA1c per registrare i valori.",
"loadError": "Impossibile caricare i biomarcatori",
"noRange": "Nessun intervallo di riferimento",
"form": {
"seedLabel": "Parti da un marcatore comune",
"seedPlaceholder": "Scegli un marcatore (facoltativo)",
"seedHint": "Sceglierne uno compila i campi — adatta l'intervallo al tuo laboratorio.",
"name": "Nome",
"namePlaceholder": "es. Colesterolo LDL",
"unit": "Unità",
"unitPlaceholder": "es. mg/dL",
"panel": "Panel",
"panelPlaceholder": "es. Lipidi",
"lowerBound": "Riferimento minimo",
"upperBound": "Riferimento massimo",
"lowerPlaceholder": "Limite inferiore",
"upperPlaceholder": "Limite superiore",
"context": "Contesto",
"contextPlaceholder": "Nota facoltativa su questo marcatore",
"rangeHint": "Entrambi i limiti sono facoltativi — lasciane uno vuoto per un intervallo aperto (es. LDL < 116).",
"requiredError": "Nome e unità sono obbligatori.",
"rangeOrderError": "Il limite inferiore non deve superare quello superiore.",
"save": "Salva biomarcatore",
"savedToast": "Biomarcatore salvato",
"updatedToast": "Biomarcatore aggiornato",
"saveError": "Impossibile salvare il biomarcatore"
}
},
"catalog": {
"total-cholesterol": "Colesterolo totale",
"ldl": "Colesterolo LDL",
"hdl": "Colesterolo HDL",
"triglycerides": "Trigliceridi",
"fasting-glucose": "Glicemia a digiuno",
"hba1c": "HbA1c",
"tsh": "TSH",
"ft3": "T3 libero",
"ft4": "T4 libero",
"ferritin": "Ferritina",
"transferrin-saturation": "Saturazione della transferrina",
"vitamin-d": "Vitamina D (25-OH)",
"vitamin-b12": "Vitamina B12",
"folate": "Folati",
"hs-crp": "PCR ad alta sensibilità",
"creatinine": "Creatinina",
"egfr": "eGFR",
"alt": "ALT",
"ast": "AST",
"ggt": "GGT",
"sodium": "Sodio",
"potassium": "Potassio",
"hemoglobin": "Emoglobina",
"hematocrit": "Ematocrito",
"wbc": "Leucociti",
"platelets": "Piastrine",
"panel": {
"lipids": "Lipidi",
"glucose": "Metabolismo del glucosio",
"thyroid": "Tiroide",
"iron": "Ferro",
"vitamins": "Vitamine",
"inflammation": "Infiammazione",
"renal": "Reni",
"liver": "Fegato",
"electrolytes": "Elettroliti",
"bloodCount": "Emocromo"
}
}
},
"modules": {
+120 -4
View File
@@ -6504,13 +6504,13 @@
"labs": {
"title": "Wyniki badań",
"subtitle": "Badania krwi i biomarkery",
"addResult": "Dodaj",
"addResult": "Dodaj wynik",
"addDescription": "Zapisz biomarker z wyniku laboratoryjnego.",
"addFirst": "Dodaj pierwszy wynik",
"emptyTitle": "Brak wyników badań",
"emptyDescription": "Zapisuj badania takie jak HbA1c, LDL czy ferrytyna, aby śledzić je w czasie i udostępniać lekarzowi.",
"referenceLabel": "Norma",
"readingsCount": "{count} wyników",
"readingsCount": "{count} pomiarów",
"deleteLatest": "Usuń ostatni wynik",
"deleteConfirmTitle": "Usunąć wynik laboratoryjny?",
"deleteConfirmDescription": "Ten wynik laboratoryjny zostanie trwale usunięty.",
@@ -6539,11 +6539,127 @@
"referenceHint": "Przepisz zakres referencyjny z wyniku laboratoryjnego. Każda z granic jest opcjonalna.",
"note": "Notatka",
"notePlaceholder": "Opcjonalny kontekst tego wyniku",
"requiredError": "Biomarker, wartość i jednostka są wymagane.",
"requiredError": "Podaj wartość.",
"rangeOrderError": "Norma dolna nie może przekraczać górnej.",
"save": "Zapisz",
"savedToast": "Wynik zapisany",
"saveError": "Nie udało się zapisać wyniku"
"saveError": "Nie udało się zapisać wyniku",
"biomarker": "Biomarker",
"biomarkerPlaceholder": "Wybierz biomarker",
"defineNew": "+ Zdefiniuj nowy biomarker",
"noBiomarkersHint": "Najpierw zdefiniuj biomarker, aby zapisać wartość.",
"pickBiomarkerError": "Najpierw wybierz biomarker."
},
"backToLabs": "Wróć do wyników",
"hasNote": "Notatka",
"editTitle": "Edytuj pomiar",
"editDescription": "Popraw wartość, datę lub notatkę tego pomiaru.",
"editedToast": "Pomiar zaktualizowany",
"editError": "Nie udało się zaktualizować pomiaru",
"editTimestampError": "Podaj prawidłową datę.",
"editReading": "Edytuj pomiar",
"deleteReading": "Usuń pomiar",
"restoredToast": "Pomiar przywrócony",
"restoreError": "Nie udało się przywrócić pomiaru",
"detail": {
"history": "Historia",
"emptyTitle": "Brak pomiarów",
"emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie."
},
"chart": {
"empty": "Brak pomiarów w tym okresie.",
"valueLabel": "Wartość",
"range": {
"30": "30 d",
"90": "90 d",
"365": "1 r",
"all": "Wszystko"
}
},
"biomarker": {
"manage": "Biomarkery",
"manageTitle": "Biomarkery",
"manageSheetDescription": "Zdefiniuj śledzone markery. Każdy ustawiasz raz wraz z jednostką i zakresem referencyjnym.",
"managerDescription": "Zdefiniuj marker raz — jednostka i zakres są używane dla każdego pomiaru.",
"define": "Zdefiniuj",
"defineFirst": "Zdefiniuj pierwszy biomarker",
"defineTitle": "Zdefiniuj biomarker",
"defineDescription": "Ustaw nazwę, jednostkę i zakres raz. Marker wybierasz przy dodawaniu wartości.",
"editTitle": "Edytuj biomarker",
"edit": "Edytuj",
"delete": "Usuń",
"deletedToast": "Biomarker usunięty",
"deleteError": "Nie udało się usunąć biomarkera",
"deleteConfirmTitle": "Usunąć biomarker?",
"deleteConfirmDescription": "Definicja zostanie usunięta. Istniejące pomiary pozostaną, ale bez powiązania.",
"emptyTitle": "Brak biomarkerów",
"emptyDescription": "Zdefiniuj markery, takie jak LDL lub HbA1c, aby zapisywać wartości.",
"loadError": "Nie udało się wczytać biomarkerów",
"noRange": "Brak zakresu referencyjnego",
"form": {
"seedLabel": "Zacznij od popularnego markera",
"seedPlaceholder": "Wybierz marker (opcjonalnie)",
"seedHint": "Wybór wypełnia pola poniżej — dopasuj zakres do swojego laboratorium.",
"name": "Nazwa",
"namePlaceholder": "np. Cholesterol LDL",
"unit": "Jednostka",
"unitPlaceholder": "np. mg/dl",
"panel": "Panel",
"panelPlaceholder": "np. Lipidy",
"lowerBound": "Dolna granica",
"upperBound": "Górna granica",
"lowerPlaceholder": "Dolna granica",
"upperPlaceholder": "Górna granica",
"context": "Kontekst",
"contextPlaceholder": "Opcjonalna notatka o tym markerze",
"rangeHint": "Obie granice są opcjonalne — zostaw jedną pustą dla zakresu otwartego (np. LDL < 116).",
"requiredError": "Nazwa i jednostka są wymagane.",
"rangeOrderError": "Dolna granica nie może przekraczać górnej.",
"save": "Zapisz biomarker",
"savedToast": "Biomarker zapisany",
"updatedToast": "Biomarker zaktualizowany",
"saveError": "Nie udało się zapisać biomarkera"
}
},
"catalog": {
"total-cholesterol": "Cholesterol całkowity",
"ldl": "Cholesterol LDL",
"hdl": "Cholesterol HDL",
"triglycerides": "Trójglicerydy",
"fasting-glucose": "Glukoza na czczo",
"hba1c": "HbA1c",
"tsh": "TSH",
"ft3": "Wolne T3",
"ft4": "Wolne T4",
"ferritin": "Ferrytyna",
"transferrin-saturation": "Wysycenie transferyny",
"vitamin-d": "Witamina D (25-OH)",
"vitamin-b12": "Witamina B12",
"folate": "Kwas foliowy",
"hs-crp": "hs-CRP",
"creatinine": "Kreatynina",
"egfr": "eGFR",
"alt": "ALT",
"ast": "AST",
"ggt": "GGTP",
"sodium": "Sód",
"potassium": "Potas",
"hemoglobin": "Hemoglobina",
"hematocrit": "Hematokryt",
"wbc": "Leukocyty",
"platelets": "Płytki krwi",
"panel": {
"lipids": "Lipidy",
"glucose": "Metabolizm glukozy",
"thyroid": "Tarczyca",
"iron": "Żelazo",
"vitamins": "Witaminy",
"inflammation": "Stan zapalny",
"renal": "Nerki",
"liver": "Wątroba",
"electrolytes": "Elektrolity",
"bloodCount": "Morfologia krwi"
}
}
},
"modules": {
@@ -74,6 +74,8 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
"src/app/api/sleep",
"src/app/api/workouts",
"src/app/api/labs",
// v1.18.1 — the user-scoped Biomarker catalog backs the Labs feature.
"src/app/api/biomarkers",
"src/app/api/cycle",
"src/app/api/gamification",
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
@@ -129,6 +131,14 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/workouts/batch/route.ts",
"src/app/api/labs/route.ts",
"src/app/api/labs/[id]/route.ts",
// v1.18.1 — the lab-result delete-Undo restore endpoint and the
// user-scoped Biomarker catalog CRUD share the LabResult data-layer
// reasoning: the labs module gates the SURFACES (the Labs page), not the
// row store. A synced / pre-existing reading and its catalog definition
// must survive a disabled module so re-enabling reveals a complete history.
"src/app/api/labs/restore/route.ts",
"src/app/api/biomarkers/route.ts",
"src/app/api/biomarkers/[id]/route.ts",
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
// Static FHIR CapabilityStatement — server metadata, no user data.
"src/app/api/fhir/metadata/route.ts",
+182
View File
@@ -0,0 +1,182 @@
/**
* OpenAPI route table for the user-scoped Biomarker catalog (`/api/biomarkers`).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* bodies reuse the runtime Zod schemas from `@/lib/validations/biomarkers`
* so the wire contract stays single-source. The response shape is declared
* here (the route serialises a derived `hasContext` flag the input schema
* doesn't carry, and returns the decrypted `context`).
*
* The iOS client consumes the biomarker DTO + the `biomarkerId` field on
* `LabResult` to render the resolved unit + reference range without
* recomputing — server-authoritative parity for Labs.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
createBiomarkerSchema,
updateBiomarkerSchema,
} from "@/lib/validations/biomarkers";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
createBiomarkerSchema.meta({
id: "CreateBiomarkerRequest",
description:
"Define a user-scoped biomarker ONCE: canonical `name`, `unit`, optional reference bounds (`lowerBound` / `upperBound`; when both present `lowerBound` must not exceed `upperBound`), an optional encrypted `context` note, and an optional `panel` grouping. The name is unique per user (no second 'LDL'). Recording a value later just picks this marker — its unit + range are never re-entered.",
});
updateBiomarkerSchema.meta({
id: "UpdateBiomarkerRequest",
description:
"Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. A rename that collides with another of the caller's markers is rejected 409.",
});
const biomarkerRow = z
.object({
id: z.string(),
name: z.string(),
unit: z.string(),
lowerBound: z.number().nullable(),
upperBound: z.number().nullable(),
panel: z.string().nullable(),
hasContext: z.boolean(),
context: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "Biomarker",
description:
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. A `LabResult` linking this marker resolves its unit + reference bounds from here.",
});
const listResponse = z
.object({ biomarkers: z.array(biomarkerRow) })
.meta({ id: "ListBiomarkersResponse" });
const conflict = {
"409": {
description: "A biomarker with this name already exists for the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
};
const notFound = {
"404": {
description: "Biomarker not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/biomarkers": {
get: {
tags: ["Labs"],
summary: "List the caller's biomarker catalog",
description:
"Returns every biomarker the caller has defined, name-ordered. Each carries its unit, reference bounds, and decrypted context.",
responses: {
"200": {
description: "Biomarker catalog.",
content: {
"application/json": {
schema: dataEnvelope(listResponse, "ListBiomarkersEnvelope"),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Labs"],
summary: "Define a biomarker",
description:
"Creates a user-scoped biomarker. The optional context note is AES-256-GCM encrypted before write. Audits as `biomarker.create`.",
requestBody: {
required: true,
content: { "application/json": { schema: createBiomarkerSchema } },
},
responses: {
"201": {
description: "Created biomarker.",
content: {
"application/json": {
schema: dataEnvelope(biomarkerRow, "CreateBiomarkerResponse"),
},
},
},
...conflict,
...stdResponses,
},
},
},
"/api/biomarkers/{id}": {
get: {
tags: ["Labs"],
summary: "Fetch a single biomarker",
description:
"Returns the biomarker including its decrypted `context`. Cross-user rows surface as 404.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Biomarker detail.",
content: {
"application/json": {
schema: dataEnvelope(biomarkerRow, "GetBiomarkerResponse"),
},
},
},
...notFound,
...stdResponses,
},
},
put: {
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`.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateBiomarkerSchema } },
},
responses: {
"200": {
description: "Updated biomarker.",
content: {
"application/json": {
schema: dataEnvelope(biomarkerRow, "UpdateBiomarkerResponse"),
},
},
},
...conflict,
...notFound,
...stdResponses,
},
},
delete: {
tags: ["Labs"],
summary: "Delete a biomarker",
description:
"Hard-deletes the catalog definition. The `onDelete: SetNull` FK unlinks every reading (they keep their legacy `analyte` / `unit`), so the history survives. Audits as `biomarker.delete`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Deletion succeeded.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteBiomarkerResponse",
),
},
},
},
...notFound,
...stdResponses,
},
},
},
};
+2
View File
@@ -26,6 +26,7 @@ import { authPaths } from "./auth";
import { coachFeedbackPaths, coachPaths } from "./coach";
import { consentPaths } from "./consent";
import { cyclePaths } from "./cycle";
import { biomarkerPaths } from "./biomarkers";
import { devicePaths } from "./devices";
import { healthRecordPaths } from "./health-record";
import { importPaths } from "./import";
@@ -63,6 +64,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...importPaths,
...measurementReminderPaths,
...labsPaths,
...biomarkerPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+34 -1
View File
@@ -47,6 +47,7 @@ const rangeStatusEnum = z
const labResultRow = z
.object({
id: z.string(),
biomarkerId: z.string().nullable(),
panel: z.string().nullable(),
analyte: z.string(),
value: z.number(),
@@ -63,7 +64,7 @@ const labResultRow = z
.meta({
id: "LabResult",
description:
"A stored lab result. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.",
"A stored lab result. `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.",
});
const labResultDetail = labResultRow
@@ -201,4 +202,36 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/labs/restore": {
post: {
tags: ["Labs"],
summary: "Restore soft-deleted lab results",
description:
"Un-tombstones 1..200 of the caller's soft-deleted lab results in one pass (the delete-Undo affordance). A foreign / live id is a silent no-op. Audits via the wide-event ledger.",
requestBody: {
required: true,
content: {
"application/json": {
schema: z
.object({ ids: z.array(z.string()).min(1).max(200) })
.meta({ id: "RestoreLabResultsRequest" }),
},
},
},
responses: {
"200": {
description: "Restore count.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ restored: z.number() }),
"RestoreLabResultsResponse",
),
},
},
},
...stdResponses,
},
},
},
};