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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-10 05:32:58 +02:00
1 parent a4bea8b0ce
commit 9e52285acc
3 files changed
+168

No files matched your search

@@ -0,0 +1,28 @@
-- Emergency ("Notfalldaten") facts on the health profile.
--
-- Additive only. No table is dropped or restructured: three new enum types and
-- six new nullable columns fold into the existing `user_health_profiles` row,
-- one row per user. The three enums are plaintext closed sets read directly by
-- the doctor-report emergency page (the same shape `User.gender` uses); the
-- three BYTEA columns hold AES-256-GCM ciphertext, fail-closed on read like
-- every other `*_encrypted` column. Every column is nullable, so an account
-- that never fills in an emergency profile carries six NULLs and nothing
-- surfaces.
-- CreateEnum
CREATE TYPE "emergency_blood_type" AS ENUM ('A_POS', 'A_NEG', 'B_POS', 'B_NEG', 'AB_POS', 'AB_NEG', 'O_POS', 'O_NEG', 'UNKNOWN');
-- CreateEnum
CREATE TYPE "organ_donor_status" AS ENUM ('YES', 'NO', 'UNKNOWN');
-- CreateEnum
CREATE TYPE "advance_directive_status" AS ENUM ('EXISTS', 'NONE', 'UNKNOWN');
-- AlterTable
ALTER TABLE "user_health_profiles"
ADD COLUMN "emergency_blood_type" "emergency_blood_type",
ADD COLUMN "organ_donor_status" "organ_donor_status",
ADD COLUMN "advance_directive_status" "advance_directive_status",
ADD COLUMN "emergency_contacts_encrypted" BYTEA,
ADD COLUMN "emergency_implants_encrypted" BYTEA,
ADD COLUMN "emergency_note_encrypted" BYTEA;
+49
View File
@@ -5945,6 +5945,19 @@ model UserHealthProfile {
// The static default preserves every pre-existing prompt section for legacy
// rows without automatically opting future enum members in.
aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
// Emergency ("Notfalldaten") facts, surfaced on page one of the doctor
// report and edited in Settings -> Anamnese. Three closed-set enums are
// plaintext (read directly by the report, like `User.gender`); three free
// text columns are AES-256-GCM at rest, fail-closed on read like every other
// `*Encrypted` column. All six are nullable: an unset emergency profile is
// absent, not empty. The report page is withheld unless the EMERGENCY leaf is
// admitted AND at least one of these holds a value.
emergencyBloodType EmergencyBloodType? @map("emergency_blood_type")
organDonorStatus OrganDonorStatus? @map("organ_donor_status")
advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status")
emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -5953,6 +5966,42 @@ model UserHealthProfile {
@@map("user_health_profiles")
}
/// ABO/Rh blood group, plus an explicit UNKNOWN so "not known" is a recorded
/// answer rather than an absent row. Plaintext, read directly by the emergency
/// report page.
enum EmergencyBloodType {
A_POS
A_NEG
B_POS
B_NEG
AB_POS
AB_NEG
O_POS
O_NEG
UNKNOWN
@@map("emergency_blood_type")
}
/// Organ-donor declaration. UNKNOWN is the explicit "not stated" answer.
enum OrganDonorStatus {
YES
NO
UNKNOWN
@@map("organ_donor_status")
}
/// Whether an advance directive (Patientenverfügung) exists. UNKNOWN is the
/// explicit "not stated" answer.
enum AdvanceDirectiveStatus {
EXISTS
NONE
UNKNOWN
@@map("advance_directive_status")
}
/// Sections of the health profile that may enter AI prompt context. This is an
/// inclusion list: a section absent from the array is neither decrypted nor
/// assembled for Coach or comprehensive briefings.
@@ -0,0 +1,91 @@
/**
* Migration 0331 shape check, against the real migrated Postgres.
*
* The testcontainer applies every migration on boot, so this asserts the
* three enum types and the six nullable columns migration 0331 adds actually
* exist on the live schema. A migration that never ran, or one that named a
* column differently from the Prisma model, turns this red before any feature
* test that depends on the columns runs.
*/
import { describe, expect, it } from "vitest";
import { getPrismaClient } from "./setup";
describe("migration 0331 — emergency profile schema", () => {
it("adds the six nullable emergency columns to user_health_profiles", async () => {
const rows = await getPrismaClient().$queryRawUnsafe<
Array<{ column_name: string; is_nullable: string; data_type: string }>
>(
`SELECT column_name, is_nullable, data_type
FROM information_schema.columns
WHERE table_name = 'user_health_profiles'
AND column_name IN (
'emergency_blood_type',
'organ_donor_status',
'advance_directive_status',
'emergency_contacts_encrypted',
'emergency_implants_encrypted',
'emergency_note_encrypted'
)
ORDER BY column_name`,
);
const byName = new Map(rows.map((r) => [r.column_name, r]));
for (const col of [
"emergency_blood_type",
"organ_donor_status",
"advance_directive_status",
"emergency_contacts_encrypted",
"emergency_implants_encrypted",
"emergency_note_encrypted",
]) {
expect(byName.get(col), `${col} is missing`).toBeDefined();
expect(byName.get(col)!.is_nullable, `${col} must be nullable`).toBe(
"YES",
);
}
expect(byName.get("emergency_contacts_encrypted")!.data_type).toBe("bytea");
expect(byName.get("emergency_blood_type")!.data_type).toBe("USER-DEFINED");
});
it("creates the three emergency enum types with their members", async () => {
const rows = await getPrismaClient().$queryRawUnsafe<
Array<{ typname: string; enumlabel: string }>
>(
`SELECT t.typname, e.enumlabel
FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname IN (
'emergency_blood_type',
'organ_donor_status',
'advance_directive_status'
)
ORDER BY t.typname, e.enumsortorder`,
);
const byType = new Map<string, string[]>();
for (const r of rows) {
(byType.get(r.typname) ?? byType.set(r.typname, []).get(r.typname)!).push(
r.enumlabel,
);
}
expect(byType.get("emergency_blood_type")).toEqual([
"A_POS",
"A_NEG",
"B_POS",
"B_NEG",
"AB_POS",
"AB_NEG",
"O_POS",
"O_NEG",
"UNKNOWN",
]);
expect(byType.get("organ_donor_status")).toEqual(["YES", "NO", "UNKNOWN"]);
expect(byType.get("advance_directive_status")).toEqual([
"EXISTS",
"NONE",
"UNKNOWN",
]);
});
});