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", + ]); + }); +});