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:
Marc-André Bombeck committed 2026-06-28 16:56:54 +02:00
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 $$;
+146
View File
@@ -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")
}
+12
View File
@@ -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;
+6
View File
@@ -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. */