Merge pull request #769 from MBombeck/release/v1.37.9

v1.37.9 — the record you want a paramedic to have
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-10 05:55:03 +02:00
commit 9a74bfb629
40 files changed
+2096 -35

No files matched your search

+7
View File
@@ -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
+245 -4
View File
@@ -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:
+79 -2
View File
@@ -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": {
+78 -1
View File
@@ -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": {
+79 -2
View File
@@ -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": {
+79 -2
View File
@@ -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": {
+79 -2
View File
@@ -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": {
+79 -2
View File
@@ -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": {
+1 -1
View File
@@ -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",
@@ -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;
+49
View File
@@ -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.
+5
View File
@@ -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(
@@ -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", () => {
+59
View File
@@ -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";
@@ -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<Draft | null>(null);
const query = useQuery({
queryKey: queryKeys.emergencyProfile(),
queryFn: () => apiGet<EmergencyProfileDto>("/api/anamnesis/emergency"),
});
const current = draft ?? (query.data ? draftFromDto(query.data) : null);
const save = useMutation({
mutationKey: queryKeys.emergencyProfile(),
mutationFn: (input: Draft) =>
apiPatch<EmergencyProfileDto>("/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 (
<QueryErrorCard
title={t("records.emergency.loadError")}
onRetry={() => void query.refetch()}
/>
);
}
const disabled = query.isLoading || save.isPending || current === null;
const set = (patch: Partial<Draft>) =>
setDraft((prev) => ({ ...(prev ?? draftFromDto(query.data!)), ...patch }));
const enumField = (
key: "bloodType" | "organDonor" | "advanceDirective",
values: readonly string[],
valuePrefix: string,
) => (
<div className="space-y-2">
<Label htmlFor={`emergency-${key}`}>
{t(`records.emergency.${key}`)}
</Label>
<Select
value={current?.[key] ?? ""}
disabled={disabled}
onValueChange={(next) => set({ [key]: next } as Partial<Draft>)}
>
<SelectTrigger id={`emergency-${key}`}>
<SelectValue placeholder={t("records.emergency.notRecorded")} />
</SelectTrigger>
<SelectContent>
{values.map((option) => (
<SelectItem key={option} value={option}>
{t(`records.emergency.${valuePrefix}.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
const textField = (
key: "contacts" | "implants" | "note",
max: number,
rows: number,
) => (
<div className="space-y-2">
<Label htmlFor={`emergency-${key}`}>
{t(`records.emergency.${key}`)}
</Label>
<Textarea
id={`emergency-${key}`}
value={current?.[key] ?? ""}
maxLength={max}
rows={rows}
disabled={disabled}
placeholder={t(`records.emergency.${key}Placeholder`)}
onChange={(event) =>
set({ [key]: event.target.value } as Partial<Draft>)
}
/>
</div>
);
return (
<div className="space-y-6" data-slot="emergency-profile-manager">
<div className="grid gap-4 md:grid-cols-3">
{enumField("bloodType", EMERGENCY_BLOOD_TYPE_VALUES, "bloodTypeValues")}
{enumField("organDonor", ORGAN_DONOR_STATUS_VALUES, "organDonorValues")}
{enumField(
"advanceDirective",
ADVANCE_DIRECTIVE_STATUS_VALUES,
"advanceDirectiveValues",
)}
</div>
{textField("contacts", EMERGENCY_CONTACTS_MAX, 3)}
{textField("implants", EMERGENCY_IMPLANTS_MAX, 2)}
{textField("note", EMERGENCY_NOTE_MAX, 3)}
<p className="text-muted-foreground text-xs">
{t("records.emergency.reportHint")}
</p>
<div className="flex items-center justify-end">
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled || current === null}
onClick={() => current && save.mutate(current)}
>
{save.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.emergency.save")}
</Button>
</div>
</div>
);
}
@@ -27,6 +27,9 @@ vi.mock("@/components/records/allergy-manager", () => ({
vi.mock("@/components/records/family-history-manager", () => ({
FamilyHistoryManager: () => null,
}));
vi.mock("@/components/records/emergency-profile-manager", () => ({
EmergencyProfileManager: () => null,
}));
function renderSection(): string {
return renderToStaticMarkup(
@@ -187,7 +187,7 @@ describe("<HealthRecordExportPanel> — the first run", () => {
// These six carry the standard template's leaves. On a first run they read
// 0/n like every other group: the template is a button, not a starting
// state.
expect(groupChip(html, "identity")).toBe("0/2");
expect(groupChip(html, "identity")).toBe("0/3");
expect(groupChip(html, "vitals")).toBe("0/7");
expect(groupChip(html, "body")).toBe("0/12");
expect(groupChip(html, "labs")).toBe("0/1");
@@ -62,15 +62,16 @@ describe("share-link create form — re-mint seeding", () => {
const html = render(<ShareLinkCreateForm />);
expect(html).toContain('data-testid="report-scope-picker-share"');
expect(html).toContain('data-testid="report-group-row-identity-share"');
// The server refuses the leaf with a 422; a control that cannot be
// honoured has no business rendering, so the identity group is one leaf
// wide here and two wide in the export panel. Nothing in it is on: a new
// link starts with an empty scope on every surface.
// The server refuses the insurance leaf with a 422; a control that cannot
// be honoured has no business rendering, so the identity group is two
// leaves wide here (patient identity + emergency) and three wide in the
// export panel. Nothing in it is on: a new link starts with an empty scope
// on every surface.
const row = html.match(
/data-testid="report-group-row-identity-share"[\s\S]*?<\/div>/,
)?.[0];
expect(row).toBeDefined();
expect(row).toContain(">0/1<");
expect(row).toContain(">0/2<");
// The fenced tier renders its leaves unconditionally; none is checked.
expect(html).toContain('data-testid="report-leaf-MOOD-share"');
expect(html).not.toContain('data-testid="report-leaf-INSURANCE-share"');
@@ -23,6 +23,7 @@ import {
EyeOff,
HeartPulse,
ShieldAlert,
Siren,
Users,
} from "lucide-react";
@@ -33,6 +34,7 @@ import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { AllergyManager } from "@/components/records/allergy-manager";
import { ConditionsManager } from "@/components/records/conditions-manager";
import { EmergencyProfileManager } from "@/components/records/emergency-profile-manager";
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
import { AiProfileInclusionManager } from "@/components/records/ai-profile-inclusion-manager";
import { HealthProfileFactsManager } from "@/components/records/health-profile-facts-manager";
@@ -91,6 +93,15 @@ export function AnamnesisSection() {
<FamilyHistoryManager />
</SettingsCard>
<SettingsCard>
<SettingsCardHeader
icon={Siren}
title={t("records.emergency.cardTitle")}
description={t("records.emergency.cardDescription")}
/>
<EmergencyProfileManager />
</SettingsCard>
<p className="text-muted-foreground text-xs">{t("records.disclaimer")}</p>
</div>
);
@@ -591,6 +591,51 @@ describe("doctor-report illness section", () => {
});
});
// ── emergency first page ── the Notfalldaten sheet owns page one, but only
// when the aggregator populated `data.emergency` (the EMERGENCY leaf admitted
// AND the profile holds something). A null payload — which is exactly what the
// aggregator returns when the leaf is NOT admitted — withholds the page.
describe("doctor-report emergency first page", () => {
const EMERGENCY: NonNullable<DoctorReportData["emergency"]> = {
bloodType: "O_NEG",
organDonor: "YES",
advanceDirective: "EXISTS",
contacts: "ICE contact on the recorded number",
contactsUnreadable: false,
implants: null,
implantsUnreadable: false,
note: null,
noteUnreadable: false,
};
it("prints the emergency sheet on page one when emergency data is present", async () => {
const { t } = getServerTranslator("en");
const data = makeData({ emergency: EMERGENCY });
const options = { t, locale: "en" as const, now: FIXED_NOW };
const text = await extractText(renderDoctorReportPdfBytes(data, options));
expect(text).toContain("Emergency information");
expect(text).toContain("O negative");
expect(text).toContain("ICE contact on the recorded number");
// The baseline report is two pages; the emergency sheet plus its page break
// add exactly one.
expect(buildDoctorReportPdfDocument(data, options).getNumberOfPages()).toBe(
3,
);
});
it("withholds the emergency page when the leaf was not admitted (payload null)", async () => {
const { t } = getServerTranslator("en");
const data = makeData({ emergency: null });
const options = { t, locale: "en" as const, now: FIXED_NOW };
const text = await extractText(renderDoctorReportPdfBytes(data, options));
expect(text).not.toContain("Emergency information");
// No emergency page, no extra page break: back to the two-page baseline.
expect(buildDoctorReportPdfDocument(data, options).getNumberOfPages()).toBe(
2,
);
});
});
describe("extracted doctor-report section boundaries", () => {
it("header/profile prefers the legal name and renders insurance identity", async () => {
const data = makeData({
+19
View File
@@ -167,6 +167,25 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
field: "pendingQuestionsEncrypted",
kind: "bytes",
},
// v1.38 — emergency ("Notfalldaten") free text: contacts, implants/devices,
// and the ICE note. Same Bytes codec as the self-context columns above; the
// three enum companions (blood type, organ-donor, advance-directive) are
// plaintext closed sets and are deliberately NOT here.
{
model: "UserHealthProfile",
field: "emergencyContactsEncrypted",
kind: "bytes",
},
{
model: "UserHealthProfile",
field: "emergencyImplantsEncrypted",
kind: "bytes",
},
{
model: "UserHealthProfile",
field: "emergencyNoteEncrypted",
kind: "bytes",
},
// ───── Effective-dated health profile facts (Bytes column) ─────
{
+10
View File
@@ -25,6 +25,7 @@ import {
import type { Locale } from "./i18n/config";
import { isValidTimezone } from "./tz/format";
import type { DoctorReportData } from "./doctor-report-data";
import { buildEmergencyFirstPageSection } from "./doctor-report-pdf/emergency-first-page-section";
import { buildHeaderProfileSection } from "./doctor-report-pdf/header-profile-section";
import { buildMeasurementsChartsSection } from "./doctor-report-pdf/measurements-charts-section";
import { buildMedicationMoodWellnessSection } from "./doctor-report-pdf/medication-mood-wellness-section";
@@ -289,6 +290,15 @@ export function buildDoctorReportPdfDocument(
};
let cursor = pdfCursorState(doc, margin);
// The emergency sheet owns page one. It renders only when emergency data is
// present (the aggregator gates on the EMERGENCY leaf + presence), and when it
// does, an explicit page break sends the rest of the report to page two so the
// opening page carries nothing else.
if (data.emergency) {
cursor = buildEmergencyFirstPageSection(context, cursor);
doc.addPage();
cursor = pdfCursorState(doc, margin);
}
cursor = buildHeaderProfileSection(context, cursor);
cursor = buildMeasurementsChartsSection(context, cursor);
cursor = buildMedicationMoodWellnessSection(context, cursor);
@@ -0,0 +1,158 @@
import {
pdfCursorState,
type DoctorReportPdfCursorState,
type DoctorReportPdfRenderContext,
} from "./render-context";
/**
* Page one of the doctor report: the emergency ("Notfalldaten") sheet a
* clinician reads first in an acute situation.
*
* Rendered before the header profile and followed by an explicit page break, so
* whatever a first responder needs — blood type, severe allergies, the current
* drug list, chronic conditions, implants, the advance-directive and
* organ-donor declarations, who to call — sits alone on the opening page.
*
* The section only renders when `data.emergency` is present; the aggregator
* returns null unless the EMERGENCY leaf was admitted AND the profile holds
* something worth surfacing, so the presence check here is the render half of
* that one gate.
*/
export function buildEmergencyFirstPageSection(
context: DoctorReportPdfRenderContext,
state: DoctorReportPdfCursorState,
): DoctorReportPdfCursorState {
const { doc, data, t, margin, pageWidth, contentMaxY } = context;
const emergency = data.emergency;
if (!emergency) return state;
const contentWidth = pageWidth - 2 * margin;
let y = state.y;
const ensureRoom = (needed: number) => {
if (y + needed > contentMaxY) {
doc.addPage();
y = margin;
}
};
// Banner: a filled bar so the page reads as the emergency sheet at a glance.
doc.setFillColor(180, 30, 30);
doc.rect(margin, y, contentWidth, 12, "F");
doc.setTextColor(255, 255, 255);
doc.setFontSize(15);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.emergency.title"), margin + 3, y + 8);
y += 12;
doc.setTextColor(120, 120, 120);
doc.setFontSize(8);
doc.setFont("helvetica", "normal");
y += 5;
doc.text(t("doctorReport.emergency.subtitle"), margin, y);
y += 6;
// A labelled paragraph block: bold label, wrapped body beneath. `emphasise`
// draws the value darker (blood type, severe allergies) so it carries.
const block = (label: string, body: string, emphasise = false) => {
ensureRoom(5 + 4.8);
doc.setFontSize(10);
doc.setFont("helvetica", "bold");
doc.setTextColor(40, 40, 40);
doc.text(label, margin, y);
y += 5;
doc.setFontSize(emphasise ? 11 : 9.5);
doc.setFont("helvetica", emphasise ? "bold" : "normal");
doc.setTextColor(
emphasise ? 150 : 60,
emphasise ? 20 : 60,
emphasise ? 20 : 60,
);
for (const wrapped of doc.splitTextToSize(body, contentWidth) as string[]) {
ensureRoom(4.8);
doc.text(wrapped, margin, y);
y += 4.8;
}
y += 3;
};
const notRecorded = t("doctorReport.emergency.none");
// Blood type.
const bloodType =
emergency.bloodType && emergency.bloodType !== "UNKNOWN"
? t(`doctorReport.emergency.bloodTypeValues.${emergency.bloodType}`)
: emergency.bloodType === "UNKNOWN"
? t("doctorReport.emergency.bloodTypeUnknown")
: notRecorded;
block(t("doctorReport.emergency.bloodType"), bloodType, true);
// Severe allergies — reuse the report's allergy collection, filter SEVERE.
const severe = (data.allergies ?? []).filter((a) => a.severity === "SEVERE");
if (severe.length > 0) {
const lines = severe.map((a) => {
const reaction = a.reactionUnreadable
? t("doctorReport.emergency.unreadable")
: a.reaction;
return reaction ? `${a.substance} (${reaction})` : a.substance;
});
block(t("doctorReport.emergency.severeAllergies"), lines.join("; "), true);
} else {
block(t("doctorReport.emergency.severeAllergies"), notRecorded);
}
// Active medications — reuse the report's medication list.
const meds = data.medications ?? [];
const medLine =
meds.length > 0
? meds.map((m) => (m.dose ? `${m.name} ${m.dose}` : m.name)).join("; ")
: notRecorded;
block(t("doctorReport.emergency.activeMedications"), medLine);
// Chronic conditions — ongoing illness episodes plus the anamnesis free text.
const chronic = (data.illnessEpisodes ?? [])
.filter((e) => e.lifecycle === "CHRONIC_ONGOING")
.map((e) => e.label);
const anamnesisConditions = data.anamnesis?.conditions ?? null;
const conditionParts = [...chronic];
if (anamnesisConditions) conditionParts.push(anamnesisConditions);
block(
t("doctorReport.emergency.chronicConditions"),
conditionParts.length > 0 ? conditionParts.join("; ") : notRecorded,
);
// Implants / devices.
const implants = emergency.implantsUnreadable
? t("doctorReport.emergency.unreadable")
: (emergency.implants ?? notRecorded);
block(t("doctorReport.emergency.implants"), implants);
// Advance directive + organ donor.
const advanceDirective = emergency.advanceDirective
? t(
`doctorReport.emergency.advanceDirectiveValues.${emergency.advanceDirective}`,
)
: notRecorded;
block(t("doctorReport.emergency.advanceDirective"), advanceDirective);
const organDonor = emergency.organDonor
? t(`doctorReport.emergency.organDonorValues.${emergency.organDonor}`)
: notRecorded;
block(t("doctorReport.emergency.organDonor"), organDonor);
// Emergency contacts.
const contacts = emergency.contactsUnreadable
? t("doctorReport.emergency.unreadable")
: (emergency.contacts ?? notRecorded);
block(t("doctorReport.emergency.contacts"), contacts, true);
// ICE note.
const note = emergency.noteUnreadable
? t("doctorReport.emergency.unreadable")
: emergency.note;
if (note) {
block(t("doctorReport.emergency.notes"), note);
}
return pdfCursorState(doc, y);
}
+24
View File
@@ -25,6 +25,11 @@ import type {
ShiftScheduleValue,
SmokingStatusValue,
} from "@/lib/validations/health-profile-facts";
import type {
AdvanceDirectiveStatusValue,
EmergencyBloodTypeValue,
OrganDonorStatusValue,
} from "@/lib/validations/emergency-profile";
export interface DoctorReportStats {
avg: number;
@@ -395,6 +400,25 @@ export interface DoctorReportData {
"SMOKING_STATUS" | "ALCOHOL_PATTERN" | "SHIFT_SCHEDULE"
>;
} | null;
/**
* Emergency ("Notfalldaten") facts for the page-one section. Present only
* when the `EMERGENCY` leaf was admitted AND at least one field holds a
* value; null otherwise, and the PDF then emits no emergency page. The three
* enums cross as constants (the renderer names them in the report's language);
* the free text decrypts fail-soft, an `*Unreadable` flag distinguishing a
* key-rotation gap from a genuinely unset field.
*/
emergency?: {
bloodType: EmergencyBloodTypeValue | null;
organDonor: OrganDonorStatusValue | null;
advanceDirective: AdvanceDirectiveStatusValue | null;
contacts: string | null;
contactsUnreadable: boolean;
implants: string | null;
implantsUnreadable: boolean;
note: string | null;
noteUnreadable: boolean;
} | null;
}
/** The three persisted score types surfaced in the wellness summary. */
@@ -253,7 +253,19 @@ function seed() {
rows.familyHistoryEntry = [
{ relationship: "MOTHER", condition: "Hypertension", ageAtOnset: 50 },
];
rows.userHealthProfileUnique = [{ conditionsEncrypted: new Uint8Array([1]) }];
rows.userHealthProfileUnique = [
{
conditionsEncrypted: new Uint8Array([1]),
// Emergency profile: a real blood type makes the EMERGENCY leaf change
// the payload, so the sweep can prove its control is not inert.
emergencyBloodType: "O_POS",
organDonorStatus: "YES",
advanceDirectiveStatus: "EXISTS",
emergencyContactsEncrypted: null,
emergencyImplantsEncrypted: null,
emergencyNoteEncrypted: null,
},
];
rows.healthProfileFactRevision = [
{
kind: "SMOKING_STATUS",
@@ -290,6 +302,7 @@ describe("per-leaf gating sweep", () => {
expect(allPayload.mood).not.toBeNull();
expect(allPayload.cycle).not.toBeNull();
expect(allPayload.anamnesis).not.toBeNull();
expect(allPayload.emergency).not.toBeNull();
expect(allPayload.glp1).not.toBeNull();
expect(allPayload.medications.length).toBeGreaterThan(0);
expect(allPayload.medicationAdministrations?.length ?? 0).toBeGreaterThan(
@@ -443,17 +456,27 @@ describe("zero read for unchosen leaves", () => {
["VISITS", "encounter"],
["IMMUNIZATIONS", "vaccinationRecord"],
["ANAMNESIS", "userHealthProfile"],
["EMERGENCY", "userHealthProfile"],
["ANAMNESIS", "healthProfileFactRevision"],
];
// ANAMNESIS and EMERGENCY both read `userHealthProfile`, so withholding one
// while the other stays selected still queries the table. Both must be off
// before the read stops — the zero-read guarantee is over the SET of leaves
// that touch a table, not each one in isolation.
const TABLE_READERS: Record<string, ReportLeafId[]> = {
userHealthProfile: ["ANAMNESIS", "EMERGENCY"],
};
it.each(gated)(
"never queries %s's table when it was not chosen",
async (leaf, model) => {
calls.length = 0;
const withheld = new Set<ReportLeafId>(TABLE_READERS[model] ?? [leaf]);
await collectDoctorReportData(
"u1",
RANGE,
selectionFromLeaves(ALL_LEAF_IDS.filter((l) => l !== leaf)),
selectionFromLeaves(ALL_LEAF_IDS.filter((l) => !withheld.has(l))),
{ moduleMap: moduleMap() },
);
expect(calls).not.toContain(model);
@@ -503,6 +526,7 @@ describe("the empty selection serves nothing", () => {
expect(payload.illnessEpisodes).toBeNull();
expect(payload.visits).toBeNull();
expect(payload.anamnesis).toBeNull();
expect(payload.emergency).toBeNull();
expect(payload.cycle).toBeNull();
expect(payload.mood).toBeNull();
expect(payload.glp1).toBeNull();
+21
View File
@@ -18,6 +18,10 @@ import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
import type { DoctorReportData } from "@/lib/doctor-report-types";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { decryptHealthProfileFactValue } from "@/lib/profile/health-facts";
import {
emergencyProfileHasContent,
readEmergencyProfile,
} from "@/lib/profile/emergency-profile";
import type {
HealthProfileFactKind,
HealthProfileFactValue,
@@ -309,6 +313,23 @@ export async function loadFamilyHistory(
return rows.length > 0 ? rows : null;
}
/**
* Emergency ("Notfalldaten") facts for the page-one section. The caller owns
* the selection gate, so reaching this function always means EMERGENCY was
* admitted. Returns null when nothing is worth surfacing, so the PDF emits no
* page over an emergency profile the person never filled in — the presence half
* of the page gate, kept beside the read so the two cannot drift.
*
* The DTO shape is exactly the report's emergency shape, so it crosses through
* unchanged. Free text decrypts fail-soft inside `readEmergencyProfile`.
*/
export async function loadEmergency(
userId: string,
): Promise<DoctorReportData["emergency"]> {
const dto = await readEmergencyProfile(userId);
return emergencyProfileHasContent(dto) ? dto : null;
}
/**
* Chronic conditions and the three current structured facts. The caller owns
* the selection gate, so reaching this function always means ANAMNESIS was
+4
View File
@@ -62,6 +62,7 @@ import { buildAdministrationLedger, buildGlp1Block } from "./medications";
import {
loadAllergies,
loadAnamnesis,
loadEmergency,
loadFamilyHistory,
loadIllnessEpisodes,
loadImmunizations,
@@ -505,6 +506,7 @@ export async function collectDoctorReportData(
allergies,
familyHistory,
anamnesis,
emergency,
] = await Promise.all([
gate.admits("CYCLE")
? buildCycleExportSummary(userId, end.toISOString().slice(0, 10))
@@ -528,6 +530,7 @@ export async function collectDoctorReportData(
? loadFamilyHistory(userId)
: Promise.resolve(null),
gate.admits("ANAMNESIS") ? loadAnamnesis(userId) : Promise.resolve(null),
gate.admits("EMERGENCY") ? loadEmergency(userId) : Promise.resolve(null),
]);
const identityOn = gate.admits("PATIENT_IDENTITY");
@@ -596,5 +599,6 @@ export async function collectDoctorReportData(
allergies,
familyHistory,
anamnesis,
emergency,
};
}
+70
View File
@@ -32,6 +32,11 @@ import {
type HealthProfileAiSection,
type HealthProfileFactKind,
} from "@/lib/validations/health-profile-facts";
import type {
AdvanceDirectiveStatusValue,
EmergencyBloodTypeValue,
OrganDonorStatusValue,
} from "@/lib/validations/emergency-profile";
export interface ProfileBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
@@ -59,6 +64,22 @@ export interface HealthProfileBackupEntry {
allergiesEncrypted?: string | null;
coachFocusEncrypted?: string | null;
aiIncludedSections: HealthProfileAiSection[];
/**
* Emergency ("Notfalldaten") profile. The three enums are plaintext closed
* sets, carried by value in both purposes exactly like `aiIncludedSections`.
* The three free-text columns follow the same split as the self-context above:
* a portable export decrypts them into `emergency*`, a DR payload leaves those
* null and carries the ciphertext in `emergency*Encrypted`.
*/
emergencyBloodType: EmergencyBloodTypeValue | null;
organDonorStatus: OrganDonorStatusValue | null;
advanceDirectiveStatus: AdvanceDirectiveStatusValue | null;
emergencyContacts: string | null;
emergencyImplants: string | null;
emergencyNote: string | null;
emergencyContactsEncrypted?: string | null;
emergencyImplantsEncrypted?: string | null;
emergencyNoteEncrypted?: string | null;
/**
* Server-derived clarifying questions awaiting an answer, encrypted JSON.
*
@@ -248,6 +269,19 @@ export async function buildProfileBackupSection(
HealthProfileAiSection[] | undefined) ?? [
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
],
emergencyBloodType: profileRow.emergencyBloodType ?? null,
organDonorStatus: profileRow.organDonorStatus ?? null,
advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null,
emergencyContacts: null,
emergencyImplants: null,
emergencyNote: null,
emergencyContactsEncrypted: toBase64(
profileRow.emergencyContactsEncrypted,
),
emergencyImplantsEncrypted: toBase64(
profileRow.emergencyImplantsEncrypted,
),
emergencyNoteEncrypted: toBase64(profileRow.emergencyNoteEncrypted),
aboutMeEncrypted: toBase64(profileRow.aboutMeEncrypted),
conditionsEncrypted: toBase64(profileRow.conditionsEncrypted),
allergiesEncrypted: toBase64(profileRow.allergiesEncrypted),
@@ -279,6 +313,21 @@ export async function buildProfileBackupSection(
HealthProfileAiSection[] | undefined) ?? [
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
],
emergencyBloodType: profileRow.emergencyBloodType ?? null,
organDonorStatus: profileRow.organDonorStatus ?? null,
advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null,
emergencyContacts: decryptProfileFieldSoft(
profileRow.emergencyContactsEncrypted,
"emergencyContacts",
),
emergencyImplants: decryptProfileFieldSoft(
profileRow.emergencyImplantsEncrypted,
"emergencyImplants",
),
emergencyNote: decryptProfileFieldSoft(
profileRow.emergencyNoteEncrypted,
"emergencyNote",
),
}
: null;
@@ -640,6 +689,27 @@ export async function restoreProfileData(
p.coachFocus,
"coachFocus",
),
// Emergency profile. The enums restore by value (null clears);
// the three free-text columns follow the same ciphertext-or-plaintext
// resolution as the self-context columns above.
emergencyBloodType: p.emergencyBloodType,
organDonorStatus: p.organDonorStatus,
advanceDirectiveStatus: p.advanceDirectiveStatus,
emergencyContactsEncrypted: resolveProfileColumn(
p.emergencyContactsEncrypted,
p.emergencyContacts,
"emergencyContacts",
),
emergencyImplantsEncrypted: resolveProfileColumn(
p.emergencyImplantsEncrypted,
p.emergencyImplants,
"emergencyImplants",
),
emergencyNoteEncrypted: resolveProfileColumn(
p.emergencyNoteEncrypted,
p.emergencyNote,
"emergencyNote",
),
// Portable exports do not carry the pending questions at all, so
// `undefined` means "this file has nothing to say" and the account
// comes back with no prompts pending — which the next profile save
+64
View File
@@ -16,6 +16,10 @@ import {
healthProfileFactWriteSchema,
removedHealthProfileFactSchema,
} from "@/lib/validations/health-profile-facts";
import {
emergencyProfileDtoSchema,
emergencyProfileUpdateSchema,
} from "@/lib/validations/emergency-profile";
import {
ACCEPTED_INSIGHTS_TILE_IDS,
INSIGHTS_SECTION_IDS,
@@ -51,6 +55,18 @@ import {
stdResponses,
} from "./shared";
emergencyProfileUpdateSchema.meta({
id: "UpdateEmergencyProfileRequest",
description:
"Partial edit of the emergency (Notfalldaten) profile. An omitted key leaves the column untouched; a `null` enum or an emptied free-text field clears it. `bloodType`, `organDonor` and `advanceDirective` are closed enums; `contacts`, `implants` and `note` are encrypted at rest. Rejects unknown keys.",
});
emergencyProfileDtoSchema.meta({
id: "EmergencyProfile",
description:
"The caller's emergency profile: three closed-enum facts plus three decrypted free-text fields. A free-text field is null when unset; its `*Unreadable` flag is true when ciphertext was present but could not be decrypted (a key-rotation gap, fail-soft rather than 500).",
});
// ── Coach cadence suggestions (v1.18.1) ──────────────────────────────
// The action endpoint behind the one-tap reminder-suggestion card. The
// client sends ONLY the cadence id + the action; the server resolves the
@@ -1360,6 +1376,54 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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: dataEnvelope(
emergencyProfileDtoSchema,
"GetEmergencyProfileResponse",
),
},
},
},
...stdResponses,
},
},
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: emergencyProfileUpdateSchema },
},
},
responses: {
"200": {
description: "The updated emergency profile.",
content: {
"application/json": {
schema: dataEnvelope(
emergencyProfileDtoSchema,
"UpdateEmergencyProfileResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/coach/about-me/questions": {
get: {
tags: ["Insights"],
+186
View File
@@ -0,0 +1,186 @@
/**
* Emergency ("Notfalldaten") profile persistence.
*
* Reads and writes the six emergency columns on the single `UserHealthProfile`
* row, one per user. The three enum columns are plaintext closed sets; the
* three free-text columns are AES-256-GCM at rest via the shared Bytes codec.
*
* Read is fail-soft per encrypted field — a key-rotation gap on one column
* reads as "unreadable" rather than failing the whole surface, the same stance
* the anamnesis conditions and the visit free text take. Write encrypts on the
* way in and never persists plaintext.
*/
import { prisma } from "@/lib/db";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getEvent } from "@/lib/logging/context";
import type {
EmergencyProfileDto,
EmergencyProfileUpdate,
} from "@/lib/validations/emergency-profile";
interface EmergencyProfileRow {
emergencyBloodType: EmergencyProfileDto["bloodType"] | null;
organDonorStatus: EmergencyProfileDto["organDonor"] | null;
advanceDirectiveStatus: EmergencyProfileDto["advanceDirective"] | null;
emergencyContactsEncrypted: Uint8Array | null;
emergencyImplantsEncrypted: Uint8Array | null;
emergencyNoteEncrypted: Uint8Array | null;
}
/**
* The column-shaped patch. Only present keys are written; every key can be
* `null` to clear its column. A plain object rather than a Prisma input type so
* it spreads cleanly into both `create` and `update` (Prisma's update input
* wraps scalars in a field-operations union that a create input would reject).
*/
interface EmergencyDataPatch {
emergencyBloodType?: EmergencyProfileDto["bloodType"] | null;
organDonorStatus?: EmergencyProfileDto["organDonor"] | null;
advanceDirectiveStatus?: EmergencyProfileDto["advanceDirective"] | null;
emergencyContactsEncrypted?: Uint8Array<ArrayBuffer> | null;
emergencyImplantsEncrypted?: Uint8Array<ArrayBuffer> | null;
emergencyNoteEncrypted?: Uint8Array<ArrayBuffer> | null;
}
/** Decrypt one free-text emergency column, fail-soft. */
function decryptField(
buf: Uint8Array | null,
field: string,
userId: string,
): { value: string | null; unreadable: boolean } {
if (!buf || buf.byteLength === 0) return { value: null, unreadable: false };
try {
const text = decryptFromBytes(buf).trim();
return { value: text.length > 0 ? text : null, unreadable: false };
} catch (error) {
getEvent()?.addWarning(
`emergency profile ${field} decrypt failed for ${userId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return { value: null, unreadable: true };
}
}
/** Project a profile row (or its absence) into the read DTO. */
export function toEmergencyProfileDto(
row: EmergencyProfileRow | null,
userId: string,
): EmergencyProfileDto {
const contacts = decryptField(
row?.emergencyContactsEncrypted ?? null,
"contacts",
userId,
);
const implants = decryptField(
row?.emergencyImplantsEncrypted ?? null,
"implants",
userId,
);
const note = decryptField(
row?.emergencyNoteEncrypted ?? null,
"note",
userId,
);
return {
bloodType: row?.emergencyBloodType ?? null,
organDonor: row?.organDonorStatus ?? null,
advanceDirective: row?.advanceDirectiveStatus ?? null,
contacts: contacts.value,
contactsUnreadable: contacts.unreadable,
implants: implants.value,
implantsUnreadable: implants.unreadable,
note: note.value,
noteUnreadable: note.unreadable,
};
}
/**
* Whether an emergency DTO carries anything worth surfacing. An enum resting at
* `UNKNOWN` is the explicit "not stated" answer and does not, on its own, earn a
* page; a real enum answer or any free text (present or unreadable) does. This
* is the presence half of the doctor-report page gate.
*/
export function emergencyProfileHasContent(dto: EmergencyProfileDto): boolean {
const enumSet =
(dto.bloodType !== null && dto.bloodType !== "UNKNOWN") ||
(dto.organDonor !== null && dto.organDonor !== "UNKNOWN") ||
(dto.advanceDirective !== null && dto.advanceDirective !== "UNKNOWN");
const textSet =
dto.contacts !== null ||
dto.contactsUnreadable ||
dto.implants !== null ||
dto.implantsUnreadable ||
dto.note !== null ||
dto.noteUnreadable;
return enumSet || textSet;
}
const EMERGENCY_SELECT = {
emergencyBloodType: true,
organDonorStatus: true,
advanceDirectiveStatus: true,
emergencyContactsEncrypted: true,
emergencyImplantsEncrypted: true,
emergencyNoteEncrypted: true,
} as const;
/** Read the caller's emergency profile as the form-prefill DTO. */
export async function readEmergencyProfile(
userId: string,
): Promise<EmergencyProfileDto> {
const row = await prisma.userHealthProfile.findUnique({
where: { userId },
select: EMERGENCY_SELECT,
});
return toEmergencyProfileDto(row, userId);
}
/**
* Build the `data` patch field-by-field from the parsed body. Only keys the
* body carried are written (an omitted key leaves the column untouched); an
* explicit `null` clears it. No mass assignment — the parsed object is never
* spread. Returns the encrypted-column shape both `create` and `update` accept.
*/
function buildEmergencyData(input: EmergencyProfileUpdate): EmergencyDataPatch {
const data: EmergencyDataPatch = {};
if (input.bloodType !== undefined) data.emergencyBloodType = input.bloodType;
if (input.organDonor !== undefined) data.organDonorStatus = input.organDonor;
if (input.advanceDirective !== undefined) {
data.advanceDirectiveStatus = input.advanceDirective;
}
if (input.contacts !== undefined) {
data.emergencyContactsEncrypted =
input.contacts === null ? null : encryptToBytes(input.contacts);
}
if (input.implants !== undefined) {
data.emergencyImplantsEncrypted =
input.implants === null ? null : encryptToBytes(input.implants);
}
if (input.note !== undefined) {
data.emergencyNoteEncrypted =
input.note === null ? null : encryptToBytes(input.note);
}
return data;
}
/**
* Apply an emergency-profile patch and return the fresh DTO.
*
* Upserts the single profile row: an account that has never opened its profile
* has no row yet, so the first save creates it. The `userId` is the caller's,
* narrowed upstream from `requireAuth()` — it is never read from the body.
*/
export async function writeEmergencyProfile(
userId: string,
input: EmergencyProfileUpdate,
): Promise<EmergencyProfileDto> {
const data = buildEmergencyData(input);
const row = await prisma.userHealthProfile.upsert({
where: { userId },
create: { userId, ...data },
update: data,
select: EMERGENCY_SELECT,
});
return toEmergencyProfileDto(row, userId);
}
+2
View File
@@ -1,4 +1,6 @@
/** Query keys for the bounded, read-only profile summary surface. */
export const profileKeys = {
profileSummary: () => ["profile", "summary"] as const,
/** Emergency ("Notfalldaten") profile (`GET`/`PATCH /api/anamnesis/emergency`). */
emergencyProfile: () => ["emergency-profile"] as const,
};
@@ -52,8 +52,8 @@ function resolves(key: string): boolean {
describe("report selection catalogue", () => {
it("carries every measurement type and every structured leaf", () => {
expect(MEASUREMENT_LEAF_IDS).toHaveLength(77);
expect(STRUCTURED_LEAF_IDS).toHaveLength(16);
expect(ALL_LEAF_IDS).toHaveLength(93);
expect(STRUCTURED_LEAF_IDS).toHaveLength(17);
expect(ALL_LEAF_IDS).toHaveLength(94);
});
it("resolves a label for every leaf", () => {
@@ -48,11 +48,14 @@ describe("scope picker state", () => {
// control covers something they cannot see or reach.
const identity = REPORT_GROUPS.find((g) => g.id === "identity")!;
const rendered = identity.leaves.filter((l) => l !== "INSURANCE");
const selected = new Set<ReportLeafId>(["PATIENT_IDENTITY"]);
expect(groupCount(rendered, selected)).toEqual({ on: 1, total: 1 });
// The share-link form renders identity minus insurance: patient identity
// and the emergency leaf. Selecting both is "all" of the rendered set even
// though the catalogue group also carries the hidden insurance leaf.
const selected = new Set<ReportLeafId>(["PATIENT_IDENTITY", "EMERGENCY"]);
expect(groupCount(rendered, selected)).toEqual({ on: 2, total: 2 });
expect(groupCheckState(rendered, selected)).toBe("all");
// And the whole group, for contrast.
expect(groupCount(identity.leaves, selected)).toEqual({ on: 1, total: 2 });
// And the whole group, for contrast: insurance is unselected and hidden.
expect(groupCount(identity.leaves, selected)).toEqual({ on: 2, total: 3 });
});
it("never adds a leaf the surface does not render", () => {
+4 -1
View File
@@ -37,6 +37,7 @@ import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"
*/
export type StructuredLeafId =
| "PATIENT_IDENTITY"
| "EMERGENCY"
| "INSURANCE"
| "GLUCOSE_PANEL"
| "LAB_RESULTS"
@@ -192,6 +193,7 @@ export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
/** Structured leaf → group. Exhaustive over the closed union. */
export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
PATIENT_IDENTITY: "identity",
EMERGENCY: "identity",
INSURANCE: "identity",
GLUCOSE_PANEL: "glucose",
LAB_RESULTS: "labs",
@@ -209,9 +211,10 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
ANAMNESIS: "sensitive",
};
/** i18n label keys for the 16 structured leaves. */
/** i18n label keys for the 17 structured leaves. */
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
EMERGENCY: "reportSelection.leafEmergency",
INSURANCE: "reportSelection.leafInsurance",
GLUCOSE_PANEL: "reportSelection.leafGlucosePanel",
LAB_RESULTS: "reportSelection.leafLabResults",
+1
View File
@@ -25,6 +25,7 @@ import { SENSITIVE_LEAF_IDS, type ReportLeafId } from "./catalogue";
*/
export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
"PATIENT_IDENTITY",
"EMERGENCY",
"INSURANCE",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
+19
View File
@@ -65,6 +65,11 @@ import {
healthProfileFactKindSchema,
isHealthProfileFactValue,
} from "@/lib/validations/health-profile-facts";
import {
advanceDirectiveStatusSchema,
emergencyBloodTypeSchema,
organDonorStatusSchema,
} from "@/lib/validations/emergency-profile";
export const BACKUP_SCHEMA_VERSION = "2" as const;
const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const;
@@ -529,6 +534,20 @@ const healthProfileBackupSchema = z
aiIncludedSections: z
.array(healthProfileAiSectionSchema)
.default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]),
// Emergency profile: three plaintext enums (carried by value in both
// purposes), three free-text columns following the ciphertext-or-plaintext
// split of the self-context fields above.
emergencyBloodType: emergencyBloodTypeSchema.nullable().default(null),
organDonorStatus: organDonorStatusSchema.nullable().default(null),
advanceDirectiveStatus: advanceDirectiveStatusSchema
.nullable()
.default(null),
emergencyContacts: z.string().nullable().default(null),
emergencyImplants: z.string().nullable().default(null),
emergencyNote: z.string().nullable().default(null),
emergencyContactsEncrypted: base64BytesSchema.nullable().optional(),
emergencyImplantsEncrypted: base64BytesSchema.nullable().optional(),
emergencyNoteEncrypted: base64BytesSchema.nullable().optional(),
aboutMeEncrypted: base64BytesSchema.nullable().optional(),
conditionsEncrypted: base64BytesSchema.nullable().optional(),
allergiesEncrypted: base64BytesSchema.nullable().optional(),
+97
View File
@@ -0,0 +1,97 @@
import { z } from "zod/v4";
/**
* Emergency ("Notfalldaten") profile — the closed enum sets and the free-text
* caps for the three encrypted columns.
*
* The three enums mirror the Prisma enums one-for-one (`EmergencyBloodType`,
* `OrganDonorStatus`, `AdvanceDirectiveStatus`); the arrays are the single
* source the form dropdowns and the Zod schemas both read, so a value cannot
* exist on one side and not the other.
*/
export const EMERGENCY_BLOOD_TYPE_VALUES = [
"A_POS",
"A_NEG",
"B_POS",
"B_NEG",
"AB_POS",
"AB_NEG",
"O_POS",
"O_NEG",
"UNKNOWN",
] as const;
export const ORGAN_DONOR_STATUS_VALUES = ["YES", "NO", "UNKNOWN"] as const;
export const ADVANCE_DIRECTIVE_STATUS_VALUES = [
"EXISTS",
"NONE",
"UNKNOWN",
] as const;
export type EmergencyBloodTypeValue =
(typeof EMERGENCY_BLOOD_TYPE_VALUES)[number];
export type OrganDonorStatusValue = (typeof ORGAN_DONOR_STATUS_VALUES)[number];
export type AdvanceDirectiveStatusValue =
(typeof ADVANCE_DIRECTIVE_STATUS_VALUES)[number];
export const emergencyBloodTypeSchema = z.enum(EMERGENCY_BLOOD_TYPE_VALUES);
export const organDonorStatusSchema = z.enum(ORGAN_DONOR_STATUS_VALUES);
export const advanceDirectiveStatusSchema = z.enum(
ADVANCE_DIRECTIVE_STATUS_VALUES,
);
/** Free-text length caps, applied BEFORE encryption. */
export const EMERGENCY_CONTACTS_MAX = 2000;
export const EMERGENCY_IMPLANTS_MAX = 1000;
export const EMERGENCY_NOTE_MAX = 2000;
/**
* One free-text field: trims, treats an emptied field as a clear (`null`), and
* caps the length. `null` clears the column; an omitted key leaves it untouched
* (the route only writes keys the body carried).
*/
function freeText(max: number) {
return z
.string()
.trim()
.max(max)
.transform((value) => (value.length === 0 ? null : value))
.nullable();
}
/**
* PATCH body. Every field is optional so a partial edit leaves the columns it
* omits untouched; an explicit `null` clears that field. `.strict()` refuses an
* unknown key rather than silently dropping it.
*/
export const emergencyProfileUpdateSchema = z
.object({
bloodType: emergencyBloodTypeSchema.nullable().optional(),
organDonor: organDonorStatusSchema.nullable().optional(),
advanceDirective: advanceDirectiveStatusSchema.nullable().optional(),
contacts: freeText(EMERGENCY_CONTACTS_MAX).optional(),
implants: freeText(EMERGENCY_IMPLANTS_MAX).optional(),
note: freeText(EMERGENCY_NOTE_MAX).optional(),
})
.strict();
export type EmergencyProfileUpdate = z.infer<
typeof emergencyProfileUpdateSchema
>;
/** The read shape the GET route returns and the form prefills from. */
export const emergencyProfileDtoSchema = z.object({
bloodType: emergencyBloodTypeSchema.nullable(),
organDonor: organDonorStatusSchema.nullable(),
advanceDirective: advanceDirectiveStatusSchema.nullable(),
contacts: z.string().nullable(),
contactsUnreadable: z.boolean(),
implants: z.string().nullable(),
implantsUnreadable: z.boolean(),
note: z.string().nullable(),
noteUnreadable: z.boolean(),
});
export type EmergencyProfileDto = z.infer<typeof emergencyProfileDtoSchema>;
@@ -0,0 +1,91 @@
/**
* Migration 0331 shape check, against the real migrated Postgres.
*
* The testcontainer applies every migration on boot, so this asserts the
* three enum types and the six nullable columns migration 0331 adds actually
* exist on the live schema. A migration that never ran, or one that named a
* column differently from the Prisma model, turns this red before any feature
* test that depends on the columns runs.
*/
import { describe, expect, it } from "vitest";
import { getPrismaClient } from "./setup";
describe("migration 0331 — emergency profile schema", () => {
it("adds the six nullable emergency columns to user_health_profiles", async () => {
const rows = await getPrismaClient().$queryRawUnsafe<
Array<{ column_name: string; is_nullable: string; data_type: string }>
>(
`SELECT column_name, is_nullable, data_type
FROM information_schema.columns
WHERE table_name = 'user_health_profiles'
AND column_name IN (
'emergency_blood_type',
'organ_donor_status',
'advance_directive_status',
'emergency_contacts_encrypted',
'emergency_implants_encrypted',
'emergency_note_encrypted'
)
ORDER BY column_name`,
);
const byName = new Map(rows.map((r) => [r.column_name, r]));
for (const col of [
"emergency_blood_type",
"organ_donor_status",
"advance_directive_status",
"emergency_contacts_encrypted",
"emergency_implants_encrypted",
"emergency_note_encrypted",
]) {
expect(byName.get(col), `${col} is missing`).toBeDefined();
expect(byName.get(col)!.is_nullable, `${col} must be nullable`).toBe(
"YES",
);
}
expect(byName.get("emergency_contacts_encrypted")!.data_type).toBe("bytea");
expect(byName.get("emergency_blood_type")!.data_type).toBe("USER-DEFINED");
});
it("creates the three emergency enum types with their members", async () => {
const rows = await getPrismaClient().$queryRawUnsafe<
Array<{ typname: string; enumlabel: string }>
>(
`SELECT t.typname, e.enumlabel
FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname IN (
'emergency_blood_type',
'organ_donor_status',
'advance_directive_status'
)
ORDER BY t.typname, e.enumsortorder`,
);
const byType = new Map<string, string[]>();
for (const r of rows) {
(byType.get(r.typname) ?? byType.set(r.typname, []).get(r.typname)!).push(
r.enumlabel,
);
}
expect(byType.get("emergency_blood_type")).toEqual([
"A_POS",
"A_NEG",
"B_POS",
"B_NEG",
"AB_POS",
"AB_NEG",
"O_POS",
"O_NEG",
"UNKNOWN",
]);
expect(byType.get("organ_donor_status")).toEqual(["YES", "NO", "UNKNOWN"]);
expect(byType.get("advance_directive_status")).toEqual([
"EXISTS",
"NONE",
"UNKNOWN",
]);
});
});
@@ -0,0 +1,136 @@
/**
* Emergency ("Notfalldaten") profile route against a real Postgres.
*
* The point of this file is the write half: a PATCH that reports success but
* never lands looks exactly like one that did. So it PATCHes through the real
* route, then reads the `user_health_profiles` row DIRECTLY and asserts the
* columns changed — the plaintext enums by value, the encrypted contact column
* by decrypting it back. Dropping any field from the handler's data builder
* turns the matching assertion red.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-emergency-profile-integration-32-bytes-1234567890";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const USER_ID = "user-emergency-profile";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
async function loginAs(userId: string): Promise<void> {
cookieJar.clear();
headerJar.clear();
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
}
function patch(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/anamnesis/emergency", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "emergency-owner",
email: "emergency-owner@example.test",
timezone: "UTC",
},
});
});
describe("emergency profile route — the write lands in the row", () => {
it("persists the enums by value and the contacts column as decryptable ciphertext", async () => {
await loginAs(USER_ID);
const { PATCH } = await import("@/app/api/anamnesis/emergency/route");
const { decryptFromBytes } = await import("@/lib/ai/coach/bytes-codec");
const CONTACTS = "ICE contact, reachable on the recorded number.";
const res = await PATCH(
patch({
bloodType: "O_NEG",
organDonor: "YES",
advanceDirective: "EXISTS",
contacts: CONTACTS,
}),
);
expect(res.status).toBe(200);
const row = await getPrismaClient().userHealthProfile.findUniqueOrThrow({
where: { userId: USER_ID },
select: {
emergencyBloodType: true,
organDonorStatus: true,
advanceDirectiveStatus: true,
emergencyContactsEncrypted: true,
emergencyImplantsEncrypted: true,
},
});
expect(row.emergencyBloodType).toBe("O_NEG");
expect(row.organDonorStatus).toBe("YES");
expect(row.advanceDirectiveStatus).toBe("EXISTS");
// The column is genuinely ciphertext (not the plaintext), and it decrypts
// back to exactly what was sent.
expect(row.emergencyContactsEncrypted).not.toBeNull();
expect(decryptFromBytes(row.emergencyContactsEncrypted!)).toBe(CONTACTS);
// An omitted field stays untouched.
expect(row.emergencyImplantsEncrypted).toBeNull();
});
it("reads the same values back through GET, and an emptied field clears its column", async () => {
await loginAs(USER_ID);
const { GET, PATCH } = await import("@/app/api/anamnesis/emergency/route");
await PATCH(patch({ bloodType: "A_POS", contacts: "first note" }));
const cleared = await PATCH(patch({ contacts: "" }));
expect(cleared.status).toBe(200);
const res = await GET();
const body = (await res.json()) as {
data: { bloodType: string | null; contacts: string | null };
};
expect(body.data.bloodType).toBe("A_POS");
expect(body.data.contacts).toBeNull();
const row = await getPrismaClient().userHealthProfile.findUniqueOrThrow({
where: { userId: USER_ID },
select: { emergencyContactsEncrypted: true },
});
expect(row.emergencyContactsEncrypted).toBeNull();
});
});
@@ -24,6 +24,9 @@ async function seedProfile() {
role: "USER",
},
});
const emergencyContactsCiphertext = encryptToBytes(
"ICE contact, reachable on the recorded number",
);
await prisma.userHealthProfile.create({
data: {
id: "profile-roundtrip-row",
@@ -31,6 +34,13 @@ async function seedProfile() {
aboutMeEncrypted: encryptToBytes("Works rotating shifts"),
conditionsEncrypted: encryptToBytes("Asthma"),
aiIncludedSections: ["CONDITIONS", "SMOKING_STATUS", "SHIFT_SCHEDULE"],
// Emergency profile: three plaintext enums + three encrypted columns.
emergencyBloodType: "O_NEG",
organDonorStatus: "YES",
advanceDirectiveStatus: "EXISTS",
emergencyContactsEncrypted: emergencyContactsCiphertext,
emergencyImplantsEncrypted: encryptToBytes("Pacemaker fitted 2021"),
emergencyNoteEncrypted: encryptToBytes("Reacts to contrast dye"),
},
});
@@ -68,13 +78,14 @@ async function seedProfile() {
},
});
return { user, currentCiphertext };
return { user, currentCiphertext, emergencyContactsCiphertext };
}
describe("health profile disaster-recovery round trip", () => {
it("restores deleted encrypted profile rows and effective-dated history", async () => {
const prisma = getPrismaClient();
const { user, currentCiphertext } = await seedProfile();
const { user, currentCiphertext, emergencyContactsCiphertext } =
await seedProfile();
const built = await buildFullBackupPayload(prisma, user.id, {
purpose: "disaster-recovery",
});
@@ -116,6 +127,21 @@ describe("health profile disaster-recovery round trip", () => {
"SHIFT_SCHEDULE",
]);
// Every emergency column has to survive the round trip. The three enums
// come back by value; the three encrypted columns come back as ciphertext,
// and the contact column decrypts to exactly what was seeded. Dropping any
// one of the six from the profile backup builder turns the matching
// assertion red naming that column.
expect(profile.emergencyBloodType).toBe("O_NEG");
expect(profile.organDonorStatus).toBe("YES");
expect(profile.advanceDirectiveStatus).toBe("EXISTS");
expect(profile.emergencyContactsEncrypted).not.toBeNull();
expect(profile.emergencyImplantsEncrypted).not.toBeNull();
expect(profile.emergencyNoteEncrypted).not.toBeNull();
expect(Buffer.from(profile.emergencyContactsEncrypted!)).toEqual(
Buffer.from(emergencyContactsCiphertext),
);
const revisions = await prisma.healthProfileFactRevision.findMany({
where: { userId: user.id },
orderBy: { validFrom: "asc" },