diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e349dc30a..7b0d150d4 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: @@ -7239,6 +7453,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: @@ -17126,6 +17351,10 @@ components: properties: id: type: string + biomarkerId: + anyOf: + - type: string + - type: "null" panel: anyOf: - type: string @@ -17158,6 +17387,7 @@ components: type: string required: - id + - biomarkerId - panel - analyte - value @@ -17171,8 +17401,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: @@ -17222,6 +17454,10 @@ components: properties: id: type: string + biomarkerId: + anyOf: + - type: string + - type: "null" panel: anyOf: - type: string @@ -17256,6 +17492,7 @@ components: - type: "null" required: - id + - biomarkerId - panel - analyte - value @@ -17310,6 +17547,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 c8f36560c..c1a6e3490 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6516,13 +6516,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.", @@ -6551,11 +6551,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 7b5bbdced..93ad4921f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6516,7 +6516,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", @@ -6551,11 +6551,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 27d84b2a8..3599840b7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6516,13 +6516,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.", @@ -6551,11 +6551,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 25c877947..c8822ab63 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6516,13 +6516,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é.", @@ -6551,11 +6551,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 2f27bd97c..4c93a1d0b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6516,13 +6516,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.", @@ -6551,11 +6551,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 2a5354dbc..6c4294f56 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6516,13 +6516,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.", @@ -6551,11 +6551,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/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts new file mode 100644 index 000000000..48be5a921 --- /dev/null +++ b/src/app/api/biomarkers/[id]/route.ts @@ -0,0 +1,190 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { + decryptContextFromBytes, + encryptContextToBytes, +} from "@/lib/labs/biomarker-store"; +import { annotate } from "@/lib/logging/context"; +import { updateBiomarkerSchema } from "@/lib/validations/biomarkers"; + +/** + * v1.18.1 — single Biomarker resource (`/api/biomarkers/{id}`). + * + * PUT applies a partial edit (`data` built field-by-field; an explicit `null` + * clears `context` / `panel` / a bound, an omitted key leaves it untouched). + * DELETE hard-deletes the catalog definition — the `onDelete: SetNull` FK on + * `LabResult.biomarkerId` unlinks every reading without losing it (the row + * keeps its legacy `analyte` / `unit` historical fields). Cross-user rows + * surface as 404 (existence sealed). + */ + +type RouteParams = { params: Promise<{ id: string }> }; + +function serialiseBiomarker(row: { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + contextEncrypted: Uint8Array | null; + createdAt: Date; + updatedAt: Date; +}) { + return { + id: row.id, + name: row.name, + unit: row.unit, + lowerBound: row.lowerBound, + upperBound: row.upperBound, + panel: row.panel, + hasContext: row.contextEncrypted !== null, + context: row.contextEncrypted + ? decryptContextFromBytes(row.contextEncrypted) + : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const row = await prisma.biomarker.findFirst({ where: { id } }); + if (!row || row.userId !== user.id) { + return apiError("Biomarker not found", 404); + } + + annotate({ + action: { name: "labs.biomarker.get" }, + meta: { biomarkerId: id }, + }); + + return apiSuccess(serialiseBiomarker(row)); + }, +); + +export const PUT = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.biomarker.findFirst({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return apiError("Biomarker not found", 404); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = updateBiomarkerSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "labs.biomarker.update.validation-failed" }, + meta: { issue_count: parsed.error.issues.length, biomarkerId: id }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "labs.biomarker.update.validation-failed", + details: JSON.stringify({ issues: auditIssues, biomarkerId: id }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const d = parsed.data; + + // A rename must not collide with another of the caller's markers. + if (d.name !== undefined && d.name !== existing.name) { + const clash = await prisma.biomarker.findFirst({ + where: { userId: user.id, name: d.name }, + select: { id: true }, + }); + if (clash) { + return apiError("A biomarker with this name already exists", 409); + } + } + + // Build `data` field-by-field. `undefined` → leave the column untouched; + // an explicit `null` on `context` / `panel` / a bound clears it. + const data: Record = {}; + if (d.name !== undefined) data.name = d.name; + if (d.unit !== undefined) data.unit = d.unit; + if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound; + if (d.upperBound !== undefined) data.upperBound = d.upperBound; + if (d.panel !== undefined) data.panel = d.panel; + if (d.context !== undefined) { + data.contextEncrypted = d.context + ? encryptContextToBytes(d.context) + : null; + } + + const updated = await prisma.biomarker.update({ where: { id }, data }); + + await auditLog("biomarker.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { biomarkerId: id }, + }); + + annotate({ + action: { name: "labs.biomarker.update" }, + meta: { biomarkerId: id }, + }); + + return apiSuccess(serialiseBiomarker(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.biomarker.findFirst({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return apiError("Biomarker not found", 404); + } + + // Hard delete — the `onDelete: SetNull` FK unlinks every reading rather + // than cascading it away. The readings keep their legacy `analyte` / + // `unit` so the history survives the catalog edit. + await prisma.biomarker.delete({ where: { id } }); + + await auditLog("biomarker.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { biomarkerId: id }, + }); + + annotate({ + action: { name: "labs.biomarker.delete" }, + meta: { biomarkerId: id }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts new file mode 100644 index 000000000..61a5b2f14 --- /dev/null +++ b/src/app/api/biomarkers/route.ts @@ -0,0 +1,151 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { + decryptContextFromBytes, + encryptContextToBytes, +} from "@/lib/labs/biomarker-store"; +import { annotate } from "@/lib/logging/context"; +import { createBiomarkerSchema } from "@/lib/validations/biomarkers"; + +/** + * v1.18.1 — user-scoped Biomarker catalog (`/api/biomarkers`). + * + * The catalog is the Labs feature's primary object: a marker is defined ONCE + * (name, unit, reference bounds, optional context) and every later reading + * just picks it. GET lists the caller's markers; POST defines a new one. + * `userId` is always narrowed from the session — never a body field — and the + * write `data` object is built field-by-field (no mass assignment). The + * optional context note is AES-256-GCM encrypted into the `contextEncrypted` + * Bytes column before write. The `@@unique([userId, name])` index means there + * is never a second "LDL" definition for one user. + */ + +// The encrypted context bytes are never echoed back; `hasContext` flags +// presence and the single-resource GET returns the decrypted text. +function serialiseBiomarker(row: { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + contextEncrypted: Uint8Array | null; + createdAt: Date; + updatedAt: Date; +}) { + return { + id: row.id, + name: row.name, + unit: row.unit, + lowerBound: row.lowerBound, + upperBound: row.upperBound, + panel: row.panel, + hasContext: row.contextEncrypted !== null, + context: row.contextEncrypted + ? decryptContextFromBytes(row.contextEncrypted) + : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const rows = await prisma.biomarker.findMany({ + where: { userId: user.id }, + orderBy: [{ name: "asc" }], + }); + + annotate({ + action: { name: "labs.biomarker.list" }, + meta: { total: rows.length }, + }); + + return apiSuccess({ biomarkers: rows.map(serialiseBiomarker) }); +}); + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker)); + +async function postBiomarker(request: NextRequest) { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createBiomarkerSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "labs.biomarker.create.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "labs.biomarker.create.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { name, unit, lowerBound, upperBound, context, panel } = parsed.data; + + // Reject a duplicate name up front so the client sees a clean 409 rather + // than a Prisma unique-constraint 500. The `@@unique([userId, name])` index + // is the structural backstop. + const existing = await prisma.biomarker.findFirst({ + where: { userId: user.id, name }, + select: { id: true }, + }); + if (existing) { + return apiError("A biomarker with this name already exists", 409); + } + + // Field-by-field assignment — never spread `parsed.data`. + const created = await prisma.biomarker.create({ + data: { + userId: user.id, + name, + unit, + lowerBound: lowerBound ?? null, + upperBound: upperBound ?? null, + panel: panel ?? null, + contextEncrypted: context ? encryptContextToBytes(context) : null, + }, + }); + + await auditLog("biomarker.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { biomarkerId: created.id }, + }); + + annotate({ + action: { name: "labs.biomarker.create" }, + meta: { biomarkerId: created.id }, + }); + + return apiSuccess(serialiseBiomarker(created), 201); +} diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index 829f88498..9463efa45 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -11,12 +11,14 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { + type ResolvedBiomarker, + serialiseLabResult, + serialiseLabResultDetail, +} from "@/lib/labs/serialise"; import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; -import { - classifyReferenceRange, - updateLabResultSchema, -} from "@/lib/validations/labs"; +import { updateLabResultSchema } from "@/lib/validations/labs"; /** * v1.17.1 — single lab-result resource (`/api/labs/{id}`). @@ -26,10 +28,36 @@ import { * (`data` built field-by-field; an explicit `null` clears `panel` / `note` / * a reference bound, an omitted key leaves it untouched). DELETE soft-deletes * by stamping `deletedAt`. Cross-user rows surface as 404 (existence sealed). + * + * v1.18.1 — when the row links a `Biomarker`, the response resolves the + * canonical name / unit / range from the catalog (server-authoritative). The + * legacy per-row free-text fields stay editable for an unlinked row. */ type RouteParams = { params: Promise<{ id: string }> }; +const biomarkerSelect = { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, +} as const; + +function toResolved( + bm: { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + } | null, +): ResolvedBiomarker | null { + return bm ?? null; +} + export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -37,6 +65,7 @@ export const GET = apiHandler( const row = await prisma.labResult.findFirst({ where: { id, deletedAt: null }, + include: { biomarker: { select: biomarkerSelect } }, }); if (!row || row.userId !== user.id) { return apiError("Lab result not found", 404); @@ -44,27 +73,13 @@ export const GET = apiHandler( annotate({ action: { name: "labs.get" }, meta: { labResultId: id } }); - return apiSuccess({ - id: row.id, - panel: row.panel, - analyte: row.analyte, - value: row.value, - unit: row.unit, - referenceLow: row.referenceLow, - referenceHigh: row.referenceHigh, - takenAt: row.takenAt.toISOString(), - source: row.source, - note: row.noteEncrypted - ? decryptNoteFromBytes(row.noteEncrypted) - : null, - rangeStatus: classifyReferenceRange( - row.value, - row.referenceLow, - row.referenceHigh, + return apiSuccess( + serialiseLabResultDetail( + row, + toResolved(row.biomarker), + row.noteEncrypted ? decryptNoteFromBytes(row.noteEncrypted) : null, ), - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }); + ); }, ); @@ -112,6 +127,8 @@ export const PUT = apiHandler( // Build `data` field-by-field. `undefined` → leave the column untouched; // an explicit `null` on `panel` / `note` / a reference bound clears it. + // A reading linked to a catalog marker is edited via VALUE / takenAt / + // note here; its name / unit / range stay resolved from the biomarker. const data: Record = {}; if (d.panel !== undefined) data.panel = d.panel; if (d.analyte !== undefined) data.analyte = d.analyte; @@ -124,7 +141,11 @@ export const PUT = apiHandler( data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null; } - const updated = await prisma.labResult.update({ where: { id }, data }); + const updated = await prisma.labResult.update({ + where: { id }, + data, + include: { biomarker: { select: biomarkerSelect } }, + }); await auditLog("labResult.update", { userId: user.id, @@ -134,25 +155,9 @@ export const PUT = apiHandler( annotate({ action: { name: "labs.update" }, meta: { labResultId: id } }); - return apiSuccess({ - id: updated.id, - panel: updated.panel, - analyte: updated.analyte, - value: updated.value, - unit: updated.unit, - referenceLow: updated.referenceLow, - referenceHigh: updated.referenceHigh, - takenAt: updated.takenAt.toISOString(), - source: updated.source, - hasNote: updated.noteEncrypted !== null, - rangeStatus: classifyReferenceRange( - updated.value, - updated.referenceLow, - updated.referenceHigh, - ), - createdAt: updated.createdAt.toISOString(), - updatedAt: updated.updatedAt.toISOString(), - }); + return apiSuccess( + serialiseLabResult(updated, toResolved(updated.biomarker)), + ); }, ); @@ -178,7 +183,7 @@ export const DELETE = apiHandler( await auditLog("labResult.delete", { userId: user.id, ipAddress: getClientIp(request), - details: { labResultId: id, analyte: existing.analyte }, + details: { labResultId: id }, }); annotate({ action: { name: "labs.delete" }, meta: { labResultId: id } }); diff --git a/src/app/api/labs/restore/route.ts b/src/app/api/labs/restore/route.ts new file mode 100644 index 000000000..07d0bc409 --- /dev/null +++ b/src/app/api/labs/restore/route.ts @@ -0,0 +1,73 @@ +/** + * `POST /api/labs/restore` — un-tombstone for the lab-history delete-Undo + * affordance (v1.18.1). + * + * Body: `{ ids: string[] }` — 1..200 lab-result ids, scoped to the caller. + * + * Clears `deletedAt` on every owned, currently-tombstoned row in one + * `updateMany`, so the reading re-surfaces in normal reads. A forged / + * foreign / not-deleted id is a silent no-op (never a 404 existence leak): + * the mutation `where` pins `userId` so it only ever touches the caller's + * rows. Mirrors `/api/measurements/restore`. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const MAX_IDS_PER_BATCH = 200; +const RATE_LIMIT_MAX = 60; +const RATE_LIMIT_WINDOW_MS = 60 * 1000; + +const restoreSchema = z.object({ + ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH), +}); + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore)); + +async function postRestore(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `labs:restore:${user.id}`, + RATE_LIMIT_MAX, + RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many restore requests, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = restoreSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "labs.restore.invalid", + }); + } + + const { ids } = parsed.data; + + // `userId`-scoped + `deletedAt != null`: a foreign / live id is skipped. + const { count } = await prisma.labResult.updateMany({ + where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } }, + data: { deletedAt: null }, + }); + + annotate({ action: { name: "labs.restore" }, meta: { restored: count } }); + + return apiSuccess({ restored: count }); +} diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 2a8ec3902..485c63c0a 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { + apiError, apiSuccess, getClientIp, returnAllZodIssues, @@ -12,10 +13,13 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; +import { + type ResolvedBiomarker, + serialiseLabResult, +} from "@/lib/labs/serialise"; import { encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; import { - classifyReferenceRange, createLabResultSchema, listLabResultsSchema, } from "@/lib/validations/labs"; @@ -23,49 +27,31 @@ import { /** * v1.17.1 — structured lab-result store (`/api/labs`). * - * GET lists the caller's live results with optional analyte / panel / date - * filters. POST records a single reading. `userId` is always narrowed from - * the session — never a body field — and the write `data` object is built - * field-by-field (no mass assignment). The free-text note, when present, is - * AES-256-GCM encrypted into the `noteEncrypted` Bytes column before write. + * GET lists the caller's live results with optional biomarker / analyte / + * panel / date filters. POST records a single reading. `userId` is always + * narrowed from the session — never a body field — and the write `data` + * object is built field-by-field (no mass assignment). The free-text note, + * when present, is AES-256-GCM encrypted into the `noteEncrypted` Bytes + * column before write. + * + * v1.18.1 — structured entry: when the body carries a `biomarkerId`, the row + * links the user-scoped catalog marker and the response resolves its unit + + * reference range FROM the biomarker (server-authoritative). The web + iOS + * clients render the resolved DTO and never recompute. */ -// The serialised row never echoes the encrypted note bytes back; the badge -// status is computed server-side so the client renders a coherent, neutral -// in/out-of-range verdict without re-deriving the rule. -function serialiseLabResult(row: { - id: string; - panel: string | null; - analyte: string; - value: number; - unit: string; - referenceLow: number | null; - referenceHigh: number | null; - takenAt: Date; - source: string; - noteEncrypted: Uint8Array | null; - createdAt: Date; - updatedAt: Date; -}) { - return { - id: row.id, - panel: row.panel, - analyte: row.analyte, - value: row.value, - unit: row.unit, - referenceLow: row.referenceLow, - referenceHigh: row.referenceHigh, - takenAt: row.takenAt.toISOString(), - source: row.source, - hasNote: row.noteEncrypted !== null, - rangeStatus: classifyReferenceRange( - row.value, - row.referenceLow, - row.referenceHigh, - ), - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; +/** Map the joined biomarker (or null) into the resolver's shape. */ +function toResolved( + bm: { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; + } | null, +): ResolvedBiomarker | null { + return bm ?? null; } export const GET = apiHandler(async (request: NextRequest) => { @@ -81,11 +67,13 @@ export const GET = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const { analyte, panel, from, to, limit, offset, sortDir } = parsed.data; + const { biomarkerId, analyte, panel, from, to, limit, offset, sortDir } = + parsed.data; const where = { userId: user.id, deletedAt: null, + ...(biomarkerId && { biomarkerId }), ...(analyte && { analyte }), ...(panel && { panel }), ...(from || to @@ -104,6 +92,18 @@ export const GET = apiHandler(async (request: NextRequest) => { orderBy: { takenAt: sortDir }, take: limit, skip: offset, + include: { + biomarker: { + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + }, + }, + }, }), prisma.labResult.count({ where }), ]); @@ -114,7 +114,9 @@ export const GET = apiHandler(async (request: NextRequest) => { }); return apiSuccess({ - results: rows.map(serialiseLabResult), + results: rows.map((row) => + serialiseLabResult(row, toResolved(row.biomarker)), + ), meta: { total, limit, offset }, }); }); @@ -154,20 +156,55 @@ async function postLabResult(request: NextRequest) { return returnAllZodIssues(parsed.error, 422); } - const { panel, analyte, value, unit, referenceLow, referenceHigh, takenAt, note } = - parsed.data; + const { + biomarkerId, + panel, + analyte, + value, + unit, + referenceLow, + referenceHigh, + takenAt, + note, + } = parsed.data; - // Field-by-field assignment — never spread `parsed.data`. `source` is - // hardcoded "MANUAL" for this user-facing path; import paths set their own. + // Structured-entry path: resolve the catalog marker (and verify ownership) + // so the row's name + unit derive from it. A forged / foreign id is a 404. + let biomarker: ResolvedBiomarker | null = null; + if (biomarkerId) { + const found = await prisma.biomarker.findFirst({ + where: { id: biomarkerId, userId: user.id }, + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + }, + }); + if (!found) { + return apiError("Biomarker not found", 404); + } + biomarker = found; + } + + // Field-by-field assignment — never spread `parsed.data`. With a catalog + // link the row stamps the resolved name/unit/range as historical truth + // (so a later catalog edit does not silently rewrite a past reading) AND + // keeps the FK; reads resolve the CURRENT catalog values via `serialise`. const created = await prisma.labResult.create({ data: { userId: user.id, - panel: panel ?? null, - analyte, + biomarkerId: biomarker?.id ?? null, + panel: biomarker ? biomarker.panel : (panel ?? null), + analyte: biomarker ? biomarker.name : (analyte as string), value, - unit, - referenceLow: referenceLow ?? null, - referenceHigh: referenceHigh ?? null, + unit: biomarker ? biomarker.unit : (unit as string), + referenceLow: biomarker ? biomarker.lowerBound : (referenceLow ?? null), + referenceHigh: biomarker + ? biomarker.upperBound + : (referenceHigh ?? null), takenAt, source: "MANUAL", noteEncrypted: note ? encryptNoteToBytes(note) : null, @@ -177,12 +214,12 @@ async function postLabResult(request: NextRequest) { await auditLog("labResult.create", { userId: user.id, ipAddress: getClientIp(request), - details: { labResultId: created.id, analyte: created.analyte }, + details: { labResultId: created.id }, }); annotate({ action: { name: "labs.create" }, - meta: { labResultId: created.id }, + meta: { labResultId: created.id, structured: biomarker !== null }, }); // v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just @@ -190,5 +227,5 @@ async function postLabResult(request: NextRequest) { // now rather than waiting on the 15-min cron. Fire-and-forget. void enqueueReminderSatisfy(user.id).catch(() => {}); - return apiSuccess(serialiseLabResult(created), 201); + return apiSuccess(serialiseLabResult(created, biomarker), 201); } diff --git a/src/app/labs/[biomarkerId]/page.tsx b/src/app/labs/[biomarkerId]/page.tsx new file mode 100644 index 000000000..29189ca39 --- /dev/null +++ b/src/app/labs/[biomarkerId]/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +/** + * v1.18.1 — per-biomarker detail page (`/labs/[biomarkerId]`). + * + * Owns auth + the back link and hands the marker id to + * ``, which carries the heading, the dashboard-style + * chart with the reference band, and the editable reading history. + */ +import { use, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { LabBiomarkerDetail } from "@/components/labs/lab-biomarker-detail"; +import { BackLink } from "@/components/ui/back-link"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; + +export default function LabBiomarkerDetailPage({ + params, +}: { + params: Promise<{ biomarkerId: string }>; +}) { + const { biomarkerId } = use(params); + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + const router = useRouter(); + const { t } = useTranslations(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/auth/login"); + } + }, [isLoading, isAuthenticated, router]); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 2030d0510..d0b685f1c 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -3,8 +3,9 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, Plus } from "lucide-react"; +import { FlaskConical, Loader2, Plus } from "lucide-react"; +import { BiomarkerManager } from "@/components/labs/biomarker-manager"; import { LabForm } from "@/components/labs/lab-form"; import { LabList } from "@/components/labs/lab-list"; import { Button } from "@/components/ui/button"; @@ -23,6 +24,7 @@ export default function LabsPage() { const queryClient = useQueryClient(); const { t } = useTranslations(); const [dialogOpen, setDialogOpen] = useState(false); + const [manageOpen, setManageOpen] = useState(false); const refreshVisible = useCallback( () => queryClient.invalidateQueries({ type: "active" }), @@ -30,7 +32,7 @@ export default function LabsPage() { ); const pull = usePullToRefresh({ onRefresh: refreshVisible, - disabled: dialogOpen, + disabled: dialogOpen || manageOpen, }); useEffect(() => { @@ -59,13 +61,25 @@ export default function LabsPage() { {t("labs.subtitle")}

- +
+ + +
+ + + + setDialogOpen(true)} /> ); diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx new file mode 100644 index 000000000..e814ef717 --- /dev/null +++ b/src/components/labs/biomarker-form.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { + BIOMARKER_CATALOG, + BIOMARKER_PANELS, +} from "@/lib/labs/biomarker-catalog"; +import { useTranslations } from "@/lib/i18n/context"; + +import type { BiomarkerDto } from "./types"; + +const CONTEXT_MAX_LENGTH = 2000; + +/** Parse a free-text decimal that may use a comma separator. */ +function parseDecimal(raw: string): number | null { + const trimmed = raw.trim().replace(",", "."); + if (trimmed === "") return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; +} + +interface BiomarkerFormProps { + /** When set the form edits this marker; otherwise it defines a new one. */ + existing?: BiomarkerDto; + onSuccess?: (saved: BiomarkerDto) => void; + onCancel?: () => void; +} + +/** + * v1.18.1 — define or edit a Biomarker catalog entry. + * + * A marker is the Labs feature's reusable definition: name, unit, reference + * bounds (set ONCE, never re-entered per reading), and an optional context + * note. When defining a new marker the user can seed the fields from the + * common-panel catalog (a ` + + + + + {BIOMARKER_PANELS.map((panelKey) => { + const items = BIOMARKER_CATALOG.filter( + (s) => s.panel === panelKey, + ); + if (items.length === 0) return null; + return ( + + + {t(`labs.catalog.panel.${panelKey}`)} + + {items.map((s) => ( + + {t(`labs.catalog.${s.slug}`)} + + ))} + + ); + })} + + +

+ {t("labs.biomarker.form.seedHint")} +

+ + ) : null} + +
+
+ + setName(e.target.value)} + placeholder={t("labs.biomarker.form.namePlaceholder")} + maxLength={120} + autoFocus + required + /> +
+
+ + setUnit(e.target.value)} + placeholder={t("labs.biomarker.form.unitPlaceholder")} + maxLength={40} + required + /> +
+
+ + setPanel(e.target.value)} + placeholder={t("labs.biomarker.form.panelPlaceholder")} + maxLength={120} + /> +
+
+ + setLower(e.target.value)} + placeholder={t("labs.biomarker.form.lowerPlaceholder")} + /> +
+
+ + setUpper(e.target.value)} + placeholder={t("labs.biomarker.form.upperPlaceholder")} + /> +
+
+ +