feat(records): add allergy + family-history data model

Introduce two structured health-record tables, kept as reference records
(not time-series signals):

- `allergies` — AllergyIntolerance-style: substance + category + type +
  severity + status + optional onset. The substance label stays queryable
  plaintext; the free-text reaction and note are AES-256-GCM encrypted
  (`reaction_encrypted` / `notes_encrypted` BYTEA).
- `family_history_entries` — one condition per relative: relationship +
  condition label + optional age-at-onset; the free-text note is encrypted.

Both are owner-scoped and soft-deleted. Migrations 0215/0216 are additive
and idempotent. The three new encrypted columns are registered in
ENCRYPTED_COLUMNS and wired into the key-rotation script, so a key drop can
never silently strand them undecryptable.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:02:54 +02:00
1 parent 3a84a07423
commit 3ded891641
5 files changed
+306

No files matched your search

@@ -0,0 +1,81 @@
-- v1.25 (W-RECORDS) — structured allergies / intolerances.
--
-- A self-recorded AllergyIntolerance-style record: substance + category +
-- type + severity + status + optional onset, with the free-text reaction
-- description and notes encrypted at rest (BYTEA, the AES-256-GCM
-- `*_encrypted` convention shared with the illness / mood / lab note
-- columns). The `substance` label stays queryable plaintext (the
-- medication.name / illness.label precedent) — it rides the FHIR
-- AllergyIntolerance `code.text` anchor, never a machine-guessed code.
--
-- Patient-reported reference data, not a time-series signal and not a
-- clinical diagnosis. Soft-deleted (`deleted_at`). Owner-scoped via the FK.
--
-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` /
-- `DO $$`) so reruns are safe on prod.
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "allergies";
-- DROP TYPE IF EXISTS "allergy_status";
-- DROP TYPE IF EXISTS "allergy_severity";
-- DROP TYPE IF EXISTS "allergy_type";
-- DROP TYPE IF EXISTS "allergy_category";
-- A roll-back drops the allergy record wholesale — no other domain depends
-- on it.
-- CreateEnum
DO $$ BEGIN
CREATE TYPE "allergy_category" AS ENUM ('FOOD', 'MEDICATION', 'ENVIRONMENT', 'BIOLOGIC', 'OTHER');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- CreateEnum
DO $$ BEGIN
CREATE TYPE "allergy_type" AS ENUM ('ALLERGY', 'INTOLERANCE');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- CreateEnum
DO $$ BEGIN
CREATE TYPE "allergy_severity" AS ENUM ('MILD', 'MODERATE', 'SEVERE');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- CreateEnum
DO $$ BEGIN
CREATE TYPE "allergy_status" AS ENUM ('ACTIVE', 'INACTIVE', 'RESOLVED');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- CreateTable
CREATE TABLE IF NOT EXISTS "allergies" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"substance" TEXT NOT NULL,
"category" "allergy_category" NOT NULL DEFAULT 'OTHER',
"type" "allergy_type" NOT NULL DEFAULT 'ALLERGY',
"severity" "allergy_severity",
"status" "allergy_status" NOT NULL DEFAULT 'ACTIVE',
"onset_at" TIMESTAMP(3),
"reaction_encrypted" BYTEA,
"notes_encrypted" BYTEA,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "allergies_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "allergies_user_id_deleted_at_idx" ON "allergies"("user_id", "deleted_at");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "allergies" ADD CONSTRAINT "allergies_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
@@ -0,0 +1,58 @@
-- v1.25 (W-RECORDS) — structured family history.
--
-- One condition recorded for one relative (a FHIR FamilyMemberHistory with a
-- single condition): relationship + condition label + optional age-at-onset,
-- with the optional free-text note encrypted at rest (BYTEA, the AES-256-GCM
-- `*_encrypted` convention). The `condition` label stays queryable plaintext
-- (the medication.name / illness.label precedent) — it rides the FHIR
-- FamilyMemberHistory `condition.code.text` anchor, never a machine-guessed
-- code.
--
-- Patient-reported reference data, not a time-series signal and not a clinical
-- diagnosis. Soft-deleted (`deleted_at`). Owner-scoped via the FK.
--
-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` /
-- `DO $$`) so reruns are safe on prod.
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "family_history_entries";
-- DROP TYPE IF EXISTS "family_relationship";
-- A roll-back drops the family-history record wholesale — no other domain
-- depends on it.
-- CreateEnum
DO $$ BEGIN
CREATE TYPE "family_relationship" AS ENUM (
'MOTHER', 'FATHER', 'SISTER', 'BROTHER', 'DAUGHTER', 'SON',
'GRANDMOTHER_MATERNAL', 'GRANDFATHER_MATERNAL',
'GRANDMOTHER_PATERNAL', 'GRANDFATHER_PATERNAL',
'AUNT', 'UNCLE', 'COUSIN', 'HALF_SIBLING', 'OTHER'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- CreateTable
CREATE TABLE IF NOT EXISTS "family_history_entries" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"relationship" "family_relationship" NOT NULL,
"condition" TEXT NOT NULL,
"age_at_onset" INTEGER,
"notes_encrypted" BYTEA,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "family_history_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "family_history_entries_user_id_deleted_at_idx" ON "family_history_entries"("user_id", "deleted_at");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "family_history_entries" ADD CONSTRAINT "family_history_entries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
+142
View File
@@ -720,6 +720,12 @@ model User {
// per-day timeline mirroring the CycleDayLog shape.
illnessEpisodes IllnessEpisode[]
illnessDayLogs IllnessDayLog[]
// v1.25 (W-RECORDS) — structured health-record entries. Allergies map to
// FHIR AllergyIntolerance; family-history entries map to FHIR
// FamilyMemberHistory. Reference records (not time-series signals); the
// sensitive free-text fields are encrypted at rest.
allergies Allergy[]
familyHistory FamilyHistoryEntry[]
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
@@ -5110,6 +5116,142 @@ model IllnessSymptomLink {
@@map("illness_symptom_links")
}
// ─── Structured health records (v1.25 W-RECORDS) ──────────────────
//
// Allergies + family history kept as STRUCTURED records (not time-series
// signals — they are NOT in the signal registry). Reference data the user
// curates once and edits rarely, surfaced in the Settings "Anamnese"
// (medical-history) section and exported to FHIR (AllergyIntolerance /
// FamilyMemberHistory). Patient-reported throughout — never a clinical
// diagnosis the app asserts. Sensitive free-text fields are AES-256-GCM
// encrypted at rest (`*Encrypted` Bytes columns, the shared note codec);
// the structured enum fields stay queryable plaintext. Soft-deleted.
/// Broad allergen category for an Allergy (maps to FHIR
/// AllergyIntolerance.category — food / medication / environment / biologic).
enum AllergyCategory {
FOOD
MEDICATION
ENVIRONMENT
BIOLOGIC
OTHER
@@map("allergy_category")
}
/// Distinguishes an immune-mediated allergy from a non-immune intolerance
/// (FHIR AllergyIntolerance.type allergy | intolerance).
enum AllergyType {
ALLERGY
INTOLERANCE
@@map("allergy_type")
}
/// Worst-reaction severity (FHIR AllergyIntolerance.reaction.severity /
/// criticality). NULL = not assessed.
enum AllergySeverity {
MILD
MODERATE
SEVERE
@@map("allergy_severity")
}
/// Clinical status of the record (FHIR AllergyIntolerance.clinicalStatus).
enum AllergyStatus {
ACTIVE
INACTIVE
RESOLVED
@@map("allergy_status")
}
/// One self-recorded allergy/intolerance. `substance` is the queryable
/// human label (the AllergyIntolerance `code.text` anchor — never a
/// machine-guessed SNOMED/RxNorm code). The free-text reaction description
/// and notes are encrypted at rest. Patient-reported; the export carries a
/// "not a clinical diagnosis" guard rail.
model Allergy {
id String @id @default(cuid())
userId String @map("user_id")
/// User-facing substance/allergen name, e.g. "Penicillin", "Peanuts".
/// Queryable plaintext label (the medication.name / illness.label
/// precedent); rides FHIR `code.text`.
substance String
category AllergyCategory @default(OTHER)
type AllergyType @default(ALLERGY)
/// Worst observed reaction severity. NULL = not assessed.
severity AllergySeverity?
status AllergyStatus @default(ACTIVE)
/// When the allergy was first noticed (UTC instant). NULL = unknown.
onsetAt DateTime? @map("onset_at")
/// AES-256-GCM ciphertext of the free-text reaction/manifestation
/// description (e.g. "anaphylaxis", "hives"). Bytes via the shared codec.
reactionEncrypted Bytes? @map("reaction_encrypted")
/// AES-256-GCM ciphertext of an optional free-text note. Bytes column.
notesEncrypted Bytes? @map("notes_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, deletedAt])
@@map("allergies")
}
/// Relationship of a family member to the user (maps to FHIR
/// FamilyMemberHistory.relationship). Lateralised grandparents let the
/// maternal/paternal line be recorded where the user knows it.
enum FamilyRelationship {
MOTHER
FATHER
SISTER
BROTHER
DAUGHTER
SON
GRANDMOTHER_MATERNAL
GRANDFATHER_MATERNAL
GRANDMOTHER_PATERNAL
GRANDFATHER_PATERNAL
AUNT
UNCLE
COUSIN
HALF_SIBLING
OTHER
@@map("family_relationship")
}
/// One condition recorded for one relative (a FHIR FamilyMemberHistory with
/// a single condition). `condition` is the queryable human label (the
/// FamilyMemberHistory.condition `code.text` anchor — never a machine-guessed
/// code); `ageAtOnset` is the relative's age when the condition began. The
/// optional free-text note is encrypted at rest. Patient-reported.
model FamilyHistoryEntry {
id String @id @default(cuid())
userId String @map("user_id")
relationship FamilyRelationship
/// User-facing condition name, e.g. "Type 2 diabetes", "Breast cancer".
/// Queryable plaintext label; rides FHIR `condition.code.text`.
condition String
/// The relative's age (years) when the condition began. NULL = unknown.
ageAtOnset Int? @map("age_at_onset")
/// AES-256-GCM ciphertext of an optional free-text note. Bytes column.
notesEncrypted Bytes? @map("notes_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, deletedAt])
@@map("family_history_entries")
}
/// One cached forecast per user, regenerated debounced /
/// stale-while-revalidate (the InsightNarrative + v1.8.7 assessment
/// cache pattern), NOT per-read. Derived — not soft-deleted.
+17
View File
@@ -448,6 +448,23 @@ async function main() {
),
);
// ───── v1.25 structured health records (Bytes columns) ─────
// Allergy free-text reaction + note; family-history note. Always encrypted
// on write (no legacy plaintext column), so every non-null row rotates.
results.push(
await rotateBytesColumn("Allergy", "reactionEncrypted", prisma.allergy),
);
results.push(
await rotateBytesColumn("Allergy", "notesEncrypted", prisma.allergy),
);
results.push(
await rotateBytesColumn(
"FamilyHistoryEntry",
"notesEncrypted",
prisma.familyHistoryEntry,
),
);
console.log("\n=== Rotation summary ===");
let totalRotated = 0;
let totalErrors = 0;
+8
View File
@@ -156,6 +156,14 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "MedicationSideEffect", field: "notesEncrypted", kind: "bytes" },
{ model: "MedicationDoseChange", field: "noteEncrypted", kind: "bytes" },
{ model: "MedicationInventoryItem", field: "notesEncrypted", kind: "bytes" },
// ───── v1.25 structured health records (Bytes columns) ─────
// Allergy free-text reaction + note, and the family-history note. The
// structured enum/label columns stay queryable plaintext; only the
// sensitive free-text fields are encrypted.
{ model: "Allergy", field: "reactionEncrypted", kind: "bytes" },
{ model: "Allergy", field: "notesEncrypted", kind: "bytes" },
{ model: "FamilyHistoryEntry", field: "notesEncrypted", kind: "bytes" },
] as const;
/** Stable `Model.field` key for a registry entry. */