diff --git a/prisma/migrations/0227_document_vault/migration.sql b/prisma/migrations/0227_document_vault/migration.sql new file mode 100644 index 000000000..97c48a5f5 --- /dev/null +++ b/prisma/migrations/0227_document_vault/migration.sql @@ -0,0 +1,78 @@ +-- Document vault: re-enable the parked inbound-documents module with the +-- hardened storage contract. +-- +-- 1. `inbound_document_kind` gains REFERRAL (Überweisung — clean LOINC +-- 57133-1 / ePA ANF mapping). +-- 2. `inbound_documents` gains `content_sha256` (lowercase hex of the +-- plaintext, for same-user duplicate detection; pre-existing rows stay +-- NULL and never dedupe) and `content_codec` ("base64v1" legacy string +-- codec | "binary2" binary codec; the default keeps existing rows +-- correct) plus a partial unique index enforcing at most one LIVE row +-- per (user_id, content_sha256). +-- 3. `document_condition_links` — m:n join between a document and an +-- illness/condition episode. Both sides cascade. +-- 4. `app_settings` gains the two admin-tunable limits (per-file cap, +-- default 25 MiB; per-user quota, default 1 GiB); `users` gains the +-- nullable per-user quota override. +-- +-- Additive and idempotent-safe on an existing database; applies clean on a +-- fresh database as part of the ordered migration chain. + +-- 1. Kind enum +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'REFERRAL'; + +-- 2. Document columns + live-dedupe partial unique index +ALTER TABLE "inbound_documents" + ADD COLUMN "content_sha256" VARCHAR(64), + ADD COLUMN "content_codec" TEXT NOT NULL DEFAULT 'base64v1'; + +-- Partial unique index (documented in schema.prisma — Prisma cannot express +-- a partial index, so it lives here as raw SQL). Tombstoned rows are +-- excluded: a soft-deleted document must not block re-uploading the same +-- file, and restore re-enters the constraint (a conflicting restore 409s). +CREATE UNIQUE INDEX "inbound_documents_user_sha_live" + ON "inbound_documents" ("user_id", "content_sha256") + WHERE "deleted_at" IS NULL AND "content_sha256" IS NOT NULL; + +-- 3. Document ⇄ condition links +CREATE TABLE "document_condition_links" ( + "id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "episode_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "document_condition_links_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "document_condition_links_document_id_episode_id_key" + ON "document_condition_links" ("document_id", "episode_id"); + +CREATE INDEX "document_condition_links_episode_id_idx" + ON "document_condition_links" ("episode_id"); + +CREATE INDEX "document_condition_links_user_id_idx" + ON "document_condition_links" ("user_id"); + +ALTER TABLE "document_condition_links" + ADD CONSTRAINT "document_condition_links_document_id_fkey" + FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "document_condition_links" + ADD CONSTRAINT "document_condition_links_episode_id_fkey" + FOREIGN KEY ("episode_id") REFERENCES "illness_episodes" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "document_condition_links" + ADD CONSTRAINT "document_condition_links_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- 4. Admin limits +ALTER TABLE "app_settings" + ADD COLUMN "document_max_file_bytes" INTEGER NOT NULL DEFAULT 26214400, + ADD COLUMN "document_quota_bytes" BIGINT NOT NULL DEFAULT 1073741824; + +ALTER TABLE "users" + ADD COLUMN "document_quota_bytes" BIGINT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a4f69bf9c..62555c6c5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -537,6 +537,12 @@ model User { // `GET /api/auth/me` as a `modules: { : boolean }` map. modulePreferencesJson Json? @map("module_preferences_json") + // Document vault — per-user storage-quota override in bytes. NULL = the + // instance default (`AppSettings.documentQuotaBytes`) applies. Admin-set + // only (the admin user PUT); resolved by + // `resolveDocumentLimits()` in `src/lib/documents/upload-policy.ts`. + documentQuotaBytes BigInt? @map("document_quota_bytes") + // v1.18.6 (CCH-03) — "Coach last seen" stamp powering the unread dot // on the Coach FAB. The proactive nudge now lands as an ASSISTANT // message in the conversation rail (CCH-02); the FAB shows a discreet @@ -777,6 +783,7 @@ model User { // approval. Opt-in module, off by default. inboundDocuments InboundDocument[] extractedFacts ExtractedFact[] + documentConditionLinks DocumentConditionLink[] // 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. @@ -3767,6 +3774,16 @@ model AppSettings { // never be disabled. Resolved in `src/lib/modules/gate.ts`. moduleAvailabilityJson Json? @map("module_availability_json") + // Document vault — the two admin-tunable limits (the only knobs beside the + // module key). `documentMaxFileBytes` is the per-file upload cap, default + // 25 MiB, clamped at runtime to the hard 100 MiB ceiling (single-shot GCM + + // bounded in-memory reads are load-bearing past that). `documentQuotaBytes` + // is the instance-wide per-user storage quota, default 1 GiB; a per-user + // override lives on `User.documentQuotaBytes`. Usage counts every + // non-purged row (tombstones still hold TOAST bytes). + documentMaxFileBytes Int @default(26214400) @map("document_max_file_bytes") + documentQuotaBytes BigInt @default(1073741824) @map("document_quota_bytes") + @@map("app_settings") } @@ -5400,11 +5417,12 @@ model IllnessEpisode { /// 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) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Parent condition for a flare/recurrence (self-relation). - parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) - flares IllnessEpisode[] @relation("IllnessFlares") - dayLogs IllnessDayLog[] + parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) + flares IllnessEpisode[] @relation("IllnessFlares") + dayLogs IllnessDayLog[] + documentLinks DocumentConditionLink[] @@index([userId, deletedAt]) @@index([userId, onsetAt]) @@ -5693,6 +5711,9 @@ enum InboundDocumentKind { LAB_RESULT IMAGING PRESCRIPTION + /// Überweisung / referral letter — a common German artefact with a clean + /// LOINC (57133-1) / ePA (ANF) mapping. Added with the document vault. + REFERRAL INSURANCE VACCINATION OTHER @@ -5760,6 +5781,21 @@ model InboundDocument { /// shared with the note columns. The original is sensitive and never /// stored in the clear or logged. contentEncrypted Bytes @map("content_encrypted") + /// Lowercase-hex SHA-256 of the PLAINTEXT document bytes, computed at + /// upload for same-user duplicate detection. Nullable: rows stored before + /// the vault release are not backfilled (only new uploads dedupe). A + /// partial unique index (raw SQL in migration 0227, not expressible in + /// Prisma) enforces at most one LIVE row per (user_id, content_sha256): + /// CREATE UNIQUE INDEX inbound_documents_user_sha_live + /// ON inbound_documents (user_id, content_sha256) + /// WHERE deleted_at IS NULL AND content_sha256 IS NOT NULL; + contentSha256 String? @map("content_sha256") @db.VarChar(64) + /// Storage codec of `contentEncrypted`. "base64v1" = legacy string path + /// (base64-of-binary → `encrypt()` string → UTF-8 bytes); "binary2" = the + /// binary AES-256-GCM layout written by `encryptBytes()` (no base64 + /// detour). Explicit column, never header-sniffed; the default keeps every + /// pre-existing row correct. + contentCodec String @default("base64v1") @map("content_codec") status InboundDocumentStatus @default(STORED) /// Provider type that ran the extraction (e.g. "anthropic", "openai", /// "local"). Diagnostic only. Null on a store-only document. @@ -5777,7 +5813,8 @@ model InboundDocument { /// Failure reason when status = FAILED (a short code, never the document). errorReason String? @map("error_reason") - facts ExtractedFact[] + facts ExtractedFact[] + conditionLinks DocumentConditionLink[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -5841,3 +5878,25 @@ model ExtractedFact { @@index([userId]) @@map("extracted_facts") } + +/// m:n join between a stored document and an illness/condition episode +/// (a discharge letter legitimately touches two conditions). Never +/// mandatory at upload; both sides cascade so a deleted document or +/// episode takes its links with it. `userId` is denormalised for the +/// owner-scoped queries the vault list runs. +model DocumentConditionLink { + id String @id @default(cuid()) + documentId String @map("document_id") + episodeId String @map("episode_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([documentId, episodeId]) + @@index([episodeId]) + @@index([userId]) + @@map("document_condition_links") +}