diff --git a/CHANGELOG.md b/CHANGELOG.md index 018f9a113..fca9868fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.37.9] — 2026-08-10 + +### Added + +- You can record the data that matters in an emergency: blood type, an organ-donor and an advance-directive status, plus emergency contacts, implants or devices, and a free note. Enter it under Anamnese, the same place the allergies and family history live. The contacts, implants and note are encrypted at rest like the rest of your sensitive record, and every field carries through backup and restore. +- The doctor report can open with a one-page emergency summary a clinician can read at a glance: blood type, severe allergies, current medications, chronic conditions, implants, the advance-directive and organ-donor lines, contacts and the note. The page only appears when you have recorded something for it, and it is part of the report you choose to share, so it travels with the report to the practice rather than sitting somewhere separate. + ## [1.37.8] — 2026-08-10 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a06955585..a6047c756 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.8 + version: 1.37.9 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -932,7 +932,7 @@ paths: type: number const: 2 leaves: - maxItems: 93 + maxItems: 94 type: array items: type: string @@ -1037,7 +1037,7 @@ paths: type: number const: 2 leaves: - maxItems: 93 + maxItems: 94 type: array items: type: string @@ -6842,6 +6842,93 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/anamnesis/emergency: + get: + tags: + - Insights + summary: Read the emergency profile + description: "Returns the caller's emergency (Notfalldaten) profile: blood type, organ-donor and advance-directive + declarations, and the decrypted emergency contacts, implants/devices and ICE note. Free text decrypts + fail-soft." + responses: + "200": + description: The emergency profile (fields null when unset). + content: + application/json: + schema: + $ref: "#/components/schemas/GetEmergencyProfileResponse" + "401": *a1 + "422": *a3 + "429": *a4 + patch: + tags: + - Insights + summary: Update the emergency profile + description: Partial edit of the emergency profile on the caller's own record. Free-text fields are AES-256-GCM + encrypted before persistence. Audits as `anamnesis.emergency.update`. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + bloodType: + anyOf: + - type: string + enum: + - A_POS + - A_NEG + - B_POS + - B_NEG + - AB_POS + - AB_NEG + - O_POS + - O_NEG + - UNKNOWN + - type: "null" + organDonor: + anyOf: + - type: string + enum: + - YES + - NO + - UNKNOWN + - type: "null" + advanceDirective: + anyOf: + - type: string + enum: + - EXISTS + - NONE + - UNKNOWN + - type: "null" + contacts: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + implants: + anyOf: + - type: string + maxLength: 1000 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + additionalProperties: false + responses: + "200": + description: The updated emergency profile. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateEmergencyProfileResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/coach/about-me/questions: get: tags: @@ -16157,7 +16244,7 @@ components: type: number const: 2 leaves: - maxItems: 93 + maxItems: 94 type: array items: type: string @@ -29624,6 +29711,160 @@ components: - data - error additionalProperties: false + GetEmergencyProfileResponse: + type: object + properties: + data: + type: object + properties: + bloodType: + anyOf: + - type: string + enum: + - A_POS + - A_NEG + - B_POS + - B_NEG + - AB_POS + - AB_NEG + - O_POS + - O_NEG + - UNKNOWN + - type: "null" + organDonor: + anyOf: + - type: string + enum: + - YES + - NO + - UNKNOWN + - type: "null" + advanceDirective: + anyOf: + - type: string + enum: + - EXISTS + - NONE + - UNKNOWN + - type: "null" + contacts: + anyOf: + - type: string + - type: "null" + contactsUnreadable: + type: boolean + implants: + anyOf: + - type: string + - type: "null" + implantsUnreadable: + type: boolean + note: + anyOf: + - type: string + - type: "null" + noteUnreadable: + type: boolean + required: + - bloodType + - organDonor + - advanceDirective + - contacts + - contactsUnreadable + - implants + - implantsUnreadable + - note + - noteUnreadable + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateEmergencyProfileResponse: + type: object + properties: + data: + type: object + properties: + bloodType: + anyOf: + - type: string + enum: + - A_POS + - A_NEG + - B_POS + - B_NEG + - AB_POS + - AB_NEG + - O_POS + - O_NEG + - UNKNOWN + - type: "null" + organDonor: + anyOf: + - type: string + enum: + - YES + - NO + - UNKNOWN + - type: "null" + advanceDirective: + anyOf: + - type: string + enum: + - EXISTS + - NONE + - UNKNOWN + - type: "null" + contacts: + anyOf: + - type: string + - type: "null" + contactsUnreadable: + type: boolean + implants: + anyOf: + - type: string + - type: "null" + implantsUnreadable: + type: boolean + note: + anyOf: + - type: string + - type: "null" + noteUnreadable: + type: boolean + required: + - bloodType + - organDonor + - advanceDirective + - contacts + - contactsUnreadable + - implants + - implantsUnreadable + - note + - noteUnreadable + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetCoachAboutMeQuestionsResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index d73d22df2..d768ca862 100644 --- a/messages/de.json +++ b/messages/de.json @@ -295,7 +295,43 @@ "immunizationsColDate": "Datum", "immunizationsColVaccine": "Impfstoff", "immunizationsColDose": "Dosis", - "immunizationsColLot": "Charge" + "immunizationsColLot": "Charge", + "emergency": { + "title": "Notfalldaten", + "subtitle": "Für den Einsatz im Notfall. Selbst angegeben; wenn möglich prüfen.", + "none": "Nicht erfasst", + "unreadable": "Gespeicherter Wert konnte nicht gelesen werden", + "bloodType": "Blutgruppe", + "bloodTypeUnknown": "Unbekannt", + "bloodTypeValues": { + "A_POS": "A positiv", + "A_NEG": "A negativ", + "B_POS": "B positiv", + "B_NEG": "B negativ", + "AB_POS": "AB positiv", + "AB_NEG": "AB negativ", + "O_POS": "0 positiv", + "O_NEG": "0 negativ" + }, + "severeAllergies": "Schwere Allergien", + "activeMedications": "Aktuelle Medikamente", + "chronicConditions": "Chronische Erkrankungen", + "implants": "Implantate und Geräte", + "advanceDirective": "Patientenverfügung", + "advanceDirectiveValues": { + "EXISTS": "Vorhanden", + "NONE": "Keine", + "UNKNOWN": "Keine Angabe" + }, + "organDonor": "Organspender", + "organDonorValues": { + "YES": "Ja", + "NO": "Nein", + "UNKNOWN": "Keine Angabe" + }, + "contacts": "Notfallkontakte", + "notes": "Weitere Hinweise" + } }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -9311,6 +9347,46 @@ "loadError": "Vorerkrankungen konnten nicht geladen werden.", "savedToast": "Erkrankungen gespeichert.", "saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut." + }, + "emergency": { + "cardTitle": "Notfalldaten", + "cardDescription": "Was Rettungskräfte zuerst brauchen: Blutgruppe, Kontakte, Verfügungen.", + "bloodType": "Blutgruppe", + "organDonor": "Organspender", + "advanceDirective": "Patientenverfügung", + "contacts": "Notfallkontakte", + "implants": "Implantate und Geräte", + "note": "Weitere Hinweise", + "notRecorded": "Nicht erfasst", + "bloodTypeValues": { + "A_POS": "A positiv", + "A_NEG": "A negativ", + "B_POS": "B positiv", + "B_NEG": "B negativ", + "AB_POS": "AB positiv", + "AB_NEG": "AB negativ", + "O_POS": "0 positiv", + "O_NEG": "0 negativ", + "UNKNOWN": "Unbekannt" + }, + "organDonorValues": { + "YES": "Ja", + "NO": "Nein", + "UNKNOWN": "Keine Angabe" + }, + "advanceDirectiveValues": { + "EXISTS": "Vorhanden", + "NONE": "Keine", + "UNKNOWN": "Keine Angabe" + }, + "contactsPlaceholder": "Name und Telefonnummer für den Notfall", + "implantsPlaceholder": "z. B. Herzschrittmacher, Insulinpumpe, Metallimplantat", + "notePlaceholder": "Alles, was im Notfall wichtig sein könnte", + "reportHint": "Dies erscheint auf der ersten Seite deines Arztberichts.", + "save": "Speichern", + "savedToast": "Notfalldaten gespeichert", + "saveError": "Notfalldaten konnten nicht gespeichert werden.", + "loadError": "Notfalldaten konnten nicht geladen werden." } }, "environment": { @@ -9834,7 +9910,8 @@ "scopeCount": "{count} Einträge aus {groups} Bereichen", "scopeSensitive": "inkl. {names}", "leafVisits": "Arztbesuche", - "leafImmunizations": "Impfungen" + "leafImmunizations": "Impfungen", + "leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)" }, "recordSharing": { "banner": { diff --git a/messages/en.json b/messages/en.json index e4bf6f235..856b6bd93 100644 --- a/messages/en.json +++ b/messages/en.json @@ -150,6 +150,42 @@ "height": "Height", "period": "Reporting period", "createdOn": "Created on", + "emergency": { + "title": "Emergency information", + "subtitle": "For use in an acute situation. Self-reported; confirm where possible.", + "none": "Not recorded", + "unreadable": "Stored value could not be read", + "bloodType": "Blood type", + "bloodTypeUnknown": "Unknown", + "bloodTypeValues": { + "A_POS": "A positive", + "A_NEG": "A negative", + "B_POS": "B positive", + "B_NEG": "B negative", + "AB_POS": "AB positive", + "AB_NEG": "AB negative", + "O_POS": "O positive", + "O_NEG": "O negative" + }, + "severeAllergies": "Severe allergies", + "activeMedications": "Current medications", + "chronicConditions": "Chronic conditions", + "implants": "Implants and devices", + "advanceDirective": "Advance directive", + "advanceDirectiveValues": { + "EXISTS": "Exists", + "NONE": "None", + "UNKNOWN": "Not stated" + }, + "organDonor": "Organ donor", + "organDonorValues": { + "YES": "Yes", + "NO": "No", + "UNKNOWN": "Not stated" + }, + "contacts": "Emergency contacts", + "notes": "Additional notes" + }, "vitalsTitle": "Vital signs — Summary", "colParameter": "Parameter", "colCurrent": "Current", @@ -9311,6 +9347,46 @@ "loadError": "Couldn't load your conditions.", "savedToast": "Conditions saved.", "saveError": "Could not save. Please try again." + }, + "emergency": { + "cardTitle": "Emergency information", + "cardDescription": "What a first responder needs first: blood type, contacts, directives.", + "bloodType": "Blood type", + "organDonor": "Organ donor", + "advanceDirective": "Advance directive", + "contacts": "Emergency contacts", + "implants": "Implants and devices", + "note": "Additional notes", + "notRecorded": "Not recorded", + "bloodTypeValues": { + "A_POS": "A positive", + "A_NEG": "A negative", + "B_POS": "B positive", + "B_NEG": "B negative", + "AB_POS": "AB positive", + "AB_NEG": "AB negative", + "O_POS": "O positive", + "O_NEG": "O negative", + "UNKNOWN": "Unknown" + }, + "organDonorValues": { + "YES": "Yes", + "NO": "No", + "UNKNOWN": "Not stated" + }, + "advanceDirectiveValues": { + "EXISTS": "Exists", + "NONE": "None", + "UNKNOWN": "Not stated" + }, + "contactsPlaceholder": "Name and phone number of who to call", + "implantsPlaceholder": "e.g. pacemaker, insulin pump, metal implant", + "notePlaceholder": "Anything a clinician should know in an emergency", + "reportHint": "This appears on the first page of your doctor report.", + "save": "Save", + "savedToast": "Emergency information saved", + "saveError": "Couldn't save your emergency information.", + "loadError": "Couldn't load your emergency information." } }, "environment": { @@ -9834,7 +9910,8 @@ "scopeCount": "{count} entries from {groups} areas", "scopeSensitive": "incl. {names}", "leafVisits": "Visits", - "leafImmunizations": "Immunizations" + "leafImmunizations": "Immunizations", + "leafEmergency": "Emergency information (blood type, contacts, directives)" }, "recordSharing": { "banner": { diff --git a/messages/es.json b/messages/es.json index 70ab1793b..1848d62e2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -295,7 +295,43 @@ "immunizationsColDate": "Fecha", "immunizationsColVaccine": "Vacuna", "immunizationsColDose": "Dosis", - "immunizationsColLot": "Lote" + "immunizationsColLot": "Lote", + "emergency": { + "title": "Información de emergencia", + "subtitle": "Para uso en una situación aguda. Autoinformado; confirmar cuando sea posible.", + "none": "No registrado", + "unreadable": "No se pudo leer el valor guardado", + "bloodType": "Grupo sanguíneo", + "bloodTypeUnknown": "Desconocido", + "bloodTypeValues": { + "A_POS": "A positivo", + "A_NEG": "A negativo", + "B_POS": "B positivo", + "B_NEG": "B negativo", + "AB_POS": "AB positivo", + "AB_NEG": "AB negativo", + "O_POS": "O positivo", + "O_NEG": "O negativo" + }, + "severeAllergies": "Alergias graves", + "activeMedications": "Medicación actual", + "chronicConditions": "Enfermedades crónicas", + "implants": "Implantes y dispositivos", + "advanceDirective": "Voluntades anticipadas", + "advanceDirectiveValues": { + "EXISTS": "Existe", + "NONE": "Ninguna", + "UNKNOWN": "Sin indicar" + }, + "organDonor": "Donante de órganos", + "organDonorValues": { + "YES": "Sí", + "NO": "No", + "UNKNOWN": "Sin indicar" + }, + "contacts": "Contactos de emergencia", + "notes": "Notas adicionales" + } }, "errorBoundary": { "title": "Algo ha salido mal", @@ -9311,6 +9347,46 @@ "loadError": "No se pudieron cargar tus enfermedades.", "savedToast": "Enfermedades guardadas.", "saveError": "No se pudo guardar. Inténtalo de nuevo." + }, + "emergency": { + "cardTitle": "Información de emergencia", + "cardDescription": "Lo que un servicio de emergencia necesita primero: grupo sanguíneo, contactos, voluntades.", + "bloodType": "Grupo sanguíneo", + "organDonor": "Donante de órganos", + "advanceDirective": "Voluntades anticipadas", + "contacts": "Contactos de emergencia", + "implants": "Implantes y dispositivos", + "note": "Notas adicionales", + "notRecorded": "No registrado", + "bloodTypeValues": { + "A_POS": "A positivo", + "A_NEG": "A negativo", + "B_POS": "B positivo", + "B_NEG": "B negativo", + "AB_POS": "AB positivo", + "AB_NEG": "AB negativo", + "O_POS": "O positivo", + "O_NEG": "O negativo", + "UNKNOWN": "Desconocido" + }, + "organDonorValues": { + "YES": "Sí", + "NO": "No", + "UNKNOWN": "Sin indicar" + }, + "advanceDirectiveValues": { + "EXISTS": "Existe", + "NONE": "Ninguna", + "UNKNOWN": "Sin indicar" + }, + "contactsPlaceholder": "Nombre y teléfono de a quién llamar", + "implantsPlaceholder": "p. ej. marcapasos, bomba de insulina, implante metálico", + "notePlaceholder": "Cualquier cosa que deba saber un profesional en una emergencia", + "reportHint": "Aparece en la primera página de tu informe médico.", + "save": "Guardar", + "savedToast": "Información de emergencia guardada", + "saveError": "No se pudo guardar la información de emergencia.", + "loadError": "No se pudo cargar la información de emergencia." } }, "environment": { @@ -9834,7 +9910,8 @@ "scopeCount": "{count} entradas de {groups} áreas", "scopeSensitive": "incl. {names}", "leafVisits": "Visitas médicas", - "leafImmunizations": "Vacunas" + "leafImmunizations": "Vacunas", + "leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)" }, "recordSharing": { "banner": { diff --git a/messages/fr.json b/messages/fr.json index 0747e74f0..b11b10ec5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -295,7 +295,43 @@ "immunizationsColDate": "Date", "immunizationsColVaccine": "Vaccin", "immunizationsColDose": "Dose", - "immunizationsColLot": "Lot" + "immunizationsColLot": "Lot", + "emergency": { + "title": "Informations d'urgence", + "subtitle": "À utiliser en situation aiguë. Déclaré par le patient ; à confirmer si possible.", + "none": "Non renseigné", + "unreadable": "La valeur enregistrée n'a pas pu être lue", + "bloodType": "Groupe sanguin", + "bloodTypeUnknown": "Inconnu", + "bloodTypeValues": { + "A_POS": "A positif", + "A_NEG": "A négatif", + "B_POS": "B positif", + "B_NEG": "B négatif", + "AB_POS": "AB positif", + "AB_NEG": "AB négatif", + "O_POS": "O positif", + "O_NEG": "O négatif" + }, + "severeAllergies": "Allergies graves", + "activeMedications": "Médicaments actuels", + "chronicConditions": "Maladies chroniques", + "implants": "Implants et dispositifs", + "advanceDirective": "Directives anticipées", + "advanceDirectiveValues": { + "EXISTS": "Existent", + "NONE": "Aucune", + "UNKNOWN": "Non précisé" + }, + "organDonor": "Don d'organes", + "organDonorValues": { + "YES": "Oui", + "NO": "Non", + "UNKNOWN": "Non précisé" + }, + "contacts": "Contacts d'urgence", + "notes": "Notes complémentaires" + } }, "errorBoundary": { "title": "Une erreur est survenue", @@ -9311,6 +9347,46 @@ "loadError": "Impossible de charger vos antécédents.", "savedToast": "Affections enregistrées.", "saveError": "Impossible d'enregistrer. Veuillez réessayer." + }, + "emergency": { + "cardTitle": "Informations d'urgence", + "cardDescription": "Ce qu'un secouriste doit savoir d'abord : groupe sanguin, contacts, directives.", + "bloodType": "Groupe sanguin", + "organDonor": "Don d'organes", + "advanceDirective": "Directives anticipées", + "contacts": "Contacts d'urgence", + "implants": "Implants et dispositifs", + "note": "Notes complémentaires", + "notRecorded": "Non renseigné", + "bloodTypeValues": { + "A_POS": "A positif", + "A_NEG": "A négatif", + "B_POS": "B positif", + "B_NEG": "B négatif", + "AB_POS": "AB positif", + "AB_NEG": "AB négatif", + "O_POS": "O positif", + "O_NEG": "O négatif", + "UNKNOWN": "Inconnu" + }, + "organDonorValues": { + "YES": "Oui", + "NO": "Non", + "UNKNOWN": "Non précisé" + }, + "advanceDirectiveValues": { + "EXISTS": "Existent", + "NONE": "Aucune", + "UNKNOWN": "Non précisé" + }, + "contactsPlaceholder": "Nom et numéro de téléphone à appeler", + "implantsPlaceholder": "ex. stimulateur cardiaque, pompe à insuline, implant métallique", + "notePlaceholder": "Tout ce qu'un soignant devrait savoir en urgence", + "reportHint": "Ceci apparaît sur la première page de votre rapport médical.", + "save": "Enregistrer", + "savedToast": "Informations d'urgence enregistrées", + "saveError": "Impossible d'enregistrer vos informations d'urgence.", + "loadError": "Impossible de charger vos informations d'urgence." } }, "environment": { @@ -9834,7 +9910,8 @@ "scopeCount": "{count} éléments issus de {groups} domaines", "scopeSensitive": "dont {names}", "leafVisits": "Consultations", - "leafImmunizations": "Vaccinations" + "leafImmunizations": "Vaccinations", + "leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)" }, "recordSharing": { "banner": { diff --git a/messages/it.json b/messages/it.json index f9a95873d..25df4a7ab 100644 --- a/messages/it.json +++ b/messages/it.json @@ -295,7 +295,43 @@ "immunizationsColDate": "Data", "immunizationsColVaccine": "Vaccino", "immunizationsColDose": "Dose", - "immunizationsColLot": "Lotto" + "immunizationsColLot": "Lotto", + "emergency": { + "title": "Informazioni di emergenza", + "subtitle": "Da usare in situazioni acute. Autodichiarato; confermare se possibile.", + "none": "Non registrato", + "unreadable": "Impossibile leggere il valore salvato", + "bloodType": "Gruppo sanguigno", + "bloodTypeUnknown": "Sconosciuto", + "bloodTypeValues": { + "A_POS": "A positivo", + "A_NEG": "A negativo", + "B_POS": "B positivo", + "B_NEG": "B negativo", + "AB_POS": "AB positivo", + "AB_NEG": "AB negativo", + "O_POS": "0 positivo", + "O_NEG": "0 negativo" + }, + "severeAllergies": "Allergie gravi", + "activeMedications": "Farmaci attuali", + "chronicConditions": "Malattie croniche", + "implants": "Impianti e dispositivi", + "advanceDirective": "Disposizioni anticipate di trattamento", + "advanceDirectiveValues": { + "EXISTS": "Presenti", + "NONE": "Nessuna", + "UNKNOWN": "Non indicato" + }, + "organDonor": "Donatore di organi", + "organDonorValues": { + "YES": "Sì", + "NO": "No", + "UNKNOWN": "Non indicato" + }, + "contacts": "Contatti di emergenza", + "notes": "Note aggiuntive" + } }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -9311,6 +9347,46 @@ "loadError": "Impossibile caricare le tue patologie.", "savedToast": "Patologie salvate.", "saveError": "Impossibile salvare. Riprova." + }, + "emergency": { + "cardTitle": "Informazioni di emergenza", + "cardDescription": "Ciò che serve subito ai soccorritori: gruppo sanguigno, contatti, disposizioni.", + "bloodType": "Gruppo sanguigno", + "organDonor": "Donatore di organi", + "advanceDirective": "Disposizioni anticipate di trattamento", + "contacts": "Contatti di emergenza", + "implants": "Impianti e dispositivi", + "note": "Note aggiuntive", + "notRecorded": "Non registrato", + "bloodTypeValues": { + "A_POS": "A positivo", + "A_NEG": "A negativo", + "B_POS": "B positivo", + "B_NEG": "B negativo", + "AB_POS": "AB positivo", + "AB_NEG": "AB negativo", + "O_POS": "0 positivo", + "O_NEG": "0 negativo", + "UNKNOWN": "Sconosciuto" + }, + "organDonorValues": { + "YES": "Sì", + "NO": "No", + "UNKNOWN": "Non indicato" + }, + "advanceDirectiveValues": { + "EXISTS": "Presenti", + "NONE": "Nessuna", + "UNKNOWN": "Non indicato" + }, + "contactsPlaceholder": "Nome e numero di telefono di chi chiamare", + "implantsPlaceholder": "es. pacemaker, microinfusore, impianto metallico", + "notePlaceholder": "Qualsiasi cosa un medico dovrebbe sapere in emergenza", + "reportHint": "Compare sulla prima pagina del referto medico.", + "save": "Salva", + "savedToast": "Informazioni di emergenza salvate", + "saveError": "Impossibile salvare le informazioni di emergenza.", + "loadError": "Impossibile caricare le informazioni di emergenza." } }, "environment": { @@ -9834,7 +9910,8 @@ "scopeCount": "{count} voci da {groups} aree", "scopeSensitive": "incl. {names}", "leafVisits": "Visite mediche", - "leafImmunizations": "Vaccinazioni" + "leafImmunizations": "Vaccinazioni", + "leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)" }, "recordSharing": { "banner": { diff --git a/messages/pl.json b/messages/pl.json index 3d1f1b0c3..d27c7ecd8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -295,7 +295,43 @@ "immunizationsColDate": "Data", "immunizationsColVaccine": "Szczepionka", "immunizationsColDose": "Dawka", - "immunizationsColLot": "Seria" + "immunizationsColLot": "Seria", + "emergency": { + "title": "Informacje na wypadek nagły", + "subtitle": "Do użycia w sytuacji nagłej. Dane deklarowane; potwierdź, jeśli to możliwe.", + "none": "Nie podano", + "unreadable": "Nie udało się odczytać zapisanej wartości", + "bloodType": "Grupa krwi", + "bloodTypeUnknown": "Nieznana", + "bloodTypeValues": { + "A_POS": "A dodatnia", + "A_NEG": "A ujemna", + "B_POS": "B dodatnia", + "B_NEG": "B ujemna", + "AB_POS": "AB dodatnia", + "AB_NEG": "AB ujemna", + "O_POS": "0 dodatnia", + "O_NEG": "0 ujemna" + }, + "severeAllergies": "Ciężkie alergie", + "activeMedications": "Aktualne leki", + "chronicConditions": "Choroby przewlekłe", + "implants": "Implanty i urządzenia", + "advanceDirective": "Oświadczenie woli pacjenta", + "advanceDirectiveValues": { + "EXISTS": "Istnieje", + "NONE": "Brak", + "UNKNOWN": "Nie podano" + }, + "organDonor": "Dawca narządów", + "organDonorValues": { + "YES": "Tak", + "NO": "Nie", + "UNKNOWN": "Nie podano" + }, + "contacts": "Kontakty w nagłych wypadkach", + "notes": "Dodatkowe uwagi" + } }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -9311,6 +9347,46 @@ "loadError": "Nie udało się wczytać Twoich chorób.", "savedToast": "Choroby zapisane.", "saveError": "Nie udało się zapisać. Spróbuj ponownie." + }, + "emergency": { + "cardTitle": "Informacje na wypadek nagły", + "cardDescription": "To, czego ratownik potrzebuje najpierw: grupa krwi, kontakty, oświadczenia.", + "bloodType": "Grupa krwi", + "organDonor": "Dawca narządów", + "advanceDirective": "Oświadczenie woli pacjenta", + "contacts": "Kontakty w nagłych wypadkach", + "implants": "Implanty i urządzenia", + "note": "Dodatkowe uwagi", + "notRecorded": "Nie podano", + "bloodTypeValues": { + "A_POS": "A dodatnia", + "A_NEG": "A ujemna", + "B_POS": "B dodatnia", + "B_NEG": "B ujemna", + "AB_POS": "AB dodatnia", + "AB_NEG": "AB ujemna", + "O_POS": "0 dodatnia", + "O_NEG": "0 ujemna", + "UNKNOWN": "Nieznana" + }, + "organDonorValues": { + "YES": "Tak", + "NO": "Nie", + "UNKNOWN": "Nie podano" + }, + "advanceDirectiveValues": { + "EXISTS": "Istnieje", + "NONE": "Brak", + "UNKNOWN": "Nie podano" + }, + "contactsPlaceholder": "Imię i numer telefonu osoby do kontaktu", + "implantsPlaceholder": "np. rozrusznik serca, pompa insulinowa, implant metalowy", + "notePlaceholder": "Wszystko, co lekarz powinien wiedzieć w nagłym wypadku", + "reportHint": "Pojawia się na pierwszej stronie raportu dla lekarza.", + "save": "Zapisz", + "savedToast": "Informacje na wypadek nagły zapisane", + "saveError": "Nie udało się zapisać informacji na wypadek nagły.", + "loadError": "Nie udało się wczytać informacji na wypadek nagły." } }, "environment": { @@ -9834,7 +9910,8 @@ "scopeCount": "{count} pozycji z {groups} obszarów", "scopeSensitive": "w tym {names}", "leafVisits": "Wizyty lekarskie", - "leafImmunizations": "Szczepienia" + "leafImmunizations": "Szczepienia", + "leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)" }, "recordSharing": { "banner": { diff --git a/package.json b/package.json index 0802ce06f..a284e61c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.8", + "version": "1.37.9", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0331_emergency_profile/migration.sql b/prisma/migrations/0331_emergency_profile/migration.sql new file mode 100644 index 000000000..3d0e0e9a1 --- /dev/null +++ b/prisma/migrations/0331_emergency_profile/migration.sql @@ -0,0 +1,28 @@ +-- Emergency ("Notfalldaten") facts on the health profile. +-- +-- Additive only. No table is dropped or restructured: three new enum types and +-- six new nullable columns fold into the existing `user_health_profiles` row, +-- one row per user. The three enums are plaintext closed sets read directly by +-- the doctor-report emergency page (the same shape `User.gender` uses); the +-- three BYTEA columns hold AES-256-GCM ciphertext, fail-closed on read like +-- every other `*_encrypted` column. Every column is nullable, so an account +-- that never fills in an emergency profile carries six NULLs and nothing +-- surfaces. + +-- CreateEnum +CREATE TYPE "emergency_blood_type" AS ENUM ('A_POS', 'A_NEG', 'B_POS', 'B_NEG', 'AB_POS', 'AB_NEG', 'O_POS', 'O_NEG', 'UNKNOWN'); + +-- CreateEnum +CREATE TYPE "organ_donor_status" AS ENUM ('YES', 'NO', 'UNKNOWN'); + +-- CreateEnum +CREATE TYPE "advance_directive_status" AS ENUM ('EXISTS', 'NONE', 'UNKNOWN'); + +-- AlterTable +ALTER TABLE "user_health_profiles" + ADD COLUMN "emergency_blood_type" "emergency_blood_type", + ADD COLUMN "organ_donor_status" "organ_donor_status", + ADD COLUMN "advance_directive_status" "advance_directive_status", + ADD COLUMN "emergency_contacts_encrypted" BYTEA, + ADD COLUMN "emergency_implants_encrypted" BYTEA, + ADD COLUMN "emergency_note_encrypted" BYTEA; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a84c51351..3c1597a60 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5945,6 +5945,19 @@ model UserHealthProfile { // The static default preserves every pre-existing prompt section for legacy // rows without automatically opting future enum members in. aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections") + // Emergency ("Notfalldaten") facts, surfaced on page one of the doctor + // report and edited in Settings -> Anamnese. Three closed-set enums are + // plaintext (read directly by the report, like `User.gender`); three free + // text columns are AES-256-GCM at rest, fail-closed on read like every other + // `*Encrypted` column. All six are nullable: an unset emergency profile is + // absent, not empty. The report page is withheld unless the EMERGENCY leaf is + // admitted AND at least one of these holds a value. + emergencyBloodType EmergencyBloodType? @map("emergency_blood_type") + organDonorStatus OrganDonorStatus? @map("organ_donor_status") + advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status") + emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted") + emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted") + emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -5953,6 +5966,42 @@ model UserHealthProfile { @@map("user_health_profiles") } +/// ABO/Rh blood group, plus an explicit UNKNOWN so "not known" is a recorded +/// answer rather than an absent row. Plaintext, read directly by the emergency +/// report page. +enum EmergencyBloodType { + A_POS + A_NEG + B_POS + B_NEG + AB_POS + AB_NEG + O_POS + O_NEG + UNKNOWN + + @@map("emergency_blood_type") +} + +/// Organ-donor declaration. UNKNOWN is the explicit "not stated" answer. +enum OrganDonorStatus { + YES + NO + UNKNOWN + + @@map("organ_donor_status") +} + +/// Whether an advance directive (Patientenverfügung) exists. UNKNOWN is the +/// explicit "not stated" answer. +enum AdvanceDirectiveStatus { + EXISTS + NONE + UNKNOWN + + @@map("advance_directive_status") +} + /// Sections of the health profile that may enter AI prompt context. This is an /// inclusion list: a section absent from the array is neither decrypted nor /// assembled for Coach or comprehensive briefings. diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index acfbe3c2a..811f55c2b 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -369,12 +369,17 @@ async function main() { // ───── UserHealthProfile (Bytes columns) ───── // "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted" // "coachFocusEncrypted" "pendingQuestionsEncrypted" + // "emergencyContactsEncrypted" "emergencyImplantsEncrypted" + // "emergencyNoteEncrypted" for (const field of [ "aboutMeEncrypted", "conditionsEncrypted", "allergiesEncrypted", "coachFocusEncrypted", "pendingQuestionsEncrypted", + "emergencyContactsEncrypted", + "emergencyImplantsEncrypted", + "emergencyNoteEncrypted", ]) { results.push( await rotateBytesColumn( diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 57227ecca..ad5fd641d 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(93); + expect(ALL_LEAF_IDS.length).toBe(94); expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77); - expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(16); + expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(17); }); it("places every leaf in exactly one group", () => { diff --git a/src/app/api/anamnesis/emergency/route.ts b/src/app/api/anamnesis/emergency/route.ts new file mode 100644 index 000000000..faa8e1d1b --- /dev/null +++ b/src/app/api/anamnesis/emergency/route.ts @@ -0,0 +1,59 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; +import { + readEmergencyProfile, + writeEmergencyProfile, +} from "@/lib/profile/emergency-profile"; +import { emergencyProfileUpdateSchema } from "@/lib/validations/emergency-profile"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const payload = await readEmergencyProfile(user.id); + annotate({ + action: { name: "anamnesis.emergency.get" }, + meta: { + has_blood_type: payload.bloodType !== null, + has_contacts: payload.contacts !== null, + has_implants: payload.implants !== null, + }, + }); + return apiSuccess(payload); +}); + +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const { data: rawBody, error } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (error) return error; + + const parsed = emergencyProfileUpdateSchema.safeParse(rawBody); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const payload = await writeEmergencyProfile(user.id, parsed.data); + await auditLog("anamnesis.emergency.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { fields: Object.keys(parsed.data).sort() }, + }); + annotate({ + action: { + name: "anamnesis.emergency.update", + entity_type: "user_health_profile", + entity_id: user.id, + }, + meta: { fields: Object.keys(parsed.data).sort() }, + }); + return apiSuccess(payload); +}); + +export const dynamic = "force-dynamic"; diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx new file mode 100644 index 000000000..ef27daa44 --- /dev/null +++ b/src/components/records/emergency-profile-manager.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { + ADVANCE_DIRECTIVE_STATUS_VALUES, + EMERGENCY_BLOOD_TYPE_VALUES, + EMERGENCY_CONTACTS_MAX, + EMERGENCY_IMPLANTS_MAX, + EMERGENCY_NOTE_MAX, + ORGAN_DONOR_STATUS_VALUES, + type EmergencyProfileDto, +} from "@/lib/validations/emergency-profile"; + +interface Draft { + bloodType: string; + organDonor: string; + advanceDirective: string; + contacts: string; + implants: string; + note: string; +} + +function draftFromDto(dto: EmergencyProfileDto): Draft { + return { + bloodType: dto.bloodType ?? "", + organDonor: dto.organDonor ?? "", + advanceDirective: dto.advanceDirective ?? "", + contacts: dto.contacts ?? "", + implants: dto.implants ?? "", + note: dto.note ?? "", + }; +} + +export function EmergencyProfileManager() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [draft, setDraft] = useState(null); + + const query = useQuery({ + queryKey: queryKeys.emergencyProfile(), + queryFn: () => apiGet("/api/anamnesis/emergency"), + }); + + const current = draft ?? (query.data ? draftFromDto(query.data) : null); + + const save = useMutation({ + mutationKey: queryKeys.emergencyProfile(), + mutationFn: (input: Draft) => + apiPatch("/api/anamnesis/emergency", { + // A select left at "" is "not recorded" and is omitted so a partial + // save leaves the column untouched; the free-text fields always ride, + // with an emptied field clearing its column server-side. + ...(input.bloodType ? { bloodType: input.bloodType } : {}), + ...(input.organDonor ? { organDonor: input.organDonor } : {}), + ...(input.advanceDirective + ? { advanceDirective: input.advanceDirective } + : {}), + contacts: input.contacts, + implants: input.implants, + note: input.note, + }), + onSuccess: (data) => { + setDraft(draftFromDto(data)); + queryClient.setQueryData(queryKeys.emergencyProfile(), data); + toastWrittenOutcome("success", t("records.emergency.savedToast")); + }, + onError: () => toast.error(t("records.emergency.saveError")), + }); + + if (query.isError) { + return ( + void query.refetch()} + /> + ); + } + + const disabled = query.isLoading || save.isPending || current === null; + const set = (patch: Partial) => + setDraft((prev) => ({ ...(prev ?? draftFromDto(query.data!)), ...patch })); + + const enumField = ( + key: "bloodType" | "organDonor" | "advanceDirective", + values: readonly string[], + valuePrefix: string, + ) => ( +
+ + +
+ ); + + const textField = ( + key: "contacts" | "implants" | "note", + max: number, + rows: number, + ) => ( +
+ +