mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(documents): add inbound clinical-document data model (migration 0218)
Store an uploaded doctor report / discharge letter and stage extracted structured facts for review. Two models: - InboundDocument: the uploaded document, raw bytes encrypted at rest via the shared AES-256-GCM Bytes codec (content_encrypted), with queryable metadata (kind, mime, size, status, report date). - ExtractedFact: the review-then-confirm staging row — fact type, status, per-field provenance, a confidence score, and a needs-review gate, plus the FHIR-staged payload (stated codes/status only) as JSONB. Wire content_encrypted into the encryption-key rotation registry and the rotation script so a key drop never strands a stored document.
This commit is contained in:
1 parent
2563690e4b
commit
5280c183bd
4 files changed
+294
No files matched your search
@@ -0,0 +1,130 @@
|
||||
-- v1.25 (W-DOCS-IN) — inbound clinical documents + extracted-fact staging.
|
||||
--
|
||||
-- A self-hoster uploads a doctor report / discharge letter they received.
|
||||
-- The raw document is stored encrypted at rest (BYTEA, the AES-256-GCM
|
||||
-- `*_encrypted` convention shared with the illness / mood / lab note
|
||||
-- columns). The dedicated OCR/vision provider transcribes STRUCTURED FACTS
|
||||
-- into a review-then-confirm staging area (`extracted_facts`). The app
|
||||
-- reproduces what the clinician wrote — it never interprets, never
|
||||
-- range-flags, never diagnoses, never links a condition to a medication. A
|
||||
-- fact reaches the structured stores (labs / conditions / medications) only
|
||||
-- on explicit user approval; low-confidence facts fail closed.
|
||||
--
|
||||
-- `mime_type`, `byte_size`, `provider_type`, `report_date` stay queryable
|
||||
-- plaintext metadata; `content_encrypted` holds the sensitive original. The
|
||||
-- per-fact `data_json` / `provenance_json` carry the FHIR-staged fields
|
||||
-- (stated codes only) and the source-span provenance.
|
||||
--
|
||||
-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` /
|
||||
-- `DO $$`) so reruns are safe on prod.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "extracted_facts";
|
||||
-- DROP TABLE IF EXISTS "inbound_documents";
|
||||
-- DROP TYPE IF EXISTS "extracted_fact_status";
|
||||
-- DROP TYPE IF EXISTS "extracted_fact_type";
|
||||
-- DROP TYPE IF EXISTS "inbound_document_status";
|
||||
-- DROP TYPE IF EXISTS "inbound_document_kind";
|
||||
-- A roll-back drops the inbound-document store wholesale — facts already
|
||||
-- approved into labs / conditions / medications are independent rows and are
|
||||
-- NOT affected (the staging link is one-way).
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "inbound_document_kind" AS ENUM ('DOCTOR_REPORT', 'DISCHARGE_LETTER', 'OTHER');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "inbound_document_status" AS ENUM ('EXTRACTING', 'EXTRACTED', 'FAILED', 'CONFIRMED', 'DISCARDED');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "extracted_fact_type" AS ENUM ('CONDITION', 'OBSERVATION', 'MEDICATION_STATEMENT');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "extracted_fact_status" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "inbound_documents" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"kind" "inbound_document_kind" NOT NULL DEFAULT 'OTHER',
|
||||
"filename" TEXT,
|
||||
"mime_type" TEXT NOT NULL,
|
||||
"byte_size" INTEGER NOT NULL,
|
||||
"content_encrypted" BYTEA NOT NULL,
|
||||
"status" "inbound_document_status" NOT NULL DEFAULT 'EXTRACTING',
|
||||
"provider_type" TEXT,
|
||||
"report_date" TIMESTAMP(3),
|
||||
"error_reason" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "inbound_documents_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "extracted_facts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"document_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"fact_type" "extracted_fact_type" NOT NULL,
|
||||
"status" "extracted_fact_status" NOT NULL DEFAULT 'PENDING',
|
||||
"confidence" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"needs_review" BOOLEAN NOT NULL DEFAULT true,
|
||||
"data_json" JSONB NOT NULL,
|
||||
"provenance_json" JSONB NOT NULL,
|
||||
"committed_record_id" TEXT,
|
||||
"committed_record_type" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "extracted_facts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "inbound_documents_user_id_created_at_idx" ON "inbound_documents"("user_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "inbound_documents_user_id_deleted_at_idx" ON "inbound_documents"("user_id", "deleted_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "extracted_facts_document_id_idx" ON "extracted_facts"("document_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "extracted_facts_user_id_idx" ON "extracted_facts"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "inbound_documents" ADD CONSTRAINT "inbound_documents_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "extracted_facts" ADD CONSTRAINT "extracted_facts_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "extracted_facts" ADD CONSTRAINT "extracted_facts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -727,6 +727,13 @@ model User {
|
||||
// sensitive free-text fields are encrypted at rest.
|
||||
allergies Allergy[]
|
||||
familyHistory FamilyHistoryEntry[]
|
||||
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
|
||||
// discharge letters) the user uploads. The raw document is encrypted at
|
||||
// rest; extracted facts land in a review-then-confirm staging area
|
||||
// (ExtractedFact) and only reach the structured stores on explicit
|
||||
// approval. Opt-in module, off by default.
|
||||
inboundDocuments InboundDocument[]
|
||||
extractedFacts ExtractedFact[]
|
||||
// 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.
|
||||
@@ -5372,3 +5379,142 @@ model CyclePrediction {
|
||||
@@unique([userId])
|
||||
@@map("cycle_predictions")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// v1.25 (W-DOCS-IN) — inbound clinical documents.
|
||||
//
|
||||
// A self-hoster uploads a doctor report / discharge letter they RECEIVED.
|
||||
// The raw document is stored encrypted at rest; the dedicated OCR/vision
|
||||
// provider (the v1.22 document-scan rails) transcribes STRUCTURED FACTS into
|
||||
// a review-then-confirm staging area. The app reproduces what the clinician
|
||||
// wrote — it never interprets, never range-flags, never diagnoses, never
|
||||
// links a condition to a medication. A fact only reaches the structured
|
||||
// stores (labs / conditions / medications) once the user explicitly approves
|
||||
// it; low-confidence facts fail closed (surfaced for manual review, never
|
||||
// auto-committed). Opt-in module, off by default.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// What kind of clinical document was uploaded (free choice by the user;
|
||||
/// drives no interpretation — it is a label only).
|
||||
enum InboundDocumentKind {
|
||||
DOCTOR_REPORT
|
||||
DISCHARGE_LETTER
|
||||
OTHER
|
||||
|
||||
@@map("inbound_document_kind")
|
||||
}
|
||||
|
||||
/// Lifecycle of an uploaded document. EXTRACTING while the provider runs,
|
||||
/// EXTRACTED once facts are staged for review, FAILED on a provider/parse
|
||||
/// miss, CONFIRMED once the user has acted on every fact, DISCARDED on a
|
||||
/// soft delete.
|
||||
enum InboundDocumentStatus {
|
||||
EXTRACTING
|
||||
EXTRACTED
|
||||
FAILED
|
||||
CONFIRMED
|
||||
DISCARDED
|
||||
|
||||
@@map("inbound_document_status")
|
||||
}
|
||||
|
||||
/// The FHIR resource an extracted fact maps to (STATED status only): a
|
||||
/// diagnosis → Condition, a measured value → Observation, a medication the
|
||||
/// patient is/was taking → MedicationStatement.
|
||||
enum ExtractedFactType {
|
||||
CONDITION
|
||||
OBSERVATION
|
||||
MEDICATION_STATEMENT
|
||||
|
||||
@@map("extracted_fact_type")
|
||||
}
|
||||
|
||||
/// Review state of a staged fact. PENDING until the user decides; APPROVED
|
||||
/// once committed to a structured store; REJECTED when the user discards it.
|
||||
enum ExtractedFactStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
|
||||
@@map("extracted_fact_status")
|
||||
}
|
||||
|
||||
model InboundDocument {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
kind InboundDocumentKind @default(OTHER)
|
||||
/// Original filename as uploaded (sanitised, optional).
|
||||
filename String?
|
||||
/// Magic-byte-sniffed MIME type the document was stored as.
|
||||
mimeType String @map("mime_type")
|
||||
/// Stored byte length of the original (pre-encryption) document.
|
||||
byteSize Int @map("byte_size")
|
||||
/// AES-256-GCM ciphertext of the raw document bytes (base64 of the binary
|
||||
/// → `encrypt()` string → UTF-8 BYTEA), the `*_encrypted` Bytes convention
|
||||
/// shared with the note columns. The original is sensitive and never
|
||||
/// stored in the clear or logged.
|
||||
contentEncrypted Bytes @map("content_encrypted")
|
||||
status InboundDocumentStatus @default(EXTRACTING)
|
||||
/// Provider type that ran the extraction (e.g. "anthropic", "openai",
|
||||
/// "local"). Diagnostic only.
|
||||
providerType String? @map("provider_type")
|
||||
/// The document's stated report/collection date (YYYY-MM-DD at UTC), as
|
||||
/// transcribed — never inferred.
|
||||
reportDate DateTime? @map("report_date")
|
||||
/// Failure reason when status = FAILED (a short code, never the document).
|
||||
errorReason String? @map("error_reason")
|
||||
|
||||
facts ExtractedFact[]
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = discarded.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([userId, deletedAt])
|
||||
@@map("inbound_documents")
|
||||
}
|
||||
|
||||
model ExtractedFact {
|
||||
id String @id @default(cuid())
|
||||
documentId String @map("document_id")
|
||||
userId String @map("user_id")
|
||||
factType ExtractedFactType @map("fact_type")
|
||||
status ExtractedFactStatus @default(PENDING)
|
||||
/// Overall extraction confidence 0..1 (the lowest per-field score). The
|
||||
/// review screen gates approval of a low-confidence fact behind a manual
|
||||
/// edit (fail-closed).
|
||||
confidence Float @default(0)
|
||||
/// True when the fact scored below the confidence floor and must be
|
||||
/// reviewed/edited before it can be approved. Cleared once the user edits
|
||||
/// the fact (the values then become user-asserted).
|
||||
needsReview Boolean @default(true) @map("needs_review")
|
||||
/// The FHIR-staged fields, STATED status only, shaped per `factType`
|
||||
/// (Condition / Observation / MedicationStatement). Codes (SNOMED / ICD-10
|
||||
/// / LOINC / RxNorm / ATC) are present only when the document writes them.
|
||||
dataJson Json @map("data_json")
|
||||
/// Per-field provenance: the verbatim source span the value came from, an
|
||||
/// optional page index, and per-field confidence. Makes "extract what's
|
||||
/// written" auditable against the original.
|
||||
provenanceJson Json @map("provenance_json")
|
||||
/// The id of the structured-store row created when the fact was approved
|
||||
/// (LabResult / IllnessEpisode / Medication), or NULL while PENDING /
|
||||
/// REJECTED.
|
||||
committedRecordId String? @map("committed_record_id")
|
||||
/// The structured store the approved fact landed in ("labResult" /
|
||||
/// "illnessEpisode" / "medication"), or NULL.
|
||||
committedRecordType String? @map("committed_record_type")
|
||||
|
||||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([documentId])
|
||||
@@index([userId])
|
||||
@@map("extracted_facts")
|
||||
}
|
||||
@@ -475,6 +475,18 @@ async function main() {
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.25 (W-DOCS-IN) inbound clinical document (Bytes column) ─────
|
||||
// The raw uploaded doctor report / discharge letter (base64-of-binary →
|
||||
// AES-256-GCM string → UTF-8 bytes). Always encrypted on write (NOT NULL),
|
||||
// so every row rotates.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"InboundDocument",
|
||||
"contentEncrypted",
|
||||
prisma.inboundDocument,
|
||||
),
|
||||
);
|
||||
|
||||
console.log("\n=== Rotation summary ===");
|
||||
let totalRotated = 0;
|
||||
let totalErrors = 0;
|
||||
|
||||
@@ -173,6 +173,12 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
{ model: "Allergy", field: "reactionEncrypted", kind: "bytes" },
|
||||
{ model: "Allergy", field: "notesEncrypted", kind: "bytes" },
|
||||
{ model: "FamilyHistoryEntry", field: "notesEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── v1.25 (W-DOCS-IN) inbound clinical document (Bytes column) ─────
|
||||
// The raw uploaded doctor report / discharge letter, base64-of-binary →
|
||||
// AES-256-GCM string → UTF-8 bytes. The most sensitive blob in the wave
|
||||
// (a full clinical document); never logged, never in wide-event meta.
|
||||
{ model: "InboundDocument", field: "contentEncrypted", kind: "bytes" },
|
||||
] as const;
|
||||
|
||||
/** Stable `Model.field` key for a registry entry. */
|
||||
|
||||
Reference in new issue
Block a user