mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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;
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user