From 90e0f2191c935589d92b714b260a1e6d7414b7b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:06:16 +0200 Subject: [PATCH] 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). --- docs/api/openapi.yaml | 414 +++++++++++++++++- messages/de.json | 124 +++++- messages/en.json | 122 +++++- messages/es.json | 124 +++++- messages/fr.json | 124 +++++- messages/it.json | 124 +++++- messages/pl.json | 124 +++++- .../module-route-gate-inventory.test.ts | 10 + src/lib/openapi/routes/biomarkers.ts | 182 ++++++++ src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/labs.ts | 35 +- 11 files changed, 1357 insertions(+), 28 deletions(-) create mode 100644 src/lib/openapi/routes/biomarkers.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9485410cb..8a709d065 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 35c813012..34a7c5c1e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index ce511ebb8..bd71db32c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 083fee1bc..bcd0bed16 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 9b837a2c6..71144bcd4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 23305e7da..270dd940a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 8e1ff3360..7b5110c72 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 91447908d..0b78b7f6e 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -74,6 +74,8 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ "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 = [ "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", diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts new file mode 100644 index 000000000..5f3aa1e9e --- /dev/null +++ b/src/lib/openapi/routes/biomarkers.ts @@ -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 = { + "/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, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 7ec87608c..87cfc3e92 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -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 = { ...importPaths, ...measurementReminderPaths, ...labsPaths, + ...biomarkerPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index f4cfeb9c7..af51889f8 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -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 = { }, }, }, + "/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, + }, + }, + }, };