mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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 $$;
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user