From 9e52285acc4940a0e9a2d31ce6fc4187722ef017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 04:54:29 +0200 Subject: [PATCH 1/6] feat(profile): add emergency profile storage Fold six nullable columns into user_health_profiles: three closed-set enums (blood type, organ-donor status, advance-directive status) read as plaintext, and three AES-256-GCM free-text columns (emergency contacts, implants, ICE note). Migration 0331 is additive only: three CREATE TYPE plus one ALTER TABLE with nullable ADD COLUMNs. An integration test asserts the columns and enum types exist on the migrated schema. --- .../0331_emergency_profile/migration.sql | 28 ++++++ prisma/schema.prisma | 49 ++++++++++ .../emergency-profile-migration.test.ts | 91 +++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 prisma/migrations/0331_emergency_profile/migration.sql create mode 100644 tests/integration/emergency-profile-migration.test.ts 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/tests/integration/emergency-profile-migration.test.ts b/tests/integration/emergency-profile-migration.test.ts new file mode 100644 index 000000000..17ef79abd --- /dev/null +++ b/tests/integration/emergency-profile-migration.test.ts @@ -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(); + 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", + ]); + }); +}); From 772da42348e4212a3b02b2464025ae63b3a42ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 04:54:42 +0200 Subject: [PATCH 2/6] feat(backup): carry emergency profile through rotation and backup Register the three encrypted emergency columns in the rotation registry and the key-rotation script so a legacy-key drop cannot strand them. Carry all six new columns through the profile backup builder and restore (the enums by value, the free text as ciphertext in a DR payload or decrypted plaintext in a portable export) and extend the wire schema. The full-backup round-trip integration test seeds the six columns and asserts each survives an export and restore. --- scripts/rotate-encryption-key.ts | 5 ++ src/lib/crypto/encrypted-columns.ts | 19 +++++ src/lib/export/profile-backup.ts | 70 +++++++++++++++++++ src/lib/validations/backup.ts | 19 +++++ .../health-profile-backup-roundtrip.test.ts | 30 +++++++- 5 files changed, 141 insertions(+), 2 deletions(-) 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/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 99afe45c9..9ecca647b 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -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) ───── { diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index 46011f936..955b90c87 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -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 diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index a9fdb0522..e22f68524 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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(), diff --git a/tests/integration/health-profile-backup-roundtrip.test.ts b/tests/integration/health-profile-backup-roundtrip.test.ts index 945e2bf1f..abe77d5ae 100644 --- a/tests/integration/health-profile-backup-roundtrip.test.ts +++ b/tests/integration/health-profile-backup-roundtrip.test.ts @@ -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" }, From 6770bf3b7fad215676945fb132d712a732cf1fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 04:54:52 +0200 Subject: [PATCH 3/6] feat(api): add emergency profile read and write route GET/PATCH /api/anamnesis/emergency, wrapped in apiHandler. The PATCH validates a partial body with per-field length caps, builds the Prisma data field-by-field from the parsed input (never a spread), narrows the user from requireAuth rather than the body, encrypts the free text on write, and audits the change. The GET decrypts fail-soft for the form prefill. Registers the OpenAPI operations and schema. An integration test reads the row back to prove the write lands. --- docs/api/openapi.yaml | 247 +++++++++++++++++- src/app/api/anamnesis/emergency/route.ts | 59 +++++ src/lib/openapi/routes/coach.ts | 64 +++++ src/lib/profile/emergency-profile.ts | 186 +++++++++++++ src/lib/query-keys/profile.ts | 2 + src/lib/validations/emergency-profile.ts | 97 +++++++ .../emergency-profile-route.test.ts | 136 ++++++++++ 7 files changed, 788 insertions(+), 3 deletions(-) create mode 100644 src/app/api/anamnesis/emergency/route.ts create mode 100644 src/lib/profile/emergency-profile.ts create mode 100644 src/lib/validations/emergency-profile.ts create mode 100644 tests/integration/emergency-profile-route.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a06955585..e9facda02 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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/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/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 268dd7c02..814cdfbf0 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -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 = { }, }, }, + "/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"], diff --git a/src/lib/profile/emergency-profile.ts b/src/lib/profile/emergency-profile.ts new file mode 100644 index 000000000..a888d24a6 --- /dev/null +++ b/src/lib/profile/emergency-profile.ts @@ -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 | null; + emergencyImplantsEncrypted?: Uint8Array | null; + emergencyNoteEncrypted?: Uint8Array | 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 { + 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 { + const data = buildEmergencyData(input); + const row = await prisma.userHealthProfile.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + select: EMERGENCY_SELECT, + }); + return toEmergencyProfileDto(row, userId); +} diff --git a/src/lib/query-keys/profile.ts b/src/lib/query-keys/profile.ts index 53d0260aa..9349931f8 100644 --- a/src/lib/query-keys/profile.ts +++ b/src/lib/query-keys/profile.ts @@ -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, }; diff --git a/src/lib/validations/emergency-profile.ts b/src/lib/validations/emergency-profile.ts new file mode 100644 index 000000000..ffde4f317 --- /dev/null +++ b/src/lib/validations/emergency-profile.ts @@ -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; diff --git a/tests/integration/emergency-profile-route.test.ts b/tests/integration/emergency-profile-route.test.ts new file mode 100644 index 000000000..cfe682c01 --- /dev/null +++ b/tests/integration/emergency-profile-route.test.ts @@ -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 { + 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(); + }); +}); From 310a9a4690cfb5954750a7661c805e641de33505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 04:55:03 +0200 Subject: [PATCH 4/6] feat(doctor-report): put emergency data on page one Add an EMERGENCY leaf to the identity group of the report catalogue, default-on, no module gate. When the leaf is admitted and the profile holds emergency data, the aggregator collects it and the PDF renders an emergency sheet as page one (banner, blood type, severe allergies, active medications, chronic conditions, implants, advance directive and organ donor, contacts, notes), then breaks to send the rest of the report to page two. The section is withheld when the leaf is not admitted or no data is present. Guards updated for the new leaf counts. --- ...doctor-report-control-gating-guard.test.ts | 4 +- .../__tests__/report-selection-panel.test.tsx | 2 +- .../__tests__/sharing-reselect.test.tsx | 11 +- .../__tests__/doctor-report-pdf-core.test.ts | 45 +++++ src/lib/doctor-report-pdf-core.ts | 10 ++ .../emergency-first-page-section.ts | 158 ++++++++++++++++++ src/lib/doctor-report-types.ts | 24 +++ .../__tests__/leaf-gating-sweep.test.ts | 28 +++- src/lib/doctor-report/clinical-records.ts | 21 +++ src/lib/doctor-report/collect.ts | 4 + .../__tests__/catalogue-guard.test.ts | 4 +- .../__tests__/panel-state.test.ts | 11 +- src/lib/report-selection/catalogue.ts | 5 +- src/lib/report-selection/template.ts | 1 + 14 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 src/lib/doctor-report-pdf/emergency-first-page-section.ts 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/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index 83535c8b4..d18a73f5c 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -187,7 +187,7 @@ describe(" — 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"); diff --git a/src/components/settings/__tests__/sharing-reselect.test.tsx b/src/components/settings/__tests__/sharing-reselect.test.tsx index 138f2ea75..4a01667fc 100644 --- a/src/components/settings/__tests__/sharing-reselect.test.tsx +++ b/src/components/settings/__tests__/sharing-reselect.test.tsx @@ -62,15 +62,16 @@ describe("share-link create form — re-mint seeding", () => { const html = render(); 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"'); diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index ff2ca9606..48092270c 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -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 = { + 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({ diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 0dbf833f9..f920068eb 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -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); diff --git a/src/lib/doctor-report-pdf/emergency-first-page-section.ts b/src/lib/doctor-report-pdf/emergency-first-page-section.ts new file mode 100644 index 000000000..ecb56efa4 --- /dev/null +++ b/src/lib/doctor-report-pdf/emergency-first-page-section.ts @@ -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); +} diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 056cbdd1e..c342f50e8 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -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. */ diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index ffb179dd2..5ebd93d96 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -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 = { + 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(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(); diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts index 380896a91..84ba348bd 100644 --- a/src/lib/doctor-report/clinical-records.ts +++ b/src/lib/doctor-report/clinical-records.ts @@ -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 { + 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 diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 50c9ca5fe..42ce9e3dc 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -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, }; } diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index cce014fe6..038c50b57 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -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", () => { diff --git a/src/lib/report-selection/__tests__/panel-state.test.ts b/src/lib/report-selection/__tests__/panel-state.test.ts index 694d4c15d..ba27ba526 100644 --- a/src/lib/report-selection/__tests__/panel-state.test.ts +++ b/src/lib/report-selection/__tests__/panel-state.test.ts @@ -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(["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(["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", () => { diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index ce96feed5..681112289 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -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 = { /** Structured leaf → group. Exhaustive over the closed union. */ export const STRUCTURED_LEAF_GROUP: Record = { PATIENT_IDENTITY: "identity", + EMERGENCY: "identity", INSURANCE: "identity", GLUCOSE_PANEL: "glucose", LAB_RESULTS: "labs", @@ -209,9 +211,10 @@ export const STRUCTURED_LEAF_GROUP: Record = { 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 = { PATIENT_IDENTITY: "reportSelection.leafPatientIdentity", + EMERGENCY: "reportSelection.leafEmergency", INSURANCE: "reportSelection.leafInsurance", GLUCOSE_PANEL: "reportSelection.leafGlucosePanel", LAB_RESULTS: "reportSelection.leafLabResults", diff --git a/src/lib/report-selection/template.ts b/src/lib/report-selection/template.ts index 722efbbbe..0a2cc953f 100644 --- a/src/lib/report-selection/template.ts +++ b/src/lib/report-selection/template.ts @@ -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", From 57938982ea1dadd21d3be4c2b38c510150c9f841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 10 Aug 2026 04:55:12 +0200 Subject: [PATCH 5/6] feat(settings): edit emergency profile under Anamnese Add a Notfalldaten card to the Anamnese settings section with a manager for the three enum facts and the three free-text fields, writing through the new emergency route. Add every string across the six shipped locales. --- messages/de.json | 81 +++++++- messages/en.json | 79 ++++++- messages/es.json | 81 +++++++- messages/fr.json | 81 +++++++- messages/it.json | 81 +++++++- messages/pl.json | 81 +++++++- .../records/emergency-profile-manager.tsx | 192 ++++++++++++++++++ .../anamnesis-ai-inclusion-gate.test.tsx | 3 + src/components/settings/anamnesis-section.tsx | 11 + 9 files changed, 679 insertions(+), 11 deletions(-) create mode 100644 src/components/records/emergency-profile-manager.tsx 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/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, + ) => ( +
+ +