diff --git a/prisma/migrations/0215_v125_allergies/migration.sql b/prisma/migrations/0215_v125_allergies/migration.sql new file mode 100644 index 000000000..25bd6dd11 --- /dev/null +++ b/prisma/migrations/0215_v125_allergies/migration.sql @@ -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 $$; diff --git a/prisma/migrations/0216_v125_family_history/migration.sql b/prisma/migrations/0216_v125_family_history/migration.sql new file mode 100644 index 000000000..274579f1d --- /dev/null +++ b/prisma/migrations/0216_v125_family_history/migration.sql @@ -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 $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index abc2d1249..50efd59c1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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. diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 3663f3f56..f75d11deb 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -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; diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 4b0c91191..cf972a5cd 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -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. */