diff --git a/prisma/migrations/0218_v125_inbound_documents/migration.sql b/prisma/migrations/0218_v125_inbound_documents/migration.sql new file mode 100644 index 000000000..f753aa643 --- /dev/null +++ b/prisma/migrations/0218_v125_inbound_documents/migration.sql @@ -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 $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e862c91f0..3f747dec2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index d8a52e239..2308f6484 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -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; diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 761b3f2e0..78ec9895a 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -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. */