From 7f1cd575f4121340eabf13bc2809026327887d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:04:31 +0200 Subject: [PATCH 01/34] feat(documents): add the vault data model (migration 0227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REFERRAL joins the document-kind enum (Überweisung — LOINC 57133-1 / ePA ANF). inbound_documents gains content_sha256 (plaintext hash for same-user duplicate detection; pre-existing rows stay NULL) and content_codec (base64v1 legacy string codec | binary2 binary codec, explicit column — never header-sniffed), plus a partial unique index that allows at most one LIVE row per (user_id, content_sha256) so a tombstoned document never blocks a re-upload. document_condition_links is the m:n join between a document and an illness/condition episode (a discharge letter legitimately touches two conditions); both sides cascade. app_settings carries the two admin-tunable limits (per-file cap, default 25 MiB; per-user quota, default 1 GiB) and users the nullable per-user quota override. Task: W1-T1. --- .../0227_document_vault/migration.sql | 78 +++++++++++++++++++ prisma/schema.prisma | 69 ++++++++++++++-- 2 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/0227_document_vault/migration.sql 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") +} From bd64b78e5dc2b847d2178a62d04b4411f70bb736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:04:51 +0200 Subject: [PATCH 02/34] feat(documents): upload policy layer and binary content codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy module (src/lib/documents/upload-policy.ts) is the one source of truth for what the vault accepts and how it serves: Class A (PDF/JPEG/PNG/ WebP/GIF) renders inline; Class B (Office OOXML+CFB, text/CSV/Markdown/RTF, TIFF, HEIC/HEIF, XML/JSON) is stored verbatim and leaves the origin only as an opaque attachment download; executables, HTML/XHTML/SVG, and generic archives are denied. Detection is magic-bytes first — the wire Content-Type is never trusted; containers (ZIP → OOXML, CFB → legacy Office) and extension-only text formats gate on the filename plus a no-NUL heuristic. resolveDocumentLimits() reads the admin-tunable per-file cap (clamped to the hard 100 MiB ceiling — single-shot GCM and bounded in-memory reads are load-bearing past that) and the quota (user override ?? instance default). crypto.ts gains the binary2 codec: encryptBytes/decryptBytes do AES-256-GCM over raw bytes with a versioned key-id header, skipping the base64 detour (−33 % at rest, one fewer copy). Same fail-closed discipline as the string codec. The store dispatches per row on the explicit contentCodec column: new uploads write binary2, pre-existing base64v1 rows keep decoding byte-identically, and an unknown codec throws instead of guessing. Exhaustive unit coverage: every accept class from real magic-byte fixtures, every deny case (incl. the PDF-with-HTML-head, SVG-as-image, and ZIP-renamed-docx polyglots), header-matrix source of truth, limit clamping, codec round-trips both ways, crossed-codec and unknown-codec fail-closed. Tasks: W1-T3, W1-T4. --- src/lib/__tests__/crypto-bytes.test.ts | Bin 0 -> 4366 bytes src/lib/crypto.ts | 99 +++++ .../documents/__tests__/store-codec.test.ts | 84 ++++ .../documents/__tests__/upload-policy.test.ts | 326 +++++++++++++++ src/lib/documents/store.ts | 65 ++- src/lib/documents/upload-policy.ts | 370 ++++++++++++++++++ 6 files changed, 938 insertions(+), 6 deletions(-) create mode 100644 src/lib/__tests__/crypto-bytes.test.ts create mode 100644 src/lib/documents/__tests__/store-codec.test.ts create mode 100644 src/lib/documents/__tests__/upload-policy.test.ts create mode 100644 src/lib/documents/upload-policy.ts diff --git a/src/lib/__tests__/crypto-bytes.test.ts b/src/lib/__tests__/crypto-bytes.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..196b10e7127bc9d696e88289fcac6ffafcdd4874 GIT binary patch literal 4366 zcmb_gTW;G%6z#WHaX}yel}NHE#g8~}0o$=tH;EI}PJkeAjS)GtrlClcGn6g32KvI7dXJhiFLUF0Qr)#1-zF)*K&*vM`EaE#uvsT%)M#R%FpH0;M3EnK`N1p7rU)@-S2Wb_ z)0I#oV)T*EIwI0`Mw~@FZ&RAlkV(Gz7~!d`$h&mP#+-0kiY(<(2S+FpG8B_Uq@0)} z1s9~sMS7_XnY-!ppZ`$IMA8Y9O!A1bl#29Ynq8%(DcPnmOHiUQezvz`iKWJyA`P=N z7MDeiAN7qQl3iF|Ut7ZuQqhaU_ZRO1+C}2LUeF&r-q_sQe!_-f#N+#ZM>Z=)`_^d8 zqj1QcY;SFDJRbCe-hIF862%BZA!}4fev0%Y9N6$%bOW!aqFW%4Vz`$ihv`)Mt*3TX zuVKOW4o?n#dH?3@`1Q#JqCNH6FOHwxxf|QC$EML({UZLfXSelW z5XLJu@d0+=cn6#cJo*D~eec_Y`}y?sN!P+i#54bfrooQ$5B%-B&waXW-6sjw#Z@P4 zB3x?kNMzdA5EnfeaNrk06wAMLit{v6=~D~fhN*2)Y5sv zW=X~(sR=V=N{nk(2}ILGnD5%953r~IY(ML=B*{X**V4_UvaGwH|Dku?>$M55fqso+ z^UBW~H+v7>JUi+H-7VWSKK++|`d0_6$x1)a$=jDw!>v&9w%2H2FVESmYc9T_gr}Ek zv_nC!chT$h=oY!UK<76367+!j8$rvNIoF&ZLX#lhg_|&uQWnqSwt&;S>;~6S(KdF= z?rQq5KhY-*5cFm%68?O5m;YX{M7mwAuFCegA7h0tAE|cz#ramF0hJf!`CCMCyQuk9 zu4TxyP76`j zTqCOzO*^Tmg)+`M$#^sv(1T`|P2G0u*c=!Hv}w!b_#PEpUoUmU4_fpH`tXvc_M?u$ z@0-P`AD9n{UHZW$5JrQ(6F{wpI>k_xCOBx7ghe3LWyZj#yI97ZgZ^@=95z7X>DMIB7C40bijXBB6mH3FShjd27jWMj+eDV3 zQY8bLlI!QDfm_Py{01Z>gk7NC;G9yOTK<6(&K3)y>nCU$JOIC@J2EduMKedq`L9Sk2pV_iBNf`$(D4?02Tq*E%11&^)I3$DtIeP%tE!{EvFgenE} zLD6p*7HiLurV@M|0WyUNJk8H8|`7?#g1wwEp{;Hz4e~ zomQ$fm#43_(7Tt3rBP`4_ElJ+GUJ3toe|F!9`DF6aa$kp%7*J=UhcM49C%CI{T9%a zo>k?jL%})yYOoZ11D2K%)c7omo}r37m6R6H1U;2qGK{8|9Qq&?+Jhd 32) { + // loadKeys() already constrains ids to [A-Za-z0-9_-]{1,32}; belt-and-braces. + throw new Error(`Encryption key id '${id}' is not header-encodable`); + } + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + const header = Buffer.from([BYTES_CODEC_VERSION, keyId.byteLength]); + return Buffer.concat([header, keyId, iv, tag, ct]); +} + +/** Parse a binary2 header. Throws on any malformation (fail-closed). */ +function parseBytesHeader(payload: Buffer): { + keyId: string; + iv: Buffer; + tag: Buffer; + ct: Buffer; +} { + if (payload.byteLength < 2) { + throw new Error("binary2 payload is truncated (no header)"); + } + if (payload[0] !== BYTES_CODEC_VERSION) { + throw new Error( + `Unknown binary ciphertext version ${payload[0]} (expected ${BYTES_CODEC_VERSION})`, + ); + } + const keyIdLen = payload[1]; + if (keyIdLen < 1 || keyIdLen > 32) { + throw new Error("binary2 payload has an invalid key-id length"); + } + const fixed = 2 + keyIdLen + IV_LENGTH + AUTH_TAG_LENGTH; + if (payload.byteLength < fixed) { + throw new Error("binary2 payload is truncated"); + } + const keyId = payload.subarray(2, 2 + keyIdLen).toString("ascii"); + if (!/^[A-Za-z0-9_-]{1,32}$/.test(keyId)) { + throw new Error("binary2 payload carries a malformed key id"); + } + const iv = payload.subarray(2 + keyIdLen, 2 + keyIdLen + IV_LENGTH); + const tag = payload.subarray(2 + keyIdLen + IV_LENGTH, fixed); + const ct = payload.subarray(fixed); + return { keyId, iv, tag, ct }; +} + +/** + * Decrypt a binary2 payload back to its raw bytes. Fail-closed: an unknown + * version, a malformed header, or an unconfigured key id throws — the caller + * must treat a throw as "cannot serve", never fall back to the ciphertext. + */ +export function decryptBytes(payload: Buffer): Buffer { + const { keyId, iv, tag, ct } = parseBytesHeader(payload); + const key = getKeyById(keyId); + if (!key) { + throw new Error( + `Encryption key id '${keyId}' is not configured. Add it to ` + + `ENCRYPTION_KEYS before decrypting rows written under that key.`, + ); + } + const dec = createDecipheriv(ALGORITHM, key, iv); + dec.setAuthTag(tag); + return Buffer.concat([dec.update(ct), dec.final()]); +} + +/** The key id a binary2 payload was written under, or null when unparsable. */ +export function extractKeyIdFromBytes(payload: Buffer): string | null { + try { + return parseBytesHeader(payload).keyId; + } catch { + return null; + } +} + +/** Re-encrypt a binary2 payload with the active key. Used by rotation. */ +export function reencryptBytesToActive(payload: Buffer): Buffer { + return encryptBytes(decryptBytes(payload)); +} + /** Returns the key id portion of a versioned ciphertext, or null for legacy. */ export function extractKeyId(encoded: string): string | null { const dot = encoded.indexOf("."); diff --git a/src/lib/documents/__tests__/store-codec.test.ts b/src/lib/documents/__tests__/store-codec.test.ts new file mode 100644 index 000000000..8b4017ba7 --- /dev/null +++ b/src/lib/documents/__tests__/store-codec.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Buffer } from "node:buffer"; + +/** + * Document content codec dispatch: new uploads write "binary2", pre-vault + * rows keep decoding via the legacy "base64v1" string path, and an unknown + * codec value fails closed instead of guessing. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +import { _resetCryptoCacheForTests } from "@/lib/crypto"; +import { + ACTIVE_DOCUMENT_CODEC, + decryptDocumentContent, + decryptDocumentFromBytes, + encryptDocumentContent, + encryptDocumentToBytes, +} from "../store"; + +const KEY_V1 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const SAMPLE = Buffer.concat([ + Buffer.from("%PDF-1.7\n"), + Buffer.from([0x00, 0xff, 0x80, 0x7f]), + Buffer.alloc(2048, 0xab), +]); + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", KEY_V1); + _resetCryptoCacheForTests(); +}); + +describe("document content codecs", () => { + it("writes new uploads with the binary2 codec and round-trips them", () => { + const { content, codec } = encryptDocumentContent(SAMPLE); + expect(codec).toBe("binary2"); + expect(codec).toBe(ACTIVE_DOCUMENT_CODEC); + expect(Buffer.from(content).equals(SAMPLE)).toBe(false); + expect(decryptDocumentContent(content, codec).equals(SAMPLE)).toBe(true); + }); + + it("still decodes legacy base64v1 rows byte-identically", () => { + // A pre-vault row: base64-of-binary → encrypt() string → UTF-8 bytes. + const legacy = encryptDocumentToBytes(SAMPLE); + expect(decryptDocumentContent(legacy, "base64v1").equals(SAMPLE)).toBe( + true, + ); + // The legacy helper itself stays the exact inverse. + expect(decryptDocumentFromBytes(legacy).equals(SAMPLE)).toBe(true); + }); + + it("binary2 is ~25 % smaller at rest than the base64 detour", () => { + const legacy = encryptDocumentToBytes(SAMPLE); + const { content } = encryptDocumentContent(SAMPLE); + expect(content.byteLength).toBeLessThan(legacy.byteLength * 0.8); + }); + + it("fails closed on an unknown codec value", () => { + const { content } = encryptDocumentContent(SAMPLE); + expect(() => decryptDocumentContent(content, "codec9")).toThrow( + /unknown document content codec/i, + ); + expect(() => decryptDocumentContent(content, "")).toThrow(); + }); + + it("fails closed when codecs are crossed (no silent fallback)", () => { + const legacy = encryptDocumentToBytes(SAMPLE); + const { content: binary } = encryptDocumentContent(SAMPLE); + // Legacy bytes read as binary2 → header parse fails. + expect(() => decryptDocumentContent(legacy, "binary2")).toThrow(); + // Binary bytes read as base64v1 → string decrypt fails. + expect(() => decryptDocumentContent(binary, "base64v1")).toThrow(); + }); +}); diff --git a/src/lib/documents/__tests__/upload-policy.test.ts b/src/lib/documents/__tests__/upload-policy.test.ts new file mode 100644 index 000000000..e83bd36fc --- /dev/null +++ b/src/lib/documents/__tests__/upload-policy.test.ts @@ -0,0 +1,326 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Document-vault policy layer: exhaustive accept-matrix coverage. + * + * Every Class A and Class B format is detected from real magic-byte + * fixtures, every deny case is denied, the wire Content-Type never plays a + * role (the detector does not even receive it), container ambiguity (ZIP → + * OOXML only under an Office extension) is pinned, adversarial polyglots + * behave as designed, and the limits resolver clamps to the hard ceiling. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +import { prisma } from "@/lib/db"; +import { + DOCUMENT_DEFAULT_MAX_FILE_BYTES, + DOCUMENT_DEFAULT_QUOTA_BYTES, + DOCUMENT_HARD_MAX_FILE_BYTES, + detectDocumentType, + resolveDocumentLimits, + servingClassFor, +} from "../upload-policy"; + +// ─── Magic-byte fixtures ───────────────────────────────────────────────────── + +const PDF = Buffer.from("%PDF-1.7\n%âãÏÓ\n1 0 obj\n", "latin1"); +const JPEG = Buffer.concat([ + Buffer.from([0xff, 0xd8, 0xff, 0xe0]), + Buffer.alloc(16, 0x11), +]); +const PNG = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(16, 0x22), +]); +const WEBP = Buffer.concat([ + Buffer.from("RIFF"), + Buffer.from([0x24, 0x00, 0x00, 0x00]), + Buffer.from("WEBPVP8 "), + Buffer.alloc(8, 0x33), +]); +const GIF87 = Buffer.concat([Buffer.from("GIF87a"), Buffer.alloc(16, 0x44)]); +const GIF89 = Buffer.concat([Buffer.from("GIF89a"), Buffer.alloc(16, 0x44)]); +const ZIP = Buffer.concat([ + Buffer.from([0x50, 0x4b, 0x03, 0x04]), + Buffer.alloc(32, 0x55), +]); +const CFB = Buffer.concat([ + Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), + Buffer.alloc(32, 0x66), +]); +const RTF = Buffer.from("{\\rtf1\\ansi Hello}", "latin1"); +const TIFF_LE = Buffer.concat([ + Buffer.from([0x49, 0x49, 0x2a, 0x00]), + Buffer.alloc(16, 0x77), +]); +const TIFF_BE = Buffer.concat([ + Buffer.from([0x4d, 0x4d, 0x00, 0x2a]), + Buffer.alloc(16, 0x77), +]); + +/** ISO-BMFF ftyp box with the given major + compatible brands. */ +function ftyp(major: string, compat: string[] = []): Buffer { + const brands = Buffer.from([major, "0000", ...compat].join(""), "ascii"); + const len = Buffer.alloc(4); + len.writeUInt32BE(8 + brands.byteLength); + return Buffer.concat([len, Buffer.from("ftyp"), brands, Buffer.alloc(8)]); +} + +const TEXT = Buffer.from("Befund: alles unauffällig.\nZeile 2\n", "utf8"); +const MZ = Buffer.concat([Buffer.from("MZ"), Buffer.alloc(64, 0x90)]); +const ELF = Buffer.concat([ + Buffer.from([0x7f, 0x45, 0x4c, 0x46]), + Buffer.alloc(32, 0), +]); +const MACHO = Buffer.concat([ + Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), + Buffer.alloc(32, 0), +]); + +describe("detectDocumentType — Class A (inline; magic bytes only)", () => { + it.each([ + ["report.pdf", PDF, "application/pdf"], + ["photo.jpg", JPEG, "image/jpeg"], + ["scan.png", PNG, "image/png"], + ["scan.webp", WEBP, "image/webp"], + ["anim87.gif", GIF87, "image/gif"], + ["anim89.gif", GIF89, "image/gif"], + ])("%s → %s inline", (name, bytes, mime) => { + expect(detectDocumentType(bytes, name)).toEqual({ + mimeType: mime, + servingClass: "inline", + }); + }); + + it("classifies by bytes, not extension (JPEG named .dat is still a JPEG)", () => { + expect(detectDocumentType(JPEG, "export.dat")).toEqual({ + mimeType: "image/jpeg", + servingClass: "inline", + }); + }); +}); + +describe("detectDocumentType — Class B (attachment-only)", () => { + it.each([ + [ + "letter.docx", + ZIP, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ], + [ + "values.xlsx", + ZIP, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ], + [ + "slides.pptx", + ZIP, + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ], + ["letter.doc", CFB, "application/msword"], + ["values.xls", CFB, "application/vnd.ms-excel"], + ["slides.ppt", CFB, "application/vnd.ms-powerpoint"], + ["note.rtf", RTF, "application/rtf"], + ["scan.tif", TIFF_LE, "image/tiff"], + ["scan.tiff", TIFF_BE, "image/tiff"], + ["befund.txt", TEXT, "text/plain"], + ["befund.md", TEXT, "text/plain"], + ["werte.csv", TEXT, "text/csv"], + ["export.xml", TEXT, "application/xml"], + ["export.json", TEXT, "application/json"], + ])("%s → %s attachment", (name, bytes, mime) => { + expect(detectDocumentType(bytes, name)).toEqual({ + mimeType: mime, + servingClass: "attachment", + }); + }); + + it.each([ + ["heic (major brand)", ftyp("heic")], + ["heix (major brand)", ftyp("heix")], + ["hevc (major brand)", ftyp("hevc")], + ["mif1 (major brand)", ftyp("mif1")], + ["msf1 (major brand)", ftyp("msf1")], + ["compatible-brand heic", ftyp("isom", ["heic"])], + ])("HEIC/HEIF %s → image/heic attachment", (_label, bytes) => { + expect(detectDocumentType(bytes, "photo.heic")).toEqual({ + mimeType: "image/heic", + servingClass: "attachment", + }); + }); +}); + +describe("detectDocumentType — deny", () => { + it.each([ + ["MZ executable", MZ, "payload.pdf"], + ["ELF executable", ELF, "scan.png"], + ["Mach-O executable", MACHO, "report.txt"], + ])("%s is denied regardless of a friendly extension", (_l, bytes, name) => { + expect(detectDocumentType(bytes, name)).toBeNull(); + }); + + it.each([ + ".exe", + ".dll", + ".bat", + ".cmd", + ".sh", + ".ps1", + ".js", + ".mjs", + ".jar", + ".apk", + ".html", + ".htm", + ".xhtml", + ".svg", + ".zip", + ".7z", + ".rar", + ".tar", + ".gz", + ".iso", + ".img", + ".dmg", + ])("extension %s is denied even with harmless text bytes", (ext) => { + expect(detectDocumentType(TEXT, `file${ext}`)).toBeNull(); + }); + + it("denies a ZIP container without an Office extension (incl. no name)", () => { + expect(detectDocumentType(ZIP, "archive.zip")).toBeNull(); + expect(detectDocumentType(ZIP, "archive")).toBeNull(); + expect(detectDocumentType(ZIP, null)).toBeNull(); + // CFB without a legacy-Office extension is equally refused. + expect(detectDocumentType(CFB, "blob.bin")).toBeNull(); + }); + + it("denies text-extension files smuggling NUL bytes", () => { + const binaryish = Buffer.concat([TEXT, Buffer.from([0x00]), TEXT]); + expect(detectDocumentType(binaryish, "befund.txt")).toBeNull(); + }); + + it("denies unidentifiable bytes and empty files", () => { + expect(detectDocumentType(Buffer.from([1, 2, 3, 4, 5, 6]), "x.bin")).toBe( + null, + ); + expect(detectDocumentType(Buffer.alloc(0), "empty.txt")).toBeNull(); + // Text-like bytes under a non-text extension stay unidentifiable. + expect(detectDocumentType(TEXT, "notes.abc")).toBeNull(); + }); + + it("adversarial: SVG content cannot sneak in under an image extension", () => { + const svg = Buffer.from(''); + expect(detectDocumentType(svg, "image.svg")).toBeNull(); // ext deny + expect(detectDocumentType(svg, "image.png")).toBeNull(); // no PNG magic, no text ext + }); + + it("adversarial: HTML under a text extension stays attachment-only", () => { + // Accepted as text/plain — but Class B never renders inline, so the + // markup can never execute in-origin. + const html = Buffer.from(""); + expect(detectDocumentType(html, "page.txt")).toEqual({ + mimeType: "text/plain", + servingClass: "attachment", + }); + expect(detectDocumentType(html, "page.html")).toBeNull(); + }); + + it("adversarial: a PDF with an HTML head is still a PDF (sandboxed inline)", () => { + const polyglot = Buffer.concat([ + Buffer.from("%PDF-1.4\n"), + Buffer.from(""), + ]); + expect(detectDocumentType(polyglot, "poly.pdf")).toEqual({ + mimeType: "application/pdf", + servingClass: "inline", + }); + }); +}); + +describe("servingClassFor", () => { + it("marks exactly the browser-native passive set inline", () => { + for (const mime of [ + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + ]) { + expect(servingClassFor(mime)).toBe("inline"); + } + }); + + it("marks every other stored type attachment (incl. unknowns)", () => { + for (const mime of [ + "image/heic", + "image/tiff", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/plain", + "text/csv", + "application/rtf", + "application/xml", + "application/json", + "text/html", // must never happen at rest, but even then: attachment + "image/svg+xml", + "application/octet-stream", + ]) { + expect(servingClassFor(mime)).toBe("attachment"); + } + }); +}); + +describe("resolveDocumentLimits", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("falls back to the defaults with no settings row and no override", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + await expect(resolveDocumentLimits("u1")).resolves.toEqual({ + maxFileBytes: DOCUMENT_DEFAULT_MAX_FILE_BYTES, + quotaBytes: DOCUMENT_DEFAULT_QUOTA_BYTES, + }); + }); + + it("clamps the per-file cap to the hard 100 MiB ceiling", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + documentMaxFileBytes: 999_999_999_999, + documentQuotaBytes: BigInt(DOCUMENT_DEFAULT_QUOTA_BYTES), + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + const limits = await resolveDocumentLimits("u1"); + expect(limits.maxFileBytes).toBe(DOCUMENT_HARD_MAX_FILE_BYTES); + }); + + it("prefers the per-user quota override over the instance default", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + documentMaxFileBytes: 26_214_400, + documentQuotaBytes: BigInt(2_000_000_000), + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentQuotaBytes: BigInt(50_000_000), + } as never); + const limits = await resolveDocumentLimits("u1"); + expect(limits.quotaBytes).toBe(50_000_000); + }); + + it("uses the instance quota when the user carries no override", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + documentMaxFileBytes: 26_214_400, + documentQuotaBytes: BigInt(2_000_000_000), + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentQuotaBytes: null, + } as never); + const limits = await resolveDocumentLimits("u1"); + expect(limits.quotaBytes).toBe(2_000_000_000); + }); +}); diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 8b9f90254..d10a12925 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -9,14 +9,16 @@ import { Buffer } from "node:buffer"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; -import { decrypt, encrypt } from "@/lib/crypto"; +import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto"; import type { ExtractedFact, ExtractedFactStatus, InboundDocument, InboundDocumentStatus, } from "@/generated/prisma/client"; +import { servingClassFor } from "@/lib/documents/upload-policy"; import type { + DocumentConditionLinkDto, ExtractedFactDto, FactData, FactProvenance, @@ -26,6 +28,20 @@ import type { InboundFactType, } from "@/lib/validations/inbound-documents"; +/** + * The two storage codecs `InboundDocument.contentEncrypted` may carry, + * recorded per row in the explicit `contentCodec` column (never sniffed): + * - "base64v1" — the legacy string path (base64-of-binary → `encrypt()` + * string → UTF-8 bytes). Pre-vault rows only; kept read-compatible. + * - "binary2" — the binary AES-256-GCM layout (`encryptBytes()`), no + * base64 detour. Every new upload writes this. + */ +export const DOCUMENT_CONTENT_CODECS = ["base64v1", "binary2"] as const; +export type DocumentContentCodec = (typeof DOCUMENT_CONTENT_CODECS)[number]; + +/** The codec every NEW upload is written with. */ +export const ACTIVE_DOCUMENT_CODEC: DocumentContentCodec = "binary2"; + /** Encrypt the raw document bytes into the `Bytes` payload the schema stores. */ export function encryptDocumentToBytes(bytes: Buffer): Uint8Array { const ciphertext = encrypt(bytes.toString("base64")); @@ -49,6 +65,31 @@ export function decryptDocumentFromBytes(buf: Uint8Array): Buffer { return Buffer.from(base64, "base64"); } +/** + * Encrypt raw document bytes with the ACTIVE codec (binary2). Returns the + * `Bytes` payload plus the codec label the row must persist alongside it. + */ +export function encryptDocumentContent(bytes: Buffer): { + content: Uint8Array; + codec: DocumentContentCodec; +} { + const encrypted = encryptBytes(bytes); + const out = new Uint8Array(new ArrayBuffer(encrypted.byteLength)); + out.set(encrypted); + return { content: out, codec: ACTIVE_DOCUMENT_CODEC }; +} + +/** + * Decrypt a stored document's payload, dispatching on the row's persisted + * codec. Fail-closed: an unknown codec value throws — a silent fallback to + * either codec would either return garbage or mask data corruption. + */ +export function decryptDocumentContent(buf: Uint8Array, codec: string): Buffer { + if (codec === "base64v1") return decryptDocumentFromBytes(buf); + if (codec === "binary2") return decryptBytes(Buffer.from(buf)); + throw new Error(`Unknown document content codec '${codec}'`); +} + /** * Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the * schema stores. The structured clinical values (diagnosis text, lab values, @@ -80,10 +121,19 @@ export function decryptFactProvenance(buf: Uint8Array): FactProvenance { return JSON.parse(decryptFromBytes(buf)) as FactProvenance; } -/** Map a persisted document row (+ counts) to the list/detail DTO. */ +/** + * The row fields the serialisers read. Deliberately WITHOUT + * `contentEncrypted`: the list path must never select the blob column + * (hardening — see the list route's `omit`), so the DTO mappers cannot + * require it either. + */ +export type SerialisableDocument = Omit; + +/** Map a persisted document row (+ counts + links) to the list/detail DTO. */ export function serialiseDocument( - doc: InboundDocument, + doc: SerialisableDocument, counts: { factCount: number; pendingCount: number }, + conditionLinks: DocumentConditionLinkDto[] = [], ): InboundDocumentDto { return { id: doc.id, @@ -103,6 +153,8 @@ export function serialiseDocument( errorReason: doc.errorReason, factCount: counts.factCount, pendingCount: counts.pendingCount, + conditionLinks, + servingClass: servingClassFor(doc.mimeType), createdAt: doc.createdAt.toISOString(), updatedAt: doc.updatedAt.toISOString(), }; @@ -123,17 +175,18 @@ export function serialiseFact(fact: ExtractedFact): ExtractedFactDto { }; } -/** Assemble the detail DTO (document + its staged facts). */ +/** Assemble the detail DTO (document + its staged facts + links). */ export function serialiseDocumentDetail( - doc: InboundDocument, + doc: SerialisableDocument, facts: ExtractedFact[], + conditionLinks: DocumentConditionLinkDto[] = [], ): InboundDocumentDetailDto { const pendingCount = facts.filter((f) => f.status === "PENDING").length; // `factCount` excludes REJECTED facts (a rejected fact is discarded, not part // of the document's tally) so the badge matches the list query. const factCount = facts.filter((f) => f.status !== "REJECTED").length; return { - ...serialiseDocument(doc, { factCount, pendingCount }), + ...serialiseDocument(doc, { factCount, pendingCount }, conditionLinks), facts: facts.map(serialiseFact), }; } diff --git a/src/lib/documents/upload-policy.ts b/src/lib/documents/upload-policy.ts new file mode 100644 index 000000000..c1aa63555 --- /dev/null +++ b/src/lib/documents/upload-policy.ts @@ -0,0 +1,370 @@ +/** + * Document-vault upload policy: the accept matrix, magic-byte sniffers, the + * serving-class function, and the admin-tunable limits resolver. + * + * Three classes, closed deny-list, serving posture as the security boundary: + * + * - Class A (inline): PDF, JPEG, PNG, WebP, GIF — browser-native passive + * formats the serve route renders `Content-Disposition: inline` (PDF + * additionally under a `Content-Security-Policy: sandbox` response + * header). + * - Class B (attachment-only): Office (OOXML + legacy CFB), text/CSV/RTF/ + * Markdown, TIFF, HEIC/HEIF, XML/JSON — stored verbatim, served ONLY as + * `application/octet-stream` + `Content-Disposition: attachment` + + * `nosniff`. A misclassified Class B file therefore cannot execute in + * the app origin. + * - Deny: executables, scripts, HTML/XHTML/SVG (the only classes whose + * inline render executes script — zero medical-artefact value), generic + * archives / disk images, anything unidentifiable. + * + * The wire Content-Type is NEVER trusted anywhere — detection is magic-bytes + * first, extension only where a container format is ambiguous (ZIP → OOXML, + * CFB → legacy Office) or where no magic exists (plain-text formats). We + * never parse, rewrite, or "sanitise" stored documents: the original is the + * artefact; safety comes from what the serve route will and won't serve + * inline. + * + * The labs OCR path (`src/lib/labs/ocr-upload.ts`) keeps its own 12 MiB cap + * and narrow allowlist unchanged; this module only reuses its bounded-read + * primitive at the route layer. + */ +import { detectAvatarMimeType } from "@/lib/avatar"; +import { prisma } from "@/lib/db"; + +// ─── Limits (§3.3) ────────────────────────────────────────────────────────── + +/** Default per-file cap: 25 MiB (a ~50-page 300-dpi scan PDF, any phone photo). */ +export const DOCUMENT_DEFAULT_MAX_FILE_BYTES = 26_214_400; + +/** + * Hard server ceiling for the admin-tunable per-file cap: 100 MiB. Single-shot + * AES-256-GCM + bounded in-memory reads are load-bearing; past this the + * architecture (chunking, streaming crypto) would have to change, so the + * setting cannot be pushed there. + */ +export const DOCUMENT_HARD_MAX_FILE_BYTES = 104_857_600; + +/** Default per-user quota: 1 GiB. */ +export const DOCUMENT_DEFAULT_QUOTA_BYTES = 1_073_741_824; + +export interface DocumentLimits { + /** Per-file upload cap in bytes (admin-tunable, clamped to the ceiling). */ + maxFileBytes: number; + /** Per-user storage quota in bytes (user override ?? instance default). */ + quotaBytes: number; +} + +/** + * Resolve the effective limits for a user: `AppSettings.documentMaxFileBytes` + * clamped to the hard ceiling, and quota = `User.documentQuotaBytes` ?? + * `AppSettings.documentQuotaBytes`. Missing rows fall back to the defaults. + */ +export async function resolveDocumentLimits( + userId: string, +): Promise { + const [settings, user] = await Promise.all([ + prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { documentMaxFileBytes: true, documentQuotaBytes: true }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { documentQuotaBytes: true }, + }), + ]); + const rawMax = + settings?.documentMaxFileBytes ?? DOCUMENT_DEFAULT_MAX_FILE_BYTES; + const maxFileBytes = Math.min( + Math.max(rawMax, 1), + DOCUMENT_HARD_MAX_FILE_BYTES, + ); + const quotaBytes = Number( + user?.documentQuotaBytes ?? + settings?.documentQuotaBytes ?? + DOCUMENT_DEFAULT_QUOTA_BYTES, + ); + return { maxFileBytes, quotaBytes }; +} + +// ─── Accept matrix (§3.1) ─────────────────────────────────────────────────── + +export type DocumentServingClass = "inline" | "attachment"; + +/** Class A — browser-native passive formats served inline. */ +const CLASS_A_MIMES: ReadonlySet = new Set([ + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +]); + +/** + * The one source of truth the serve route uses: Class A renders inline with + * its true Content-Type; EVERYTHING else stored leaves the origin only as an + * opaque attachment download. + */ +export function servingClassFor(mimeType: string): DocumentServingClass { + return CLASS_A_MIMES.has(mimeType) ? "inline" : "attachment"; +} + +/** + * Extensions the upload UI's `accept` attribute mirrors (Class A + B minus + * HEIC — its exclusion makes the iOS picker auto-transcode camera photos to + * JPEG so the common path stays inline-previewable). Server-side policy + * remains the enforcement; this list is advisory for pickers. + */ +export const DOCUMENT_ACCEPTED_EXTENSIONS: readonly string[] = [ + ".pdf", + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".docx", + ".xlsx", + ".pptx", + ".doc", + ".xls", + ".ppt", + ".txt", + ".md", + ".csv", + ".rtf", + ".tif", + ".tiff", + ".xml", + ".json", +]; + +/** + * Extension deny-list: executables and scripts (regardless of content), + * HTML/XHTML/SVG (inline render executes script), generic archives and disk + * images. A ZIP container is allowed ONLY under an Office extension. + */ +const DENY_EXTENSIONS: ReadonlySet = new Set([ + ".exe", + ".dll", + ".bat", + ".cmd", + ".sh", + ".ps1", + ".js", + ".mjs", + ".jar", + ".apk", + ".html", + ".htm", + ".xhtml", + ".svg", + ".zip", + ".7z", + ".rar", + ".tar", + ".gz", + ".iso", + ".img", + ".dmg", +]); + +/** OOXML extensions → stored MIME (ZIP magic + Office extension required). */ +const OOXML_BY_EXTENSION: ReadonlyMap = new Map([ + [ + ".docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ], + [ + ".xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ], + [ + ".pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ], +]); + +/** Legacy Office extensions → stored MIME (CFB magic + extension required). */ +const CFB_BY_EXTENSION: ReadonlyMap = new Map([ + [".doc", "application/msword"], + [".xls", "application/vnd.ms-excel"], + [".ppt", "application/vnd.ms-powerpoint"], +]); + +/** Text-format extensions → stored MIME (heuristic-gated, no magic bytes). */ +const TEXT_BY_EXTENSION: ReadonlyMap = new Map([ + [".txt", "text/plain"], + [".md", "text/plain"], + [".csv", "text/csv"], + [".xml", "application/xml"], + [".json", "application/json"], +]); + +/** Lower-cased extension (with dot) of a filename, or "" when absent. */ +function extensionOf(filename: string | null | undefined): string { + if (!filename) return ""; + const idx = filename.lastIndexOf("."); + if (idx <= 0 || idx === filename.length - 1) return ""; + return filename.slice(idx).toLowerCase(); +} + +function startsWith(buffer: Buffer, bytes: readonly number[]): boolean { + if (buffer.length < bytes.length) return false; + for (let i = 0; i < bytes.length; i++) { + if (buffer[i] !== bytes[i]) return false; + } + return true; +} + +/** `%PDF-` */ +function isPdf(buffer: Buffer): boolean { + return startsWith(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]); +} + +/** `GIF87a` / `GIF89a` */ +function isGif(buffer: Buffer): boolean { + return ( + startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || + startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) + ); +} + +/** ZIP local-file header `PK\x03\x04` (OOXML containers; also generic ZIPs). */ +function isZip(buffer: Buffer): boolean { + return startsWith(buffer, [0x50, 0x4b, 0x03, 0x04]); +} + +/** Compound File Binary (legacy Office) `D0 CF 11 E0 A1 B1 1A E1`. */ +function isCfb(buffer: Buffer): boolean { + return startsWith(buffer, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]); +} + +/** RTF `{\rtf` */ +function isRtf(buffer: Buffer): boolean { + return startsWith(buffer, [0x7b, 0x5c, 0x72, 0x74, 0x66]); +} + +/** TIFF `II*\0` (little-endian) or `MM\0*` (big-endian). */ +function isTiff(buffer: Buffer): boolean { + return ( + startsWith(buffer, [0x49, 0x49, 0x2a, 0x00]) || + startsWith(buffer, [0x4d, 0x4d, 0x00, 0x2a]) + ); +} + +/** HEIC/HEIF — ISO-BMFF `ftyp` box with a HEIF-family brand. */ +const HEIF_BRANDS: ReadonlySet = new Set([ + "heic", + "heix", + "hevc", + "mif1", + "msf1", +]); + +function isHeif(buffer: Buffer): boolean { + if (buffer.length < 12) return false; + if (buffer.toString("ascii", 4, 8) !== "ftyp") return false; + const major = buffer.toString("ascii", 8, 12).toLowerCase(); + if (HEIF_BRANDS.has(major)) return true; + // Compatible-brand scan inside the ftyp box (bounded by the box length). + const boxLen = Math.min(buffer.readUInt32BE(0), buffer.length); + for (let off = 16; off + 4 <= boxLen; off += 4) { + const brand = buffer.toString("ascii", off, off + 4).toLowerCase(); + if (HEIF_BRANDS.has(brand)) return true; + } + return false; +} + +/** Executable magic: MZ (PE), ELF, Mach-O (32/64, both endians, fat). */ +function isExecutable(buffer: Buffer): boolean { + if (startsWith(buffer, [0x4d, 0x5a])) return true; // MZ + if (startsWith(buffer, [0x7f, 0x45, 0x4c, 0x46])) return true; // ELF + const machO = [ + [0xfe, 0xed, 0xfa, 0xce], + [0xfe, 0xed, 0xfa, 0xcf], + [0xce, 0xfa, 0xed, 0xfe], + [0xcf, 0xfa, 0xed, 0xfe], + [0xca, 0xfe, 0xba, 0xbe], + ] as const; + return machO.some((m) => startsWith(buffer, m)); +} + +/** + * Plain-text heuristic for the extension-gated text formats: no NUL byte in + * the first 8 KiB and the sample decodes as UTF-8 or Latin-1 (any byte + * sequence is valid Latin-1, so the effective gate is "no NUL" — a binary + * payload smuggled under `.txt` almost always carries NULs; if one does not, + * it is still stored Class B and can only ever leave as an opaque download). + */ +function looksLikeText(buffer: Buffer): boolean { + if (buffer.length === 0) return false; + const sample = buffer.subarray(0, 8192); + return !sample.includes(0x00); +} + +export interface DetectedDocumentType { + /** The MIME type the row stores (sniffed, never the wire Content-Type). */ + mimeType: string; + /** Derived serving class (same as `servingClassFor(mimeType)`). */ + servingClass: DocumentServingClass; +} + +/** + * Classify an upload from its bytes (+ the claimed filename, used only where + * a container/text format needs an extension gate). Returns null for every + * deny case: executables, script/markup extensions, generic archives, ZIPs + * without an Office extension, and anything unidentifiable. + */ +export function detectDocumentType( + buffer: Buffer, + filename: string | null | undefined, +): DetectedDocumentType | null { + const ext = extensionOf(filename); + + // Hard denies first: content-level executables, then the extension + // deny-list (an .exe named payload is refused even if its bytes look like + // text; HTML/SVG are refused regardless of content). + if (isExecutable(buffer)) return null; + if (DENY_EXTENSIONS.has(ext)) return null; + + // ── Class A (magic bytes only — extension is irrelevant) ── + if (isPdf(buffer)) { + return { mimeType: "application/pdf", servingClass: "inline" }; + } + if (isGif(buffer)) { + return { mimeType: "image/gif", servingClass: "inline" }; + } + const image = detectAvatarMimeType(buffer); + if (image) { + return { mimeType: image, servingClass: "inline" }; + } + + // ── Class B (magic where it exists; containers need the extension) ── + if (isZip(buffer)) { + // A ZIP container is acceptable ONLY as an OOXML Office file; a generic + // archive (any other extension, incl. none) is denied. + const ooxml = OOXML_BY_EXTENSION.get(ext); + return ooxml ? { mimeType: ooxml, servingClass: "attachment" } : null; + } + if (isCfb(buffer)) { + const legacy = CFB_BY_EXTENSION.get(ext); + return legacy ? { mimeType: legacy, servingClass: "attachment" } : null; + } + if (isRtf(buffer)) { + return { mimeType: "application/rtf", servingClass: "attachment" }; + } + if (isTiff(buffer)) { + return { mimeType: "image/tiff", servingClass: "attachment" }; + } + if (isHeif(buffer)) { + return { mimeType: "image/heic", servingClass: "attachment" }; + } + + // ── Text formats (extension + heuristic; no magic bytes exist) ── + const text = TEXT_BY_EXTENSION.get(ext); + if (text && looksLikeText(buffer)) { + return { mimeType: text, servingClass: "attachment" }; + } + + // Unidentifiable → deny. + return null; +} From b76873ee1f5c5cea1a7e77a074659a3d0cc57a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:05:12 +0200 Subject: [PATCH 03/34] feat(documents): rebuild upload and list on the vault contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/documents/inbound now runs the policy layer end to end: bounded read aborts at the admin-set cap (413 + meta.reason=fileTooLarge with the limit), magic-byte classification (415 + unsupportedType), per-user quota checked inside the insert transaction over every non-purged row — tombstones keep counting, so deleted bytes are never invisible weight (413 + quotaExceeded with the figures), sha256 dedupe (a same-bytes re-upload answers 200 + meta.duplicate with the existing live row; the partial unique index closes the race), an honoured Idempotency-Key, the binary2 codec on write, and optional episodeIds[] pre-linking narrowed to the caller's live episodes. GET stops fetching the encrypted blob per row (omit: contentEncrypted — a 50-row page dragged up to 50 × cap ciphertext before), learns kind[] / episodeId / year facets on top of q/from/to, and returns conditionLinks + servingClass on every DTO via one grouped link query (no N+1). PATCH gains replace-set episodeIds with the same 404-shaped refusal for a foreign episode. The extract path decrypts through the codec dispatcher. The document-kind labels gain REFERRAL in all six locales. Tasks: W1-T5, W1-T6, W1-T7. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../inbound/[id]/__tests__/route.test.ts | 78 ++++ .../documents/inbound/[id]/extract/route.ts | 9 +- src/app/api/documents/inbound/[id]/route.ts | 64 ++- .../documents/inbound/__tests__/route.test.ts | 281 +++++++++++--- src/app/api/documents/inbound/route.ts | 366 ++++++++++++++---- .../__tests__/inbound-documents-view.test.tsx | 2 + .../documents/__tests__/library-utils.test.ts | 2 + src/lib/documents/links.ts | 83 ++++ src/lib/validations/inbound-documents.ts | 108 +++++- 15 files changed, 846 insertions(+), 153 deletions(-) create mode 100644 src/lib/documents/links.ts diff --git a/messages/de.json b/messages/de.json index 0afda8c76..91606adaf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8035,6 +8035,7 @@ "LAB_RESULT": "Laborbefund", "IMAGING": "Bildgebung", "PRESCRIPTION": "Rezept", + "REFERRAL": "Überweisung", "INSURANCE": "Versicherung", "VACCINATION": "Impfung" }, diff --git a/messages/en.json b/messages/en.json index 946fff862..d0068d128 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8035,6 +8035,7 @@ "LAB_RESULT": "Lab result", "IMAGING": "Imaging", "PRESCRIPTION": "Prescription", + "REFERRAL": "Referral", "INSURANCE": "Insurance", "VACCINATION": "Vaccination" }, diff --git a/messages/es.json b/messages/es.json index 54bc72f7b..0144945d2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8035,6 +8035,7 @@ "LAB_RESULT": "Resultado de laboratorio", "IMAGING": "Imagen", "PRESCRIPTION": "Receta", + "REFERRAL": "Derivación", "INSURANCE": "Seguro", "VACCINATION": "Vacunación" }, diff --git a/messages/fr.json b/messages/fr.json index ffad926a0..22c1cace0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8035,6 +8035,7 @@ "LAB_RESULT": "Résultat de laboratoire", "IMAGING": "Imagerie", "PRESCRIPTION": "Ordonnance", + "REFERRAL": "Lettre d'adressage", "INSURANCE": "Assurance", "VACCINATION": "Vaccination" }, diff --git a/messages/it.json b/messages/it.json index aa5902b27..dd4dfbf76 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8035,6 +8035,7 @@ "LAB_RESULT": "Risultato di laboratorio", "IMAGING": "Diagnostica per immagini", "PRESCRIPTION": "Prescrizione", + "REFERRAL": "Impegnativa", "INSURANCE": "Assicurazione", "VACCINATION": "Vaccinazione" }, diff --git a/messages/pl.json b/messages/pl.json index fe0d8e170..4d597f8b8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8035,6 +8035,7 @@ "LAB_RESULT": "Wynik laboratoryjny", "IMAGING": "Obrazowanie", "PRESCRIPTION": "Recepta", + "REFERRAL": "Skierowanie", "INSURANCE": "Ubezpieczenie", "VACCINATION": "Szczepienie" }, diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts index b9dd14372..1f1ee72f9 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -17,12 +17,28 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), findFirstOrThrow: vi.fn(), }, + documentConditionLink: { + findMany: vi.fn(), + deleteMany: vi.fn(), + createMany: vi.fn(), + }, + illnessEpisode: { + findMany: vi.fn(), + }, + appSettings: { + findUnique: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, }, })); vi.mock("@/lib/crypto", () => ({ encrypt: vi.fn((s: string) => `v1.${s}`), decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")), + encryptBytes: vi.fn((b: Buffer) => b), + decryptBytes: vi.fn((b: Buffer) => b), })); vi.mock("@/lib/ai/coach/bytes-codec", () => ({ @@ -106,6 +122,15 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.documentConditionLink.deleteMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.documentConditionLink.createMany).mockResolvedValue({ + count: 0, + } as never); }); describe("PATCH /api/documents/inbound/[id]", () => { @@ -158,4 +183,57 @@ describe("PATCH /api/documents/inbound/[id]", () => { expect(res.status).toBe(422); expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled(); }); + + it("replace-sets condition links from episodeIds", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + } as never); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([ + { id: "ep-1" }, + { id: "ep-2" }, + ] as never); + vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue( + docRow() as never, + ); + + const res = await PATCH( + mkReq("doc-1", { episodeIds: ["ep-1", "ep-2"] }) as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(200); + + // Replace-set: everything outside the set is deleted, the set is upserted. + expect(prisma.documentConditionLink.deleteMany).toHaveBeenCalledWith({ + where: { + userId: "user-1", + documentId: "doc-1", + episodeId: { notIn: ["ep-1", "ep-2"] }, + }, + }); + expect(prisma.documentConditionLink.createMany).toHaveBeenCalledWith({ + data: [ + { documentId: "doc-1", episodeId: "ep-1", userId: "user-1" }, + { documentId: "doc-1", episodeId: "ep-2", userId: "user-1" }, + ], + skipDuplicates: true, + }); + // A links-only PATCH does not touch the metadata columns. + expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); + }); + + it("refuses a foreign episodeId with a 404-shaped response", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + } as never); + // The owner-narrowed lookup misses the foreign id. + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + + const res = await PATCH( + mkReq("doc-1", { episodeIds: ["ep-foreign"] }) as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(404); + expect(prisma.documentConditionLink.deleteMany).not.toHaveBeenCalled(); + expect(prisma.documentConditionLink.createMany).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index cbd6a513f..765f556c7 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -46,7 +46,7 @@ import { type InboundExtractionResult, } from "@/lib/documents/extract"; import { - decryptDocumentFromBytes, + decryptDocumentContent, encryptFactData, encryptFactProvenance, serialiseDocumentDetail, @@ -75,6 +75,7 @@ type LoadedDocument = { id: string; kind: string; contentEncrypted: Uint8Array; + contentCodec: string; mimeType: string; status: string; }; @@ -135,6 +136,7 @@ export const POST = apiHandler( id: true, kind: true, contentEncrypted: true, + contentCodec: true, mimeType: true, status: true, }, @@ -324,7 +326,10 @@ async function handleVisionExtract( // trust a stored label for the provider call). let buffer: Buffer; try { - buffer = decryptDocumentFromBytes(document.contentEncrypted); + buffer = decryptDocumentContent( + document.contentEncrypted, + document.contentCodec, + ); } catch { return apiError("Couldn't read the stored document.", 422, { errorCode: "documents.inbound.extractFailed", diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 3efdbd2dd..bd87795be 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -18,6 +18,11 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { + loadConditionLinks, + narrowOwnedEpisodeIds, + replaceConditionLinks, +} from "@/lib/documents/links"; import { serialiseDocumentDetail } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -62,6 +67,7 @@ export const GET = apiHandler( const { id } = await params; const document = await prisma.inboundDocument.findFirst({ where: { id, userId: user.id, deletedAt: null }, + omit: { contentEncrypted: true }, include: { facts: { orderBy: { createdAt: "asc" } } }, }); if (!document) { @@ -70,12 +76,20 @@ export const GET = apiHandler( }); } + const links = await loadConditionLinks(user.id, [document.id]); + annotate({ action: { name: "documents.inbound.get" }, meta: { documentId: id, facts: document.facts.length }, }); - return apiSuccess(serialiseDocumentDetail(document, document.facts)); + return apiSuccess( + serialiseDocumentDetail( + document, + document.facts, + links.get(document.id) ?? [], + ), + ); }, ); @@ -110,6 +124,22 @@ export const PATCH = apiHandler( }); } + // Replace-set condition links: every episode id must be a LIVE episode + // of the caller — a foreign/unknown id gets a 404-shaped refusal. + let nextEpisodeIds: string[] | undefined; + if (parsed.data.episodeIds !== undefined) { + const narrowed = await narrowOwnedEpisodeIds( + user.id, + parsed.data.episodeIds, + ); + if (narrowed === null) { + return apiError("Episode not found", 404, { + errorCode: "documents.inbound.episodeNotFound", + }); + } + nextEpisodeIds = narrowed; + } + // No mass assignment — each editable column is set explicitly only when // the client sent it. `userId` is never a body field. const data: Prisma.InboundDocumentUpdateInput = {}; @@ -121,31 +151,49 @@ export const PATCH = apiHandler( : null; } - await prisma.inboundDocument.update({ - where: { id: existing.id }, - data, - }); + if (Object.keys(data).length > 0) { + await prisma.inboundDocument.update({ + where: { id: existing.id }, + data, + }); + } + if (nextEpisodeIds !== undefined) { + await replaceConditionLinks(prisma, user.id, existing.id, nextEpisodeIds); + } const document = await prisma.inboundDocument.findFirstOrThrow({ where: { id: existing.id }, + omit: { contentEncrypted: true }, include: { facts: { orderBy: { createdAt: "asc" } } }, }); + const links = await loadConditionLinks(user.id, [document.id]); + + const touched = [ + ...Object.keys(data), + ...(nextEpisodeIds !== undefined ? ["episodeIds"] : []), + ]; await auditLog("documents.inbound.update", { userId: user.id, ipAddress: getClientIp(request), details: { documentId: existing.id, - fields: Object.keys(data), + fields: touched, }, }); annotate({ action: { name: "documents.inbound.update" }, - meta: { documentId: existing.id, fields: Object.keys(data).join(",") }, + meta: { documentId: existing.id, fields: touched.join(",") }, }); - return apiSuccess(serialiseDocumentDetail(document, document.facts)); + return apiSuccess( + serialiseDocumentDetail( + document, + document.facts, + links.get(document.id) ?? [], + ), + ); }, ); diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts index d1be3394f..7142a5d79 100644 --- a/src/app/api/documents/inbound/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -1,30 +1,59 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; /** - * v1.25 — documents library: store-only upload + browsable list. + * Document vault: store-only upload + browsable list. * - * Pins the store-first inversion: a file uploads + persists as a STORED row - * with NO provider / consent / budget call on the path (a self-hoster with no - * document-scan provider can still file documents), and the list honours the - * `q` / `kind` filters with keyset pagination. + * Pins the vault upload contract: a file persists as a STORED row with the + * binary codec + plaintext sha256 and NO provider / consent / budget call on + * the path, the policy-layer error contract (413 fileTooLarge / quotaExceeded + * with limits in `meta`, 415 unsupportedType), the duplicate short-circuit + * (200 + `meta.duplicate`, no second row), and — hardening — that the list + * query NEVER selects the encrypted blob column. */ -vi.mock("@/lib/db", () => ({ - prisma: { - inboundDocument: { - create: vi.fn(), - findMany: vi.fn(), - }, - extractedFact: { - groupBy: vi.fn(), - }, - }, +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const { txQueryRaw, txCreate, txCreateMany } = vi.hoisted(() => ({ + txQueryRaw: vi.fn(), + txCreate: vi.fn(), + txCreateMany: vi.fn(), })); -vi.mock("@/lib/crypto", () => ({ - encrypt: vi.fn((s: string) => `v1.${s}`), - decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")), -})); +vi.mock("@/lib/db", () => { + const tx = { + $queryRaw: txQueryRaw, + inboundDocument: { create: txCreate }, + documentConditionLink: { createMany: txCreateMany }, + }; + return { + prisma: { + inboundDocument: { + create: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + }, + extractedFact: { + groupBy: vi.fn(), + }, + documentConditionLink: { + findMany: vi.fn(), + }, + illnessEpisode: { + findMany: vi.fn(), + }, + appSettings: { + findUnique: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + $transaction: vi.fn(async (cb: (t: unknown) => Promise) => + cb(tx), + ), + }, + }; +}); vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), @@ -56,6 +85,8 @@ vi.mock("next/headers", () => ({ })), })); +import { createHash } from "node:crypto"; + import { POST, GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; @@ -71,18 +102,23 @@ const SESSION_OK = { const ONE_BY_ONE_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg=="; +const PNG_BYTES = Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64"); +const PNG_SHA256 = createHash("sha256").update(PNG_BYTES).digest("hex"); -function mkUpload(fields: Record = {}) { - const body = Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64"); - const blobBytes = new Uint8Array(body.byteLength); - blobBytes.set(body); +function mkUpload( + fields: Record = {}, + bytes: Buffer = PNG_BYTES, + filename = "x.png", +) { + const blobBytes = new Uint8Array(bytes.byteLength); + blobBytes.set(bytes); const formData = new FormData(); - formData.append( - "file", - new Blob([blobBytes], { type: "image/png" }), - "x.png", - ); - for (const [k, v] of Object.entries(fields)) formData.append(k, v); + formData.append("file", new Blob([blobBytes]), filename); + for (const [k, v] of Object.entries(fields)) { + for (const item of Array.isArray(v) ? v : [v]) { + formData.append(k, item); + } + } return new Request("http://localhost/api/documents/inbound", { method: "POST", body: formData, @@ -103,6 +139,8 @@ function docRow(over: Record = {}) { reportDate: null, documentDate: null, errorReason: null, + contentSha256: PNG_SHA256, + contentCodec: "binary2", createdAt: new Date("2026-06-01T00:00:00.000Z"), updatedAt: new Date("2026-06-01T00:00:00.000Z"), ...over, @@ -114,11 +152,21 @@ beforeEach(() => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(null as never); + // No settings row / no user override → policy defaults (25 MiB / 1 GiB). + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + txQueryRaw.mockResolvedValue([{ used: BigInt(0) }]); + txCreate.mockResolvedValue(docRow() as never); + txCreateMany.mockResolvedValue({ count: 0 } as never); }); -describe("POST /api/documents/inbound (store-only)", () => { - it("stores a file as STORED with no provider call and returns 201", async () => { - vi.mocked(prisma.inboundDocument.create).mockResolvedValue( +describe("POST /api/documents/inbound (vault upload)", () => { + it("stores a file binary-codec encrypted with its plaintext sha256", async () => { + txCreate.mockResolvedValue( docRow({ kind: "LAB_RESULT", title: "Blood panel" }) as never, ); @@ -132,47 +180,151 @@ describe("POST /api/documents/inbound (store-only)", () => { expect(body.data.status).toBe("STORED"); expect(body.data.kind).toBe("LAB_RESULT"); expect(body.data.title).toBe("Blood panel"); + expect(body.data.servingClass).toBe("inline"); + expect(body.data.conditionLinks).toEqual([]); - // The row is created with status STORED, no provider, userId from session. - const arg = vi.mocked(prisma.inboundDocument.create).mock.calls[0]![0]!; + // The row is created with status STORED, no provider, userId from the + // session, the ACTIVE binary codec, and the plaintext sha256. + const arg = txCreate.mock.calls[0]![0]!; expect(arg.data.status).toBe("STORED"); expect(arg.data.userId).toBe("user-1"); expect(arg.data.kind).toBe("LAB_RESULT"); + expect(arg.data.contentCodec).toBe("binary2"); + expect(arg.data.contentSha256).toBe(PNG_SHA256); expect("providerType" in arg.data).toBe(false); - // No userId smuggled from the form is honoured — it comes from the session. - expect(arg.data.userId).toBe("user-1"); + // The persisted blob is ciphertext, never the plaintext bytes. + const stored = Buffer.from(arg.data.contentEncrypted as Uint8Array); + expect(stored.equals(PNG_BYTES)).toBe(false); + // The create never round-trips the blob back out. + expect(arg.omit).toEqual({ contentEncrypted: true }); }); - it("rejects a non-image/pdf payload with 415", async () => { - const formData = new FormData(); - formData.append("file", new Blob([new Uint8Array([1, 2, 3, 4])]), "x.bin"); - const req = new Request("http://localhost/api/documents/inbound", { - method: "POST", - body: formData, - }); - const res = await post(req); + it("rejects an unidentifiable payload with 415 + reason unsupportedType", async () => { + const res = await post( + mkUpload({}, Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]), "x.bin"), + ); expect(res.status).toBe(415); - expect(prisma.inboundDocument.create).not.toHaveBeenCalled(); + const body = await res.json(); + expect(body.meta.reason).toBe("unsupportedType"); + expect(txCreate).not.toHaveBeenCalled(); + }); + + it("rejects an over-cap file with 413 + the configured limit", async () => { + // Admin capped uploads below the PNG's 70 bytes (clamped ≥ 1 in the + // resolver); the file itself trips the per-file check. + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + documentMaxFileBytes: 16, + documentQuotaBytes: BigInt(1_073_741_824), + } as never); + + const res = await post(mkUpload()); + expect(res.status).toBe(413); + const body = await res.json(); + expect(body.meta.reason).toBe("fileTooLarge"); + expect(body.meta.maxFileBytes).toBe(16); + expect(txCreate).not.toHaveBeenCalled(); + }); + + it("rejects an upload past the quota with 413 + quota figures", async () => { + // Per-user override of 100 bytes; 60 already used; the 70-byte PNG tips it. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentQuotaBytes: BigInt(100), + } as never); + txQueryRaw.mockResolvedValue([{ used: BigInt(60) }]); + + const res = await post(mkUpload()); + expect(res.status).toBe(413); + const body = await res.json(); + expect(body.meta.reason).toBe("quotaExceeded"); + expect(body.meta.quotaBytes).toBe(100); + expect(body.meta.usedBytes).toBe(60); + expect(txCreate).not.toHaveBeenCalled(); + }); + + it("returns the existing live row with meta.duplicate on a re-upload", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue( + docRow({ id: "doc-existing" }) as never, + ); + + const res = await post(mkUpload()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.meta.duplicate).toBe(true); + expect(body.data.id).toBe("doc-existing"); + // The duplicate lookup is scoped to the caller's LIVE rows by sha256. + const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0]![0]! + .where!; + expect(where.userId).toBe("user-1"); + expect(where.contentSha256).toBe(PNG_SHA256); + expect(where.deletedAt).toBeNull(); + expect(txCreate).not.toHaveBeenCalled(); + }); + + it("pre-links the caller's episodes and refuses a foreign episode id", async () => { + // Owned episode → link rows land in the same transaction. + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([ + { id: "ep-1" }, + ] as never); + let res = await post(mkUpload({ episodeIds: ["ep-1"] })); + expect(res.status).toBe(201); + expect(txCreateMany).toHaveBeenCalledWith({ + data: [{ documentId: "doc-1", episodeId: "ep-1", userId: "user-1" }], + }); + + // Foreign/unknown episode id → 404-shaped refusal, nothing persisted. + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: true, + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + res = await post(mkUpload({ episodeIds: ["ep-foreign"] })); + expect(res.status).toBe(404); + expect(txCreate).not.toHaveBeenCalled(); }); }); describe("GET /api/documents/inbound (list)", () => { - it("applies q + kind filters and returns a keyset page with grouped counts", async () => { + it("never selects the encrypted blob column (omit hardening)", async () => { + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never); + + const res = await get( + new Request("http://localhost/api/documents/inbound"), + ); + expect(res.status).toBe(200); + + const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!; + expect(arg.omit).toEqual({ contentEncrypted: true }); + expect(arg.select).toBeUndefined(); + expect(arg.include).toBeUndefined(); + }); + + it("applies q + multi-kind + episode filters and returns grouped counts", async () => { vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ docRow({ id: "d1" }), docRow({ id: "d2" }), ] as never); - // factCount excludes REJECTED; pendingCount is the PENDING subset. The - // counts come from a single grouped query, not from materialised fact rows. vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([ { documentId: "d2", status: "PENDING", _count: { _all: 1 } }, { documentId: "d2", status: "APPROVED", _count: { _all: 1 } }, { documentId: "d2", status: "REJECTED", _count: { _all: 3 } }, ] as never); + vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue([ + { + documentId: "d1", + episodeId: "ep-1", + episode: { label: "Knie" }, + }, + ] as never); const res = await get( new Request( - "http://localhost/api/documents/inbound?q=panel&kind=LAB_RESULT&sort=createdAt&order=desc&limit=10", + "http://localhost/api/documents/inbound?q=panel&kind=LAB_RESULT&kind=IMAGING&episodeId=ep-1&sort=createdAt&order=desc&limit=10", ), ); expect(res.status).toBe(200); @@ -180,11 +332,11 @@ describe("GET /api/documents/inbound (list)", () => { expect(body.data.documents).toHaveLength(2); expect(body.data.nextCursor).toBeNull(); - // d1 has no facts; d2 has 1 pending + 1 approved + 3 rejected → factCount 2 - // (rejected excluded), pendingCount 1. + // d1 carries its condition link; d2 has 1 pending + 1 approved + + // 3 rejected → factCount 2 (rejected excluded), pendingCount 1. const [d1, d2] = body.data.documents; + expect(d1.conditionLinks).toEqual([{ episodeId: "ep-1", name: "Knie" }]); expect(d1.factCount).toBe(0); - expect(d1.pendingCount).toBe(0); expect(d2.factCount).toBe(2); expect(d2.pendingCount).toBe(1); @@ -192,23 +344,32 @@ describe("GET /api/documents/inbound (list)", () => { const where = arg.where!; expect(where.userId).toBe("user-1"); expect(where.deletedAt).toBeNull(); - expect(where.kind).toBe("LAB_RESULT"); + expect(where.kind).toEqual({ in: ["LAB_RESULT", "IMAGING"] }); + expect(where.conditionLinks).toEqual({ some: { episodeId: "ep-1" } }); expect(where.OR).toEqual([ { title: { contains: "panel", mode: "insensitive" } }, { filename: { contains: "panel", mode: "insensitive" } }, ]); - // No fact rows are materialised in the page query — counts come from groupBy. - expect(arg.include).toBeUndefined(); // limit+1 fetched for the has-more probe. expect(arg.take).toBe(11); }); + it("maps the year filter to a UTC calendar-year documentDate range", async () => { + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never); + const res = await get( + new Request("http://localhost/api/documents/inbound?year=2025"), + ); + expect(res.status).toBe(200); + const where = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]! + .where!; + expect(where.documentDate).toEqual({ + gte: new Date(Date.UTC(2025, 0, 1)), + lt: new Date(Date.UTC(2026, 0, 1)), + }); + }); + it("emits nextCursor when a full page+1 is returned", async () => { - const rows = Array.from({ length: 3 }, (_, i) => ({ - ...docRow({ id: `d${i}` }), - _count: { facts: 0 }, - facts: [], - })); + const rows = Array.from({ length: 3 }, (_, i) => docRow({ id: `d${i}` })); vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue(rows as never); const res = await get( diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index eda814789..4a0f09462 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -1,39 +1,54 @@ /** - * v1.25 — documents library: store-first upload + browsable list. + * Document vault: store-first upload + browsable list. * - * POST is STORE-ONLY and provider-free. A self-hoster uploads any clinical - * document; it is stored ENCRYPTED at rest with `status: STORED` and NO - * extraction run. There is no provider resolution, no AI consent guard, no - * budget, and NO egress on this path — a file can always be filed, even on an - * account with no document-scan provider configured. Optional AI extraction is - * a separate, user-triggered action (`POST /api/documents/inbound/[id]/extract`). + * POST is STORE-ONLY and provider-free. A self-hoster uploads any accepted + * document; it is stored ENCRYPTED at rest (binary codec) with + * `status: STORED` and NO extraction run. There is no provider resolution, no + * AI consent guard, no budget, and NO egress on this path — a file can always + * be filed, even on an account with no document-scan provider configured. * - * GET lists the caller's documents with title/filename search, a category - * filter, a `documentDate` range, sort, and keyset pagination — all through the - * parameter-bound Prisma query builder (no raw SQL). + * The upload path enforces the vault policy layer + * (`src/lib/documents/upload-policy.ts`): magic-byte classification (the wire + * Content-Type is never trusted), the admin-tunable per-file cap (bounded + * read aborts at the cap), the per-user quota (checked in the same + * transaction as the insert, tombstone-inclusive), sha256 duplicate detection + * (a same-user re-upload returns the existing live row, never a second copy), + * an honoured `Idempotency-Key`, and optional `episodeIds[]` pre-linking. + * + * GET lists the caller's documents with title/filename search, kind / + * episode / year / date-range filters, sort, and keyset pagination — and + * NEVER selects the encrypted blob column (`omit: { contentEncrypted: true }`). * * The document is UNTRUSTED (prompt-injection): the server never acts on an - * instruction inside it. Storing it does nothing with its contents; the later - * review-then-confirm step is the safety boundary for any extracted facts. + * instruction inside it. Storing it does nothing with its contents. */ import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; + +import { NextResponse } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { - encryptDocumentToBytes, + loadConditionLinks, + narrowOwnedEpisodeIds, +} from "@/lib/documents/links"; +import { + encryptDocumentContent, serialiseDocument, + type SerialisableDocument, } from "@/lib/documents/store"; import { - BodyTooLargeError, - detectOcrMimeType, - OCR_MAX_BYTES, - readBoundedBody, -} from "@/lib/labs/ocr-upload"; + detectDocumentType, + resolveDocumentLimits, +} from "@/lib/documents/upload-policy"; +import { withIdempotency } from "@/lib/idempotency"; +import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { isP2002 } from "@/lib/prisma-errors"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { documentCreateSchema, @@ -50,13 +65,78 @@ export const dynamic = "force-dynamic"; const UPLOAD_LIMIT_PER_HOUR = 60; const UPLOAD_WINDOW_MS = 60 * 60 * 1000; +/** + * Multipart envelope allowance on top of the per-file cap: boundaries plus + * the small metadata fields (title / kind / documentDate / episodeIds). The + * exact per-file cap is re-enforced on the extracted file bytes below. + */ +const MULTIPART_OVERHEAD_BYTES = 64 * 1024; + /** Parse a YYYY-MM-DD form field into a UTC midnight Date. */ function isoDateToUtc(value: string): Date { return new Date(`${value}T00:00:00.000Z`); } +/** §3.2 — 413 fileTooLarge with the configured limit in `meta`. */ +function fileTooLarge(maxFileBytes: number): NextResponse { + return apiError("File is too large.", 413, { + errorCode: "documents.inbound.fileTooLarge", + reason: "fileTooLarge", + maxFileBytes, + }); +} + +/** Internal signal: the quota gate inside the insert transaction tripped. */ +class QuotaExceededError extends Error { + constructor(public readonly usedBytes: number) { + super("Document quota exceeded"); + this.name = "QuotaExceededError"; + } +} + +/** + * §3.2 — a duplicate upload is NOT an error: return the existing live row + * with `meta.duplicate: true` at the envelope level (the UI toasts "already + * stored" and highlights the row). + */ +async function duplicateResponse( + userId: string, + existing: SerialisableDocument, +): Promise { + const [links, groups] = await Promise.all([ + loadConditionLinks(userId, [existing.id]), + prisma.extractedFact.groupBy({ + by: ["status"], + where: { userId, documentId: existing.id }, + _count: { _all: true }, + }), + ]); + let factCount = 0; + let pendingCount = 0; + for (const g of groups) { + if (g.status !== "REJECTED") factCount += g._count._all; + if (g.status === "PENDING") pendingCount += g._count._all; + } + annotate({ + action: { name: "documents.vault.upload" }, + meta: { documentId: existing.id, duplicate: true }, + }); + return NextResponse.json( + { + data: serialiseDocument( + existing, + { factCount, pendingCount }, + links.get(existing.id) ?? [], + ), + error: null, + meta: { duplicate: true }, + }, + { status: 200 }, + ); +} + /** POST — store an uploaded document encrypted at rest. No extraction. */ -export const POST = apiHandler(async (request: Request) => { +async function postUpload(request: Request): Promise { const { user } = await requireAuth(); // Opt-in module gate — even a valid Bearer token is refused when the surface @@ -79,24 +159,25 @@ export const POST = apiHandler(async (request: Request) => { return response; } + const limits = await resolveDocumentLimits(user.id); + const bodyCap = limits.maxFileBytes + MULTIPART_OVERHEAD_BYTES; + + // Instant rejection on a declared oversize (a CD/ISO-sized upload never + // allocates); the bounded read below covers chunked/undeclared bodies. const contentLength = Number(request.headers.get("content-length") ?? 0); - if (contentLength > OCR_MAX_BYTES) { - return apiError("File is too large (max 12 MB).", 413, { - errorCode: "documents.inbound.fileTooLarge", - }); + if (contentLength > bodyCap) { + return fileTooLarge(limits.maxFileBytes); } let formData: FormData; try { - const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES); + const bytes = await readBoundedBody(request.body, bodyCap); formData = await new Response(new Blob([bytes]), { headers: { "content-type": request.headers.get("content-type") ?? "" }, }).formData(); } catch (err) { if (err instanceof BodyTooLargeError) { - return apiError("File is too large (max 12 MB).", 413, { - errorCode: "documents.inbound.fileTooLarge", - }); + return fileTooLarge(limits.maxFileBytes); } return apiError("Invalid multipart body", 400); } @@ -106,12 +187,17 @@ export const POST = apiHandler(async (request: Request) => { return apiError("Field 'file' must be a file", 422); } - // Optional metadata (title / kind / documentDate). The file is read - // separately; these are the form fields beside it. + // Optional metadata (title / kind / documentDate / episodeIds). The file is + // read separately; these are the form fields beside it. `episodeIds` may be + // repeated. + const rawEpisodeIds = formData + .getAll("episodeIds") + .filter((v): v is string => typeof v === "string"); const parsed = documentCreateSchema.safeParse({ title: formData.get("title") ?? undefined, kind: formData.get("kind") ?? undefined, documentDate: formData.get("documentDate") ?? undefined, + episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined, }); if (!parsed.success) { return apiError("Invalid document metadata", 422, { @@ -125,63 +211,155 @@ export const POST = apiHandler(async (request: Request) => { } catch { return apiError("Failed to read uploaded file", 400); } - - const mime = detectOcrMimeType(buffer); - if (!mime) { - return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, { - errorCode: "documents.inbound.fileType", + if (buffer.byteLength > limits.maxFileBytes) { + return fileTooLarge(limits.maxFileBytes); + } + if (buffer.byteLength === 0) { + return apiError("Uploaded file is empty", 422, { + errorCode: "documents.inbound.invalidMetadata", }); } - // No mass assignment — every column is set field-by-field; `userId` comes - // from the session, never the body. `status` is STORED (the library default); - // no provider / consent / budget call runs here. - // - // `documentDate` is the effective filing date the library sorts, groups, and - // range-filters on. When the user does not supply one at upload, default it to - // the upload day (now) — the same effective date the UI shows — so the column - // is never NULL on a stored row. This keeps display == sort == filter: an - // undated upload no longer sinks below older dated rows under `nulls: "last"`, - // and a "this month" range filter sees it. (At store time `reportDate` is - // still null; it only exists after an extraction, so the default resolves to - // the upload day.) The date stays user-editable via PATCH, which still allows - // clearing it back to NULL. - const document = await prisma.inboundDocument.create({ - data: { - userId: user.id, - kind: parsed.data.kind ?? "OTHER", - // Stored plaintext on purpose (mirrors `filename`) so the list can - // ILIKE-search + ORDER BY it. It MAY hold PHI the user types; that is the - // accepted tradeoff for server-side search/sort — the document body stays - // encrypted. - title: parsed.data.title ?? null, - filename: typeof file.name === "string" ? file.name.slice(0, 255) : null, - mimeType: mime, - byteSize: buffer.byteLength, - contentEncrypted: encryptDocumentToBytes(buffer), - status: "STORED", - documentDate: parsed.data.documentDate - ? isoDateToUtc(parsed.data.documentDate) - : new Date(), - }, + // §3.1 — magic-byte classification; the wire Content-Type is never trusted. + const detected = detectDocumentType( + buffer, + typeof file.name === "string" ? file.name : null, + ); + if (!detected) { + return apiError("This file type is not supported.", 415, { + errorCode: "documents.inbound.fileType", + reason: "unsupportedType", + }); + } + + // Pre-linking: every episode id must be a LIVE episode of the caller. + const episodeIds = await narrowOwnedEpisodeIds( + user.id, + parsed.data.episodeIds ?? [], + ); + if (episodeIds === null) { + return apiError("Episode not found", 404, { + errorCode: "documents.inbound.episodeNotFound", + }); + } + + // sha256 of the PLAINTEXT for same-user duplicate detection. + const contentSha256 = createHash("sha256").update(buffer).digest("hex"); + + // Fast-path dedupe check; the partial unique index closes the race below. + const existing = await prisma.inboundDocument.findFirst({ + where: { userId: user.id, contentSha256, deletedAt: null }, + omit: { contentEncrypted: true }, }); + if (existing) { + return duplicateResponse(user.id, existing); + } + + // Quota gate + insert + pre-links in ONE transaction. Usage counts every + // non-purged row — tombstones still hold TOAST bytes, so "deleted" bytes + // are never invisible weight (undo-delete never changes usage). + let document: SerialisableDocument; + try { + document = await prisma.$transaction(async (tx) => { + const rows = await tx.$queryRaw>` + SELECT COALESCE(SUM(byte_size), 0)::bigint AS used + FROM inbound_documents + WHERE user_id = ${user.id} + `; + const usedBytes = Number(rows[0]?.used ?? 0); + if (usedBytes + buffer.byteLength > limits.quotaBytes) { + throw new QuotaExceededError(usedBytes); + } + + const { content, codec } = encryptDocumentContent(buffer); + + // No mass assignment — every column is set field-by-field; `userId` + // comes from the session, never the body. `documentDate` defaults to + // the upload day so display == sort == filter (user-editable later). + const created = await tx.inboundDocument.create({ + data: { + userId: user.id, + kind: parsed.data.kind ?? "OTHER", + // Stored plaintext on purpose (mirrors `filename`) so the list can + // ILIKE-search + ORDER BY it. It MAY hold PHI the user types; that + // is the accepted tradeoff for server-side search/sort — the + // document body stays encrypted. + title: parsed.data.title ?? null, + filename: + typeof file.name === "string" ? file.name.slice(0, 255) : null, + mimeType: detected.mimeType, + byteSize: buffer.byteLength, + contentEncrypted: content, + contentCodec: codec, + contentSha256, + status: "STORED", + documentDate: parsed.data.documentDate + ? isoDateToUtc(parsed.data.documentDate) + : new Date(), + }, + omit: { contentEncrypted: true }, + }); + + if (episodeIds.length > 0) { + await tx.documentConditionLink.createMany({ + data: episodeIds.map((episodeId) => ({ + documentId: created.id, + episodeId, + userId: user.id, + })), + }); + } + return created; + }); + } catch (err) { + if (err instanceof QuotaExceededError) { + return apiError("Storage quota exceeded.", 413, { + errorCode: "documents.inbound.quotaExceeded", + reason: "quotaExceeded", + quotaBytes: limits.quotaBytes, + usedBytes: err.usedBytes, + }); + } + if (isP2002(err)) { + // A racing upload of the same bytes won the partial unique index. + // Surface the winner as the duplicate — same outcome as the fast path. + const winner = await prisma.inboundDocument.findFirst({ + where: { userId: user.id, contentSha256, deletedAt: null }, + omit: { contentEncrypted: true }, + }); + if (winner) return duplicateResponse(user.id, winner); + } + throw err; + } await auditLog("documents.inbound.store", { userId: user.id, ipAddress: getClientIp(request), - details: { documentId: document.id, mime }, + details: { documentId: document.id, mime: detected.mimeType }, }); annotate({ - action: { name: "documents.inbound.store" }, - meta: { documentId: document.id, byteSize: document.byteSize }, + action: { name: "documents.vault.upload" }, + meta: { + documentId: document.id, + byteSize: document.byteSize, + servingClass: detected.servingClass, + linked: episodeIds.length, + }, }); + const links = await loadConditionLinks(user.id, [document.id]); return apiSuccess( - serialiseDocument(document, { factCount: 0, pendingCount: 0 }), + serialiseDocument( + document, + { factCount: 0, pendingCount: 0 }, + links.get(document.id) ?? [], + ), 201, ); -}); +} + +export const POST = apiHandler(withIdempotency<[Request]>(postUpload)); /** GET — list the caller's documents (search / filter / sort / paginate). */ export const GET = apiHandler(async (request: Request) => { @@ -191,22 +369,37 @@ export const GET = apiHandler(async (request: Request) => { if (!gate.enabled) return gate.response; const url = new URL(request.url); - const parsed = documentListQuerySchema.safeParse( - Object.fromEntries(url.searchParams), - ); + // `kind` is a multi-value facet: repeated params and/or comma-separated. + const kinds = url.searchParams + .getAll("kind") + .flatMap((v) => v.split(",")) + .map((v) => v.trim()) + .filter((v) => v.length > 0); + const single = Object.fromEntries(url.searchParams); + const parsed = documentListQuerySchema.safeParse({ + ...single, + kind: kinds.length > 0 ? kinds : undefined, + }); if (!parsed.success) { return apiError("Invalid list query", 422, { errorCode: "documents.inbound.invalidQuery", }); } - const { q, kind, from, to, sort, order, cursor, limit } = parsed.data; + const { q, kind, episodeId, year, from, to, sort, order, cursor, limit } = + parsed.data; const where: Prisma.InboundDocumentWhereInput = { userId: user.id, deletedAt: null, }; - if (kind) where.kind = kind; - if (from || to) { + if (kind && kind.length > 0) where.kind = { in: kind }; + if (episodeId) where.conditionLinks = { some: { episodeId } }; + if (year !== undefined) { + where.documentDate = { + gte: new Date(Date.UTC(year, 0, 1)), + lt: new Date(Date.UTC(year + 1, 0, 1)), + }; + } else if (from || to) { where.documentDate = { ...(from ? { gte: isoDateToUtc(from) } : {}), // inclusive end-of-day for the `to` bound @@ -234,8 +427,11 @@ export const GET = apiHandler(async (request: Request) => { { id: order }, ]; + // Hardening: the list NEVER fetches the encrypted blob column — a page of + // 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request. const rows = await prisma.inboundDocument.findMany({ where, + omit: { contentEncrypted: true }, orderBy, take: limit + 1, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), @@ -247,8 +443,8 @@ export const GET = apiHandler(async (request: Request) => { // Fact tallies for the whole page in ONE grouped count — no per-document // fan-out and no materialising fact-id rows. `factCount` counts every - // non-REJECTED fact (a rejected fact is discarded, not part of the document's - // tally), `pendingCount` the PENDING subset still awaiting review. + // non-REJECTED fact (a rejected fact is discarded, not part of the + // document's tally), `pendingCount` the PENDING subset awaiting review. const factCounts = new Map< string, { factCount: number; pendingCount: number } @@ -271,9 +467,20 @@ export const GET = apiHandler(async (request: Request) => { } } + // Condition links for the page in ONE grouped query (no N+1). + const linkMap = await loadConditionLinks( + user.id, + page.map((d) => d.id), + ); + annotate({ action: { name: "documents.inbound.list" }, - meta: { count: page.length, sort, order, filtered: Boolean(q || kind) }, + meta: { + count: page.length, + sort, + order, + filtered: Boolean(q || (kind && kind.length > 0) || episodeId || year), + }, }); return apiSuccess({ @@ -281,6 +488,7 @@ export const GET = apiHandler(async (request: Request) => { serialiseDocument( doc, factCounts.get(doc.id) ?? { factCount: 0, pendingCount: 0 }, + linkMap.get(doc.id) ?? [], ), ), nextCursor, diff --git a/src/components/documents/__tests__/inbound-documents-view.test.tsx b/src/components/documents/__tests__/inbound-documents-view.test.tsx index a38eb13d3..5df3dbabe 100644 --- a/src/components/documents/__tests__/inbound-documents-view.test.tsx +++ b/src/components/documents/__tests__/inbound-documents-view.test.tsx @@ -59,6 +59,8 @@ function doc( errorReason: null, factCount: 0, pendingCount: 0, + conditionLinks: [], + servingClass: "inline", createdAt: "2026-06-20T08:00:00.000Z", updatedAt: "2026-06-20T08:00:00.000Z", ...over, diff --git a/src/components/documents/__tests__/library-utils.test.ts b/src/components/documents/__tests__/library-utils.test.ts index 944b9abca..b8e55a6e8 100644 --- a/src/components/documents/__tests__/library-utils.test.ts +++ b/src/components/documents/__tests__/library-utils.test.ts @@ -49,6 +49,8 @@ function doc( errorReason: null, factCount: 0, pendingCount: 0, + conditionLinks: [], + servingClass: "inline", createdAt: "2026-06-20T08:00:00.000Z", updatedAt: "2026-06-20T08:00:00.000Z", ...over, diff --git a/src/lib/documents/links.ts b/src/lib/documents/links.ts new file mode 100644 index 000000000..92515b6eb --- /dev/null +++ b/src/lib/documents/links.ts @@ -0,0 +1,83 @@ +/** + * Document ⇄ condition link helpers shared by the vault routes. + * + * One grouped query per page (no N+1), owner-scoped everywhere: every episode + * id a client sends is re-narrowed against the caller's live episodes before + * it can land in `document_condition_links`. + */ +import { prisma } from "@/lib/db"; +import type { DocumentConditionLinkDto } from "@/lib/validations/inbound-documents"; + +/** + * Load the condition links for a page of documents in ONE grouped query. + * Returns a map documentId → link DTOs (documents without links are absent). + */ +export async function loadConditionLinks( + userId: string, + documentIds: string[], +): Promise> { + const map = new Map(); + if (documentIds.length === 0) return map; + const links = await prisma.documentConditionLink.findMany({ + where: { userId, documentId: { in: documentIds } }, + select: { + documentId: true, + episodeId: true, + episode: { select: { label: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + for (const link of links) { + const entry = map.get(link.documentId) ?? []; + entry.push({ episodeId: link.episodeId, name: link.episode.label }); + map.set(link.documentId, entry); + } + return map; +} + +/** + * Narrow a client-sent episode-id list to the caller's LIVE episodes. + * Returns the deduplicated owned ids, or null when any id is unknown / + * foreign / deleted — the caller answers with a 404-shaped refusal (an + * attacker probing another user's episode ids learns nothing beyond + * "not found"). + */ +export async function narrowOwnedEpisodeIds( + userId: string, + episodeIds: string[], +): Promise { + const unique = [...new Set(episodeIds)]; + if (unique.length === 0) return []; + const owned = await prisma.illnessEpisode.findMany({ + where: { id: { in: unique }, userId, deletedAt: null }, + select: { id: true }, + }); + if (owned.length !== unique.length) return null; + return unique; +} + +/** + * Replace-set a document's condition links inside the given transaction-ish + * client: delete links no longer in the set, insert the missing ones. + * `episodeIds` MUST already be owner-narrowed (`narrowOwnedEpisodeIds`). + */ +export async function replaceConditionLinks( + tx: Pick, + userId: string, + documentId: string, + episodeIds: string[], +): Promise { + await tx.documentConditionLink.deleteMany({ + where: { userId, documentId, episodeId: { notIn: episodeIds } }, + }); + if (episodeIds.length > 0) { + await tx.documentConditionLink.createMany({ + data: episodeIds.map((episodeId) => ({ + documentId, + episodeId, + userId, + })), + skipDuplicates: true, + }); + } +} diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index b2153902d..e7c8307e4 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -52,6 +52,7 @@ export const INBOUND_DOCUMENT_KINDS = [ "LAB_RESULT", "IMAGING", "PRESCRIPTION", + "REFERRAL", "INSURANCE", "VACCINATION", "OTHER", @@ -196,6 +197,13 @@ export interface ExtractedFactDto { committedRecordType: string | null; } +/** One condition link on a document DTO (chip → `/illness/[id]`). */ +export interface DocumentConditionLinkDto { + episodeId: string; + /** The episode's user-facing label. */ + name: string; +} + /** The document DTO (list + detail). */ export interface InboundDocumentDto { id: string; @@ -216,6 +224,10 @@ export interface InboundDocumentDto { factCount: number; /** Count of staged facts still PENDING review. */ pendingCount: number; + /** Linked illness/condition episodes (empty when unlinked). */ + conditionLinks: DocumentConditionLinkDto[]; + /** How the serve route delivers the original: render inline or download. */ + servingClass: "inline" | "attachment"; createdAt: string; updatedAt: string; } @@ -343,16 +355,26 @@ const isoDateString = z export const DOCUMENT_TITLE_MAX = 200; +/** Max condition links a single document may carry / receive per request. */ +export const DOCUMENT_MAX_EPISODE_LINKS = 20; + +const episodeIdList = z + .array(z.string().trim().min(1).max(40)) + .max(DOCUMENT_MAX_EPISODE_LINKS); + /** * The store-only upload metadata (the multipart form fields beside the file). * Every field is optional — a bare file upload is valid and lands as a STORED - * document with no title / category / filing date. No `userId` field; it is - * always narrowed from the session. + * document with no title / category / filing date. `episodeIds` pre-links the + * document to the caller's illness/condition episodes (repeated `episodeIds` + * form fields; ownership is re-checked in the route). No `userId` field; it + * is always narrowed from the session. */ export const documentCreateSchema = z.object({ title: z.string().trim().min(1).max(DOCUMENT_TITLE_MAX).optional(), kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(), documentDate: isoDateString.optional(), + episodeIds: episodeIdList.optional(), }); export type DocumentCreateInput = z.infer; @@ -374,12 +396,19 @@ export const documentUpdateSchema = z .transform((v) => (v === "" ? null : v)), kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(), documentDate: isoDateString.nullable().optional(), + /** + * Replace-set condition links: the document's links become exactly this + * set (an empty array unlinks everything). Ownership of every episode id + * is re-checked in the route against the caller's episodes. + */ + episodeIds: episodeIdList.optional(), }) .refine( (d) => d.title !== undefined || d.kind !== undefined || - d.documentDate !== undefined, + d.documentDate !== undefined || + d.episodeIds !== undefined, { message: "Provide at least one field to update" }, ); @@ -403,7 +432,15 @@ export const DOCUMENT_LIST_DEFAULT_LIMIT = 50; */ export const documentListQuerySchema = z.object({ q: z.string().trim().max(100).optional(), - kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(), + /** + * Category filter — OR inside the facet. Repeated `kind` params or a + * comma-separated value; the route normalises to an array before parsing. + */ + kind: z.array(z.enum(INBOUND_DOCUMENT_KINDS)).max(16).optional(), + /** Only documents linked to this illness/condition episode. */ + episodeId: z.string().trim().min(1).max(40).optional(), + /** Only documents whose filing date falls in this calendar year (UTC). */ + year: z.coerce.number().int().min(1900).max(9999).optional(), from: isoDateString.optional(), to: isoDateString.optional(), sort: z.enum(DOCUMENT_LIST_SORTS).default("documentDate"), @@ -418,3 +455,66 @@ export const documentListQuerySchema = z.object({ }); export type DocumentListQuery = z.infer; + +// ─── Vault: bulk actions ─────────────────────────────────────────────────── + +/** Max document ids one bulk request may touch. */ +export const DOCUMENT_BULK_MAX_IDS = 100; + +export const DOCUMENT_BULK_ACTIONS = [ + "setKind", + "linkEpisode", + "unlinkEpisode", + "delete", + "restore", +] as const; +export type DocumentBulkAction = (typeof DOCUMENT_BULK_ACTIONS)[number]; + +/** + * One bulk action over up to 100 owner-scoped documents. `setKind` requires + * `kind`; `linkEpisode` / `unlinkEpisode` require `episodeId` (ownership + * re-checked in the route). Per-id outcomes ride the response so a partial + * failure never aborts the batch. No `userId` field — narrowed from the + * session. + */ +export const documentBulkSchema = z + .object({ + ids: z + .array(z.string().trim().min(1).max(40)) + .min(1) + .max(DOCUMENT_BULK_MAX_IDS), + action: z.enum(DOCUMENT_BULK_ACTIONS), + kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(), + episodeId: z.string().trim().min(1).max(40).optional(), + }) + .refine((d) => d.action !== "setKind" || d.kind !== undefined, { + message: "Action 'setKind' requires a kind", + path: ["kind"], + }) + .refine( + (d) => + (d.action !== "linkEpisode" && d.action !== "unlinkEpisode") || + d.episodeId !== undefined, + { + message: "Episode actions require an episodeId", + path: ["episodeId"], + }, + ); + +export type DocumentBulkInput = z.infer; + +/** Per-id outcome in the bulk response. */ +export interface DocumentBulkResultDto { + id: string; + ok: boolean; + /** Short machine reason when `ok` is false (e.g. "notFound", "conflict"). */ + error: string | null; +} + +/** The usage endpoint payload the UI reads before offering an upload. */ +export interface DocumentUsageDto { + usedBytes: number; + quotaBytes: number; + maxFileBytes: number; + acceptedExtensions: string[]; +} From d9bbdb037afd7781e8f7037ebb62997220c31a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:05:29 +0200 Subject: [PATCH 04/34] feat(documents): restore, bulk, usage endpoints and class-aware serving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST [id]/restore clears the soft-delete tombstone inside the 30-day undo grace; it answers 409 once the tombstone is purged (the undo window is over) and 409 with meta.reason=duplicateExists when an identical live document would collide with the live-dedupe index. Restoring an already-live row is a no-op success, so a late undo toast never errors. POST /bulk applies one action (setKind / linkEpisode / unlinkEpisode / delete / restore) to up to 100 owner-scoped documents and reports per-id outcomes — a partial failure never aborts the batch. GET /usage returns usedBytes (tombstone-inclusive), the effective quota, the per-file cap, and the accepted-extension list for picker accept attributes. The serve route now derives its posture from the serving class: inline types keep their true Content-Type and gain Content-Security-Policy: sandbox as defence-in-depth for the PDF viewer context; attachment types ALWAYS go out as application/octet-stream + Content-Disposition: attachment. nosniff and private,no-store on both; RFC 5987 filename handling unchanged; decrypt dispatches on the row codec and stays fail-closed. Header matrix pinned per class in the route tests. Tasks: W1-T8, W1-T9, W1-T10. --- .../[id]/original/__tests__/route.test.ts | 67 ++++++ .../documents/inbound/[id]/original/route.ts | 95 +++++--- .../documents/inbound/[id]/restore/route.ts | 155 +++++++++++++ src/app/api/documents/inbound/bulk/route.ts | 203 ++++++++++++++++++ src/app/api/documents/inbound/usage/route.ts | 50 +++++ 5 files changed, 545 insertions(+), 25 deletions(-) create mode 100644 src/app/api/documents/inbound/[id]/restore/route.ts create mode 100644 src/app/api/documents/inbound/bulk/route.ts create mode 100644 src/app/api/documents/inbound/usage/route.ts diff --git a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts index 980fa8391..84520733b 100644 --- a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts @@ -104,6 +104,7 @@ describe("GET /api/documents/inbound/[id]/original", () => { id: "doc-1", filename: "report.pdf", mimeType: "application/pdf", + contentCodec: "base64v1", contentEncrypted: encryptDocumentToBytes(PDF_BYTES), } as never); @@ -130,6 +131,7 @@ describe("GET /api/documents/inbound/[id]/original", () => { id: "doc-2", filename: null, mimeType: "image/png", + contentCodec: "base64v1", contentEncrypted: encryptDocumentToBytes(Buffer.from("png bytes")), } as never); @@ -147,6 +149,7 @@ describe("GET /api/documents/inbound/[id]/original", () => { id: "doc-4", filename: "Müller-Bericht.pdf", mimeType: "application/pdf", + contentCodec: "base64v1", contentEncrypted: encryptDocumentToBytes(PDF_BYTES), } as never); @@ -176,6 +179,7 @@ describe("GET /api/documents/inbound/[id]/original", () => { id: "doc-3", filename: "report.pdf", mimeType: "application/pdf", + contentCodec: "base64v1", contentEncrypted: encryptDocumentToBytes(PDF_BYTES), } as never); decryptMock.mockImplementation(() => { @@ -189,3 +193,66 @@ describe("GET /api/documents/inbound/[id]/original", () => { expect(body.error).toBeTruthy(); }); }); + +describe("serving-posture header matrix (Class A inline vs Class B attachment)", () => { + function seed(mimeType: string, filename: string) { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-m", + filename, + mimeType, + contentCodec: "base64v1", + contentEncrypted: encryptDocumentToBytes(Buffer.from("payload")), + } as never); + } + + it.each([ + ["application/pdf", "scan.pdf"], + ["image/jpeg", "photo.jpg"], + ["image/png", "scan.png"], + ["image/webp", "scan.webp"], + ["image/gif", "anim.gif"], + ])( + "Class A %s → inline, true type, nosniff, CSP sandbox", + async (mime, name) => { + seed(mime, name); + const res = await callGet(makeReq("doc-m"), ctx("doc-m")); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe(mime); + expect(res.headers.get("Content-Disposition")).toContain("inline"); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(res.headers.get("Content-Security-Policy")).toBe("sandbox"); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }, + ); + + it.each([ + [ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "letter.docx", + ], + ["application/msword", "letter.doc"], + ["text/plain", "befund.txt"], + ["text/csv", "werte.csv"], + ["application/rtf", "note.rtf"], + ["image/tiff", "scan.tif"], + ["image/heic", "photo.heic"], + ["application/xml", "export.xml"], + ["application/json", "export.json"], + ])( + "Class B %s → attachment + octet-stream, never inline", + async (mime, name) => { + seed(mime, name); + const res = await callGet(makeReq("doc-m"), ctx("doc-m")); + expect(res.status).toBe(200); + // The true stored type is deliberately NOT surfaced on the wire. + expect(res.headers.get("Content-Type")).toBe("application/octet-stream"); + const disposition = res.headers.get("Content-Disposition") ?? ""; + expect(disposition.startsWith("attachment;")).toBe(true); + expect(disposition).toContain(name); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + // No render context → no sandbox header needed on a pure download. + expect(res.headers.get("Content-Security-Policy")).toBeNull(); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }, + ); +}); diff --git a/src/app/api/documents/inbound/[id]/original/route.ts b/src/app/api/documents/inbound/[id]/original/route.ts index 97366a357..2acf5e2b4 100644 --- a/src/app/api/documents/inbound/[id]/original/route.ts +++ b/src/app/api/documents/inbound/[id]/original/route.ts @@ -1,23 +1,31 @@ /** - * v1.25.1 (W-DOCS-IN) — view / download the original uploaded document. + * Document vault: view / download the original uploaded document. * - * The raw doctor report / discharge letter is stored encrypted at rest - * (`InboundDocument.contentEncrypted`, AES-256-GCM). This route is the ONLY - * path that decrypts and serves it, so the user can re-read the source the - * extracted facts came from. Owner-scoped + module-gated; the bytes are PHI: - * never logged, never served cross-user, fail-closed on a decrypt error. + * The raw document is stored encrypted at rest + * (`InboundDocument.contentEncrypted`, AES-256-GCM, codec per row). This + * route is the ONLY path that decrypts and serves it. Owner-scoped + + * module-gated; the bytes are PHI: never logged, never served cross-user, + * fail-closed on a decrypt error. * - * The response carries the stored `mimeType`. Uploads are constrained at the - * store path to the inline-safe set (JPEG / PNG / WebP / PDF, magic-byte - * sniffed), so the document is always served `Content-Disposition: inline` for - * in-tab rendering. The filename is sanitised before it reaches the header. + * Serving posture IS the security boundary (upload policy §N1): the serving + * class derives from the stored MIME type via `servingClassFor` — + * + * - inline (Class A: PDF/JPEG/PNG/WebP/GIF): true Content-Type, + * `Content-Disposition: inline`, `X-Content-Type-Options: nosniff`, + * `Cache-Control: private, no-store`, plus `Content-Security-Policy: + * sandbox` as defence-in-depth for the PDF viewer context. + * - attachment (Class B: Office/text/TIFF/HEIC/XML/JSON): ALWAYS + * `Content-Disposition: attachment` with `Content-Type: + * application/octet-stream` + `nosniff` — never inline, regardless of + * the stored type, so a misclassified file cannot execute in-origin. */ import { NextResponse } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; -import { decryptDocumentFromBytes } from "@/lib/documents/store"; +import { decryptDocumentContent } from "@/lib/documents/store"; +import { servingClassFor } from "@/lib/documents/upload-policy"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -40,6 +48,22 @@ const MIME_EXTENSION: Record = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", + "image/gif": "gif", + "image/tiff": "tif", + "image/heic": "heic", + "application/msword": "doc", + "application/vnd.ms-excel": "xls", + "application/vnd.ms-powerpoint": "ppt", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + "docx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": + "pptx", + "text/plain": "txt", + "text/csv": "csv", + "application/rtf": "rtf", + "application/xml": "xml", + "application/json": "json", }; /** @@ -109,6 +133,7 @@ export const GET = apiHandler( filename: true, mimeType: true, contentEncrypted: true, + contentCodec: true, }, }); if (!document) { @@ -119,10 +144,14 @@ export const GET = apiHandler( let bytes: Buffer; try { - bytes = decryptDocumentFromBytes(document.contentEncrypted); + bytes = decryptDocumentContent( + document.contentEncrypted, + document.contentCodec, + ); } catch { // Fail closed — never fall back to the raw ciphertext. The error reason - // (bad / missing key id) is logged by the annotation, never the bytes. + // (bad / missing key id, unknown codec) is logged by the annotation, + // never the bytes. annotate({ action: { name: "documents.inbound.original.decryptFailed" }, meta: { documentId: document.id }, @@ -132,16 +161,18 @@ export const GET = apiHandler( }); } + const servingClass = servingClassFor(document.mimeType); const downloadName = safeDownloadName( document.filename, document.id, document.mimeType, ); - // ASCII fallback for the bare `filename=` (replace any non-ASCII byte) plus - // an RFC 5987 `filename*` that carries the real UTF-8 name to compliant - // clients. The stored set is inline-safe by construction, so always inline. + // ASCII fallback for the bare `filename=` (replace any non-ASCII byte) + // plus an RFC 5987 `filename*` that carries the real UTF-8 name to + // compliant clients. const asciiName = downloadName.replace(/[^ -~]/g, "_"); - const disposition = `inline; filename="${asciiName}"; filename*=UTF-8''${encodeRfc5987(downloadName)}`; + const dispositionType = servingClass === "inline" ? "inline" : "attachment"; + const disposition = `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodeRfc5987(downloadName)}`; annotate({ action: { name: "documents.inbound.original.get" }, @@ -149,19 +180,33 @@ export const GET = apiHandler( documentId: document.id, mimeType: document.mimeType, bytes: bytes.byteLength, - disposition: "inline", + disposition: dispositionType, }, }); + const headers: Record = { + // Class B leaves the origin ONLY as an opaque download — the true + // stored type is deliberately not surfaced on the wire. + "Content-Type": + servingClass === "inline" + ? document.mimeType + : "application/octet-stream", + "Content-Length": String(bytes.byteLength), + "Content-Disposition": disposition, + // The disposition/type pair is authoritative — never MIME-sniffed. + "X-Content-Type-Options": "nosniff", + // Private PHI — never cache in a shared / disk cache. + "Cache-Control": "private, no-store", + }; + if (servingClass === "inline") { + // Defence-in-depth for the in-origin render context (notably the PDF + // viewer): no scripts, no forms, no top-navigation from the response. + headers["Content-Security-Policy"] = "sandbox"; + } + return new NextResponse(bytes as unknown as BodyInit, { status: 200, - headers: { - "Content-Type": document.mimeType, - "Content-Length": String(bytes.byteLength), - "Content-Disposition": disposition, - // Private PHI — never cache in a shared / disk cache. - "Cache-Control": "private, no-store", - }, + headers, }); }, ); diff --git a/src/app/api/documents/inbound/[id]/restore/route.ts b/src/app/api/documents/inbound/[id]/restore/route.ts new file mode 100644 index 000000000..1d43a8c60 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/restore/route.ts @@ -0,0 +1,155 @@ +/** + * Document vault: restore a soft-deleted (tombstoned) document. + * + * Delete is undo-able: the DELETE route tombstones (`deletedAt`), the UI + * shows an "Undo" toast that calls this route, and the daily purge job + * physically removes tombstones older than the 30-day grace window. This + * route clears the tombstone; it answers 409 when the row is gone (purged — + * indistinguishable from never-existed by construction, and owner-scoped + * either way) or when restoring would collide with a live duplicate of the + * same bytes (the `inbound_documents_user_sha_live` partial unique index). + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { loadConditionLinks } from "@/lib/documents/links"; +import { serialiseDocumentDetail } from "@/lib/documents/store"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { isP2002 } from "@/lib/prisma-errors"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import type { InboundDocumentStatus } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const dynamic = "force-dynamic"; + +/** Shares the write posture of the other owner-scoped document mutations. */ +const WRITE_LIMIT_PER_HOUR = 240; +const WRITE_WINDOW_MS = 60 * 60 * 1000; + +/** + * Post-restore lifecycle status, derived from the staged facts: no facts → + * STORED, any PENDING → EXTRACTED, otherwise every fact was acted on → + * CONFIRMED. (DELETE stamped DISCARDED; the prior status is not persisted.) + */ +function restoredStatus( + facts: Array<{ status: string }>, +): InboundDocumentStatus { + if (facts.length === 0) return "STORED"; + return facts.some((f) => f.status === "PENDING") ? "EXTRACTED" : "CONFIRMED"; +} + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `documents-write:${user.id}`, + WRITE_LIMIT_PER_HOUR, + WRITE_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { id } = await params; + const row = await prisma.inboundDocument.findFirst({ + where: { id, userId: user.id }, + omit: { contentEncrypted: true }, + include: { facts: { orderBy: { createdAt: "asc" } } }, + }); + + if (!row) { + // Purged (past the 30-day grace) or never existed — the tombstone is + // gone either way, so the undo window is over: 409, not 404. + return apiError("This document can no longer be restored.", 409, { + errorCode: "documents.inbound.restoreGone", + reason: "purged", + }); + } + + if (row.deletedAt === null) { + // Already live — restoring twice is a no-op, not an error (the undo + // toast may fire after a concurrent restore). + const links = await loadConditionLinks(user.id, [row.id]); + return apiSuccess( + serialiseDocumentDetail(row, row.facts, links.get(row.id) ?? []), + ); + } + + // A live duplicate of the same bytes blocks the restore (the partial + // unique index would reject it; check first for a clean 409). + if (row.contentSha256) { + const conflict = await prisma.inboundDocument.findFirst({ + where: { + userId: user.id, + contentSha256: row.contentSha256, + deletedAt: null, + id: { not: row.id }, + }, + select: { id: true }, + }); + if (conflict) { + return apiError("An identical document is already stored.", 409, { + errorCode: "documents.inbound.restoreDuplicate", + reason: "duplicateExists", + existingId: conflict.id, + }); + } + } + + try { + await prisma.inboundDocument.update({ + where: { id: row.id }, + data: { deletedAt: null, status: restoredStatus(row.facts) }, + }); + } catch (err) { + if (isP2002(err)) { + // Raced a concurrent identical upload into the partial unique index. + return apiError("An identical document is already stored.", 409, { + errorCode: "documents.inbound.restoreDuplicate", + reason: "duplicateExists", + }); + } + throw err; + } + + const restored = await prisma.inboundDocument.findFirstOrThrow({ + where: { id: row.id }, + omit: { contentEncrypted: true }, + include: { facts: { orderBy: { createdAt: "asc" } } }, + }); + const links = await loadConditionLinks(user.id, [restored.id]); + + await auditLog("documents.inbound.restore", { + userId: user.id, + ipAddress: getClientIp(request), + details: { documentId: restored.id }, + }); + + annotate({ + action: { name: "documents.vault.restore" }, + meta: { documentId: restored.id }, + }); + + return apiSuccess( + serialiseDocumentDetail( + restored, + restored.facts, + links.get(restored.id) ?? [], + ), + ); + }, +); diff --git a/src/app/api/documents/inbound/bulk/route.ts b/src/app/api/documents/inbound/bulk/route.ts new file mode 100644 index 000000000..0a9fe10b9 --- /dev/null +++ b/src/app/api/documents/inbound/bulk/route.ts @@ -0,0 +1,203 @@ +/** + * Document vault: bulk actions over up to 100 owner-scoped documents. + * + * `{ ids[], action, kind?, episodeId? }` → a per-id result array. Actions: + * `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` (condition + * links), `delete` (tombstone, undo-able), `restore` (clear tombstone). A + * partial failure never aborts the batch — each id reports `ok` or a short + * machine reason. Every id is narrowed to the caller; a foreign id reads as + * "notFound", exactly like the single-document routes. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { isP2002 } from "@/lib/prisma-errors"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + documentBulkSchema, + type DocumentBulkResultDto, +} from "@/lib/validations/inbound-documents"; + +export const dynamic = "force-dynamic"; + +/** + * One bulk call covers up to 100 rows, so the bucket is tighter than the + * single-row write bucket (240/h) while allowing the same overall throughput. + */ +const BULK_LIMIT_PER_HOUR = 60; +const BULK_WINDOW_MS = 60 * 60 * 1000; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `documents-bulk:${user.id}`, + BULK_LIMIT_PER_HOUR, + BULK_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = documentBulkSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + const { ids, action, kind, episodeId } = parsed.data; + const uniqueIds = [...new Set(ids)]; + + // Episode actions: the target episode must be a LIVE episode of the caller. + if (action === "linkEpisode" || action === "unlinkEpisode") { + const episode = await prisma.illnessEpisode.findFirst({ + where: { id: episodeId!, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!episode) { + return apiError("Episode not found", 404, { + errorCode: "documents.inbound.episodeNotFound", + }); + } + } + + // One owner-scoped sweep resolves every id's current state; per-id work + // then never touches a row the caller does not own. + const owned = await prisma.inboundDocument.findMany({ + where: { id: { in: uniqueIds }, userId: user.id }, + select: { id: true, deletedAt: true, contentSha256: true }, + }); + const byId = new Map(owned.map((d) => [d.id, d])); + + const results: DocumentBulkResultDto[] = []; + for (const id of uniqueIds) { + const row = byId.get(id); + if (!row) { + results.push({ id, ok: false, error: "notFound" }); + continue; + } + try { + switch (action) { + case "setKind": { + if (row.deletedAt !== null) { + results.push({ id, ok: false, error: "notFound" }); + continue; + } + await prisma.inboundDocument.update({ + where: { id }, + data: { kind: kind! }, + }); + break; + } + case "linkEpisode": { + if (row.deletedAt !== null) { + results.push({ id, ok: false, error: "notFound" }); + continue; + } + await prisma.documentConditionLink.createMany({ + data: [{ documentId: id, episodeId: episodeId!, userId: user.id }], + skipDuplicates: true, + }); + break; + } + case "unlinkEpisode": { + await prisma.documentConditionLink.deleteMany({ + where: { documentId: id, episodeId: episodeId!, userId: user.id }, + }); + break; + } + case "delete": { + if (row.deletedAt !== null) { + // Already tombstoned — deleting twice is a no-op success. + break; + } + await prisma.inboundDocument.update({ + where: { id }, + data: { deletedAt: new Date(), status: "DISCARDED" }, + }); + break; + } + case "restore": { + if (row.deletedAt === null) { + // Already live — restoring twice is a no-op success. + break; + } + if (row.contentSha256) { + const conflict = await prisma.inboundDocument.findFirst({ + where: { + userId: user.id, + contentSha256: row.contentSha256, + deletedAt: null, + id: { not: id }, + }, + select: { id: true }, + }); + if (conflict) { + results.push({ id, ok: false, error: "conflict" }); + continue; + } + } + const facts = await prisma.extractedFact.findMany({ + where: { documentId: id, userId: user.id }, + select: { status: true }, + }); + const status = + facts.length === 0 + ? "STORED" + : facts.some((f) => f.status === "PENDING") + ? "EXTRACTED" + : "CONFIRMED"; + await prisma.inboundDocument.update({ + where: { id }, + data: { deletedAt: null, status }, + }); + break; + } + } + results.push({ id, ok: true, error: null }); + } catch (err) { + if (isP2002(err)) { + results.push({ id, ok: false, error: "conflict" }); + continue; + } + throw err; + } + } + + const okCount = results.filter((r) => r.ok).length; + + await auditLog("documents.inbound.bulk", { + userId: user.id, + ipAddress: getClientIp(request), + details: { action, requested: uniqueIds.length, succeeded: okCount }, + }); + + annotate({ + action: { name: "documents.vault.bulk" }, + meta: { bulkAction: action, requested: uniqueIds.length, ok: okCount }, + }); + + return apiSuccess({ results }); +}); diff --git a/src/app/api/documents/inbound/usage/route.ts b/src/app/api/documents/inbound/usage/route.ts new file mode 100644 index 000000000..bdaf44be9 --- /dev/null +++ b/src/app/api/documents/inbound/usage/route.ts @@ -0,0 +1,50 @@ +/** + * Document vault: storage usage + effective limits for the calling user. + * + * The UI reads this before offering an upload (quota bar above 80 % usage, + * client-side pre-flight against `maxFileBytes`, picker `accept` list from + * `acceptedExtensions`). `usedBytes` counts every non-purged row — tombstones + * still hold TOAST bytes until the purge job reclaims them, so "deleted" + * bytes are never invisible weight and an undo never changes usage. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { + DOCUMENT_ACCEPTED_EXTENSIONS, + resolveDocumentLimits, +} from "@/lib/documents/upload-policy"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import type { DocumentUsageDto } from "@/lib/validations/inbound-documents"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const [limits, rows] = await Promise.all([ + resolveDocumentLimits(user.id), + prisma.$queryRaw>` + SELECT COALESCE(SUM(byte_size), 0)::bigint AS used + FROM inbound_documents + WHERE user_id = ${user.id} + `, + ]); + const usedBytes = Number(rows[0]?.used ?? 0); + + annotate({ + action: { name: "documents.vault.usage" }, + meta: { usedBytes, quotaBytes: limits.quotaBytes }, + }); + + const payload: DocumentUsageDto = { + usedBytes, + quotaBytes: limits.quotaBytes, + maxFileBytes: limits.maxFileBytes, + acceptedExtensions: [...DOCUMENT_ACCEPTED_EXTENSIONS], + }; + return apiSuccess(payload); +}); From 972c5fbf482a3197acd2aad6ed954ac4d646ef50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:05:43 +0200 Subject: [PATCH 05/34] feat(crypto): rotate the document blob column in bounded, codec-aware batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry entry for InboundDocument.contentEncrypted gains a codecField marker: the column now holds two layouts (base64v1 string codec | binary2 binary codec), recorded per row, and its rows are up to cap-sized blobs. scan/rotate walk such a column with id-cursor pagination (25 rows per page — never an unbounded blob findMany) and re-encrypt each row under its OWN codec; an unknown codec value is counted as an error and the row left untouched, same fail-closed posture as a missing key. The walk is idempotent, so an interrupted run resumes on re-invocation with the already-active rows skipped. Both rotation paths share the logic: the admin-triggered pg-boss job via rotateCorpus() and the CLI (scripts/rotate-encryption-key.ts), which now calls the shared batched walk for this column. The registry guard test keeps schema, registry, and script in lock-step as before. Unit coverage over a mocked 500-row corpus asserts the batch boundedness (take + advancing cursor per page), both-codec rotation, per-row fail-closed on unknown codecs, and interrupted-run resume. Task: W1-T11. --- scripts/rotate-encryption-key.ts | 40 ++- .../__tests__/encryption-corpus-blob.test.ts | 276 ++++++++++++++++++ src/lib/crypto/encrypted-columns.ts | 27 +- src/lib/crypto/encryption-corpus.ts | 153 +++++++++- 4 files changed, 466 insertions(+), 30 deletions(-) create mode 100644 src/lib/crypto/__tests__/encryption-corpus-blob.test.ts diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 047dd0ec3..ebad6121a 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -24,6 +24,10 @@ import { Buffer } from "node:buffer"; import { PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto"; +import { + rotateColumn, + type CorpusClient, +} from "@/lib/crypto/encryption-corpus"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { @@ -484,17 +488,31 @@ 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, - ), - ); + // ───── Inbound clinical document (Bytes column, codec-dispatched) ───── + // The raw uploaded document. Two layouts recorded per row in + // `contentCodec` ("base64v1" string codec | "binary2" binary codec), so + // rotation goes through the shared codec-aware corpus walk: bounded + // id-cursor batches (rows are up to cap-sized blobs — never an unbounded + // findMany) re-encrypted under each row's OWN codec. Idempotent, so an + // interrupted run resumes on re-invocation. + { + const docResult = await rotateColumn( + { inboundDocument: prisma.inboundDocument } as unknown as CorpusClient, + { + model: "InboundDocument", + field: "contentEncrypted", + kind: "bytes", + codecField: "contentCodec", + }, + ); + results.push({ + table: docResult.model, + field: docResult.field, + scanned: docResult.scanned, + rotated: docResult.rotated, + errors: docResult.errors, + }); + } // The staged extracted-fact payloads: the FHIR-staged clinical values and the // verbatim source-span provenance. Both NOT NULL, so every staged row rotates. results.push( diff --git a/src/lib/crypto/__tests__/encryption-corpus-blob.test.ts b/src/lib/crypto/__tests__/encryption-corpus-blob.test.ts new file mode 100644 index 000000000..57cb3820e --- /dev/null +++ b/src/lib/crypto/__tests__/encryption-corpus-blob.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Buffer } from "node:buffer"; +import { + encrypt, + encryptBytes, + extractKeyIdFromBytes, + getActiveKeyId, + _resetCryptoCacheForTests, +} from "@/lib/crypto"; +import { + rotateColumn, + scanColumn, + BLOB_ROTATION_BATCH_SIZE, + type CorpusClient, +} from "@/lib/crypto/encryption-corpus"; +import type { EncryptedColumn } from "@/lib/crypto/encrypted-columns"; + +/** + * Codec-dispatched blob rotation (the document vault's `contentEncrypted`): + * the walk is id-cursor paginated with a bounded batch size — never an + * unbounded blob `findMany` — re-encrypts each row under its OWN codec, + * fails closed per row on an unknown codec, and resumes cleanly after an + * interrupted run because already-active rows are skipped. + */ + +const KEY_V1 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const KEY_V2 = + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + +const DOC_COLUMN: EncryptedColumn = { + model: "InboundDocument", + field: "contentEncrypted", + kind: "bytes", + codecField: "contentCodec", +}; + +interface DocRow { + id: string; + contentEncrypted: Uint8Array; + contentCodec: string; +} + +interface FindManyArgs { + select: Record; + orderBy?: Record; + take?: number; + cursor?: Record; + skip?: number; +} + +/** + * Pagination-honouring fake delegate: applies orderBy(id) + cursor + skip + + * take exactly like Prisma keyset pagination, and records every findMany + * call so the test can assert the batch boundedness. + */ +function makeDocClient(rows: DocRow[]): { + client: CorpusClient; + store: DocRow[]; + findManyCalls: FindManyArgs[]; + failUpdatesFor: Set; +} { + const store = [...rows]; + const findManyCalls: FindManyArgs[] = []; + const failUpdatesFor = new Set(); + const client = { + inboundDocument: { + findMany: async (args: FindManyArgs) => { + findManyCalls.push(args); + let list = [...store].sort((a, b) => a.id.localeCompare(b.id)); + if (args.cursor) { + const idx = list.findIndex((r) => r.id === args.cursor!.id); + list = list.slice(idx + (args.skip ?? 0)); + } + if (args.take !== undefined) list = list.slice(0, args.take); + return list.map((row) => { + const out: Record = {}; + for (const f of Object.keys(args.select)) { + out[f] = row[f as keyof DocRow]; + } + return out; + }); + }, + update: async ({ + where, + data, + }: { + where: { id: string }; + data: Record; + }) => { + if (failUpdatesFor.has(where.id)) { + throw new Error("simulated interruption"); + } + const row = store.find((r) => r.id === where.id); + if (row) Object.assign(row, data); + return row; + }, + }, + } as unknown as CorpusClient; + return { client, store, findManyCalls, failUpdatesFor }; +} + +function binaryUnder(keyId: "v1" | "v2", plaintext: Buffer): Uint8Array { + process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId; + _resetCryptoCacheForTests(); + const out = encryptBytes(plaintext); + process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2"; + _resetCryptoCacheForTests(); + return new Uint8Array(out); +} + +function legacyUnder(keyId: "v1" | "v2", plaintext: string): Uint8Array { + process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId; + _resetCryptoCacheForTests(); + const ct = encrypt(Buffer.from(plaintext).toString("base64")); + process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2"; + _resetCryptoCacheForTests(); + return new Uint8Array(Buffer.from(ct, "utf8")); +} + +function pad(i: number): string { + return `doc-${String(i).padStart(5, "0")}`; +} + +beforeEach(() => { + process.env.ENCRYPTION_KEYS = JSON.stringify({ v1: KEY_V1, v2: KEY_V2 }); + process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2"; + delete process.env.ENCRYPTION_KEY; + _resetCryptoCacheForTests(); +}); + +describe("codec-dispatched blob rotation (bounded batches)", () => { + it("walks a 500-row corpus in bounded id-cursor batches and rotates both codecs", async () => { + const rows: DocRow[] = []; + // 200 binary2 under the stale v1, 150 legacy base64v1 under v1, + // 100 binary2 already on v2 (skipped), 50 legacy already on v2 (skipped). + for (let i = 0; i < 200; i++) { + rows.push({ + id: pad(i), + contentEncrypted: binaryUnder("v1", Buffer.from(`bin-${i}`)), + contentCodec: "binary2", + }); + } + for (let i = 200; i < 350; i++) { + rows.push({ + id: pad(i), + contentEncrypted: legacyUnder("v1", `legacy-${i}`), + contentCodec: "base64v1", + }); + } + for (let i = 350; i < 450; i++) { + rows.push({ + id: pad(i), + contentEncrypted: binaryUnder("v2", Buffer.from(`bin-${i}`)), + contentCodec: "binary2", + }); + } + for (let i = 450; i < 500; i++) { + rows.push({ + id: pad(i), + contentEncrypted: legacyUnder("v2", `legacy-${i}`), + contentCodec: "base64v1", + }); + } + + const { client, store, findManyCalls } = makeDocClient(rows); + const result = await rotateColumn(client, DOC_COLUMN); + + expect(result.scanned).toBe(500); + expect(result.rotated).toBe(350); + expect(result.errors).toBe(0); + + // BOUNDED: every page requested at most the batch size, and the walk + // needed ceil(500 / batch) full pages (+1 terminating empty page, since + // 500 is an exact multiple) — never one unbounded findMany. + expect(findManyCalls.length).toBe( + Math.ceil(500 / BLOB_ROTATION_BATCH_SIZE) + 1, + ); + for (const call of findManyCalls) { + expect(call.take).toBe(BLOB_ROTATION_BATCH_SIZE); + expect(call.orderBy).toEqual({ id: "asc" }); + } + // Cursor advanced between pages. + expect(findManyCalls[0].cursor).toBeUndefined(); + expect(findManyCalls[1].cursor).toEqual({ id: pad(24) }); + + // Every row now reads under the active key, each in its OWN codec. + for (const row of store) { + if (row.contentCodec === "binary2") { + expect(extractKeyIdFromBytes(Buffer.from(row.contentEncrypted))).toBe( + getActiveKeyId(), + ); + } else { + expect( + Buffer.from(row.contentEncrypted).toString("utf8").startsWith("v2."), + ).toBe(true); + } + } + + // IDEMPOTENT: the second pass rotates nothing. + const second = await rotateColumn(client, DOC_COLUMN); + expect(second.rotated).toBe(0); + expect(second.errors).toBe(0); + }); + + it("resumes after an interrupted run (already-rotated rows are skipped)", async () => { + const rows: DocRow[] = []; + for (let i = 0; i < 60; i++) { + rows.push({ + id: pad(i), + contentEncrypted: binaryUnder("v1", Buffer.from(`bin-${i}`)), + contentCodec: "binary2", + }); + } + const { client, failUpdatesFor } = makeDocClient(rows); + + // First run: the last 20 updates fail (simulated interruption) — they + // are counted, never dropped. + for (let i = 40; i < 60; i++) failUpdatesFor.add(pad(i)); + const first = await rotateColumn(client, DOC_COLUMN); + expect(first.rotated).toBe(40); + expect(first.errors).toBe(20); + + // Second run with the interruption cleared: exactly the remainder + // rotates — the 40 already-active rows are skipped. + failUpdatesFor.clear(); + const second = await rotateColumn(client, DOC_COLUMN); + expect(second.rotated).toBe(20); + expect(second.errors).toBe(0); + }); + + it("fails closed per row on an unknown codec and leaves the row untouched", async () => { + const strange = new Uint8Array(Buffer.from("???strange???")); + const { client, store } = makeDocClient([ + { + id: pad(0), + contentEncrypted: binaryUnder("v1", Buffer.from("fine")), + contentCodec: "binary2", + }, + { id: pad(1), contentEncrypted: strange, contentCodec: "codec9" }, + ]); + + const result = await rotateColumn(client, DOC_COLUMN); + expect(result.rotated).toBe(1); + expect(result.errors).toBe(1); + expect(store.find((r) => r.id === pad(1))!.contentEncrypted).toBe(strange); + }); + + it("scanColumn buckets blob rows per codec-aware key id in bounded batches", async () => { + const { client, findManyCalls } = makeDocClient([ + { + id: pad(0), + contentEncrypted: binaryUnder("v1", Buffer.from("a")), + contentCodec: "binary2", + }, + { + id: pad(1), + contentEncrypted: legacyUnder("v1", "b"), + contentCodec: "base64v1", + }, + { + id: pad(2), + contentEncrypted: binaryUnder("v2", Buffer.from("c")), + contentCodec: "binary2", + }, + ]); + const scan = await scanColumn(client, DOC_COLUMN); + expect(scan.total).toBe(3); + expect(scan.byKeyId.v1).toBe(2); + expect(scan.byKeyId.v2).toBe(1); + expect(scan.legacy).toBe(0); + for (const call of findManyCalls) { + expect(call.take).toBe(BLOB_ROTATION_BATCH_SIZE); + } + }); +}); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 69a72866c..1bc6d6760 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -34,6 +34,15 @@ export interface EncryptedColumn { /** Prisma field name (camelCase, as declared in schema.prisma). */ readonly field: string; readonly kind: EncryptedColumnKind; + /** + * Present when the column's ciphertext layout is dispatched by a sibling + * codec column (the document vault's `contentCodec`: "base64v1" = the + * `encrypt()`-string-as-UTF-8 shape every other Bytes column uses, + * "binary2" = the binary `encryptBytes()` layout). A codec-dispatched + * column also rotates in bounded id-cursor batches — its rows are + * multi-megabyte blobs, so an unbounded `findMany` would balloon memory. + */ + readonly codecField?: string; } /** @@ -179,11 +188,19 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { 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" }, + // ───── Inbound clinical document (Bytes column, codec-dispatched) ───── + // The raw uploaded document (the most sensitive blob in the corpus; never + // logged, never in wide-event meta). Two layouts, recorded per row in + // `contentCodec`: "base64v1" (pre-vault rows — base64-of-binary → + // AES-256-GCM string → UTF-8 bytes) and "binary2" (vault uploads — the + // binary `encryptBytes()` layout). Rotation walks this column in bounded + // id-cursor batches and re-encrypts under the row's own codec. + { + model: "InboundDocument", + field: "contentEncrypted", + kind: "bytes", + codecField: "contentCodec", + }, // The staged extracted-fact payloads: the FHIR-staged clinical values // (diagnosis text / lab values / medication names / stated codes) and the // verbatim source-span provenance. Both are PHI transcribed from the source diff --git a/src/lib/crypto/encryption-corpus.ts b/src/lib/crypto/encryption-corpus.ts index f8574a30f..4230770eb 100644 --- a/src/lib/crypto/encryption-corpus.ts +++ b/src/lib/crypto/encryption-corpus.ts @@ -30,7 +30,14 @@ * protects against dropping a legacy key too early. */ import { Buffer } from "node:buffer"; -import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto"; +import { + decrypt, + encrypt, + extractKeyId, + extractKeyIdFromBytes, + getActiveKeyId, + reencryptBytesToActive, +} from "@/lib/crypto"; import { ENCRYPTED_COLUMNS, encryptedColumnKey, @@ -40,10 +47,21 @@ import { /** Sentinel bucket for legacy (unversioned) ciphertext under `byKeyId`. */ export const LEGACY_BUCKET = "legacy"; +/** + * Batch size for codec-dispatched blob columns (`codecField` present). The + * document vault's rows are up to cap-sized ciphertexts, so the walk is + * id-cursor paginated — at most this many blobs are in memory at once. + */ +export const BLOB_ROTATION_BATCH_SIZE = 25; + /** Minimal Prisma delegate shape this module needs. */ interface ColumnDelegate { findMany: (args: { select: Record; + orderBy?: Record; + take?: number; + cursor?: Record; + skip?: number; }) => Promise>>; update: (args: { where: { id: string }; @@ -98,6 +116,78 @@ function shouldRotate(ciphertext: string): boolean { return extractKeyId(ciphertext) !== getActiveKeyId(); } +// ─── Codec-dispatched blob columns (document vault) ───────────────────────── + +/** + * The key id a codec-dispatched blob row was written under, or null when + * legacy/unparsable. "binary2" parses the binary header; every other codec + * value (notably "base64v1") is the `encrypt()`-string-as-UTF-8 shape. + */ +function blobKeyId(value: Uint8Array, codec: string): string | null { + const buf = Buffer.from(value); + if (codec === "binary2") return extractKeyIdFromBytes(buf); + return extractKeyId(buf.toString("utf8")); +} + +/** Re-encrypt one codec-dispatched blob under its OWN codec (never converts). */ +function reencryptBlob(value: Uint8Array, codec: string): Uint8Array { + const buf = Buffer.from(value); + if (codec === "binary2") { + const rotated = reencryptBytesToActive(buf); + const next = new Uint8Array(new ArrayBuffer(rotated.byteLength)); + next.set(rotated); + return next; + } + if (codec === "base64v1") { + const rotated = encrypt(decrypt(buf.toString("utf8"))); + const encoded = Buffer.from(rotated, "utf8"); + const next = new Uint8Array(new ArrayBuffer(encoded.byteLength)); + next.set(encoded); + return next; + } + // FAIL-CLOSED: an unknown codec is counted as an error and left untouched. + throw new Error(`Unknown content codec '${codec}'`); +} + +/** + * Walk a codec-dispatched blob column in bounded id-cursor batches, invoking + * `onRow` per non-empty row. At most `BLOB_ROTATION_BATCH_SIZE` blobs are in + * memory per step; an interrupted run resumes safely on re-invocation because + * processing is idempotent (already-active rows are skipped by the callers). + */ +async function walkBlobColumn( + delegate: ColumnDelegate, + col: EncryptedColumn, + onRow: (row: { + id: string; + value: Uint8Array; + codec: string; + }) => Promise | void, +): Promise { + const codecField = col.codecField!; + let cursor: string | null = null; + for (;;) { + const rows = await delegate.findMany({ + select: { id: true, [col.field]: true, [codecField]: true }, + orderBy: { id: "asc" }, + take: BLOB_ROTATION_BATCH_SIZE, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + if (rows.length === 0) break; + for (const row of rows) { + const value = row[col.field] as Uint8Array | null; + if (!value || value.byteLength === 0) continue; + await onRow({ + id: row.id as string, + value, + codec: String(row[codecField] ?? ""), + }); + } + cursor = rows[rows.length - 1]!.id as string; + if (rows.length < BLOB_ROTATION_BATCH_SIZE) break; + } +} + export interface ColumnScan { model: string; field: string; @@ -116,17 +206,27 @@ export async function scanColumn( col: EncryptedColumn, ): Promise { const delegate = getDelegate(client, col.model); - const rows = await delegate.findMany({ - select: { id: true, [col.field]: true }, - }); const byKeyId: Record = {}; let total = 0; - for (const row of rows) { - const ciphertext = toCiphertext(row[col.field], col.kind); - if (ciphertext == null) continue; - total += 1; - const id = extractKeyId(ciphertext) ?? LEGACY_BUCKET; - byKeyId[id] = (byKeyId[id] ?? 0) + 1; + + if (col.codecField) { + // Codec-dispatched blob column: bounded batches, per-row codec. + await walkBlobColumn(delegate, col, ({ value, codec }) => { + total += 1; + const id = blobKeyId(value, codec) ?? LEGACY_BUCKET; + byKeyId[id] = (byKeyId[id] ?? 0) + 1; + }); + } else { + const rows = await delegate.findMany({ + select: { id: true, [col.field]: true }, + }); + for (const row of rows) { + const ciphertext = toCiphertext(row[col.field], col.kind); + if (ciphertext == null) continue; + total += 1; + const id = extractKeyId(ciphertext) ?? LEGACY_BUCKET; + byKeyId[id] = (byKeyId[id] ?? 0) + 1; + } } return { model: col.model, @@ -192,16 +292,41 @@ export async function rotateColumn( col: EncryptedColumn, ): Promise { const delegate = getDelegate(client, col.model); - const rows = await delegate.findMany({ - select: { id: true, [col.field]: true }, - }); const result: RotationResult = { model: col.model, field: col.field, - scanned: rows.length, + scanned: 0, rotated: 0, errors: 0, }; + + if (col.codecField) { + // Codec-dispatched blob column: bounded id-cursor batches (never an + // unbounded blob findMany), re-encrypted under each row's OWN codec. + // Idempotent — rows already on the active key are skipped, so an + // interrupted run resumes cleanly on the next invocation. + await walkBlobColumn(delegate, col, async ({ id, value, codec }) => { + result.scanned += 1; + if (blobKeyId(value, codec) === getActiveKeyId()) return; + try { + await delegate.update({ + where: { id }, + data: { [col.field]: reencryptBlob(value, codec) }, + }); + result.rotated += 1; + } catch { + // FAIL-CLOSED: unknown codec or a no-longer-configured key throws; + // count it and leave the row untouched rather than dropping data. + result.errors += 1; + } + }); + return result; + } + + const rows = await delegate.findMany({ + select: { id: true, [col.field]: true }, + }); + result.scanned = rows.length; for (const row of rows) { const ciphertext = toCiphertext(row[col.field], col.kind); if (ciphertext == null || !shouldRotate(ciphertext)) continue; From de9e52861418a838fdf06f7c9ffaba4619a9bdaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:06:04 +0200 Subject: [PATCH 06/34] feat(documents): daily purge for tombstoned documents past the undo grace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete in the vault is undo-able: DELETE tombstones, the restore endpoint clears the tombstone, and this pg-boss job (daily 04:10 Europe/Berlin, single-flight like every other cleanup) hard-deletes tombstones older than 30 days — returning the TOAST space the encrypted blob holds. Until then a tombstoned row keeps counting against the quota, so deleted bytes are never invisible backup weight. Staged facts cascade via the FK; restore-after-purge answers 409 at the API. Task: W1-T12. --- src/lib/jobs/document-purge.ts | 58 +++++++++++++++++++ src/lib/jobs/reminder/register-maintenance.ts | 22 +++++++ 2 files changed, 80 insertions(+) create mode 100644 src/lib/jobs/document-purge.ts diff --git a/src/lib/jobs/document-purge.ts b/src/lib/jobs/document-purge.ts new file mode 100644 index 000000000..8f136be07 --- /dev/null +++ b/src/lib/jobs/document-purge.ts @@ -0,0 +1,58 @@ +/** + * Daily physical purge for soft-deleted document-vault rows. + * + * Delete in the vault is undo-able: the DELETE route tombstones + * (`deletedAt`), the UI's undo toast and the restore endpoint clear the + * tombstone, and THIS job hard-deletes tombstones older than the 30-day + * grace window — returning the TOAST space the encrypted blob holds (a + * tombstoned document keeps counting against the quota until purged, so + * "deleted" bytes are never invisible backup weight). Staged facts cascade + * via the FK. Restore-after-purge answers 409 at the API. + */ +import { type Job } from "pg-boss"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; +import type { PrismaClient } from "@/generated/prisma/client"; + +export const DOCUMENT_PURGE_QUEUE = "document-tombstone-purge"; +/** Daily at 04:10 Europe/Berlin — after the med-notes backfill tick (04:05). */ +export const DOCUMENT_PURGE_CRON = "10 4 * * *"; + +/** Days a tombstoned document stays restorable before the purge claims it. */ +export const DOCUMENT_PURGE_GRACE_DAYS = 30; + +const DAY_MS = 86_400_000; + +export interface DocumentPurgePayload { + triggeredAt?: string; +} + +/** + * Hard-delete tombstoned `inbound_documents` rows whose `deletedAt` is older + * than the grace horizon. Returns the number of rows purged. Rows inside the + * grace window and live rows are never touched. + */ +export async function purgeExpiredDocumentTombstones( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const cutoff = new Date(now.getTime() - DOCUMENT_PURGE_GRACE_DAYS * DAY_MS); + const { count } = await prisma.inboundDocument.deleteMany({ + where: { deletedAt: { not: null, lt: cutoff } }, + }); + return count; +} + +export async function handleDocumentPurge(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.document_tombstone_purge", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const purged = await purgeExpiredDocumentTombstones(prisma); + evt.setAction({ name: "documents.vault.purge" }); + evt.addMeta("document_purge_deleted", purged); + } catch (err) { + evt.addWarning(`document-tombstone-purge failed: ${err}`); + } + }); +} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index a6d355f6a..507cb20c8 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -124,6 +124,12 @@ import { handleMedicationInventoryExpire, handleIntakeAutoSkip, } from "./medication-maintenance"; +import { + DOCUMENT_PURGE_QUEUE, + DOCUMENT_PURGE_CRON, + handleDocumentPurge, + type DocumentPurgePayload, +} from "@/lib/jobs/document-purge"; const DATA_BACKUP_QUEUE = "data-backup"; @@ -287,6 +293,11 @@ const allQueues = [ // plaintext medication note; without this entry pg-boss never provisions the // queue and both the boot enqueue and the cron silently no-op. MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, + // Document vault — daily physical purge for tombstones past the 30-day + // undo grace (returns the encrypted blob's TOAST space). Without this entry + // the daily schedule silently no-ops and "deleted" documents hold backup + // weight forever. + DOCUMENT_PURGE_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -352,6 +363,9 @@ const schedules: ScheduleEntry[] = [ // fan out one per-user job per account still holding a plaintext medication // note. [MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, MED_NOTES_ENCRYPTION_BACKFILL_CRON], + // Document vault — daily 04:10 Europe/Berlin purge for tombstoned + // documents past the 30-day undo grace. + [DOCUMENT_PURGE_QUEUE, DOCUMENT_PURGE_CRON], ]; /** @@ -448,6 +462,14 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleMcpTokenCleanup, ); + // Document vault — daily purge of tombstoned documents past the 30-day + // undo grace. Single-flight like every other cleanup queue; the underlying + // deleteMany is idempotent so a duplicate tick is a no-op. + await boss.work( + DOCUMENT_PURGE_QUEUE, + { localConcurrency: 1 }, + handleDocumentPurge, + ); await boss.work( PR_DETECTION_QUEUE, { localConcurrency: PR_DETECTION_CONCURRENCY }, From 155b74ccf428251487049ec69ae364a965e3e7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:06:04 +0200 Subject: [PATCH 07/34] feat(admin): expose the two document-vault limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin settings GET/PUT carry documentMaxFileBytes (1 MiB – 100 MiB; the hard ceiling exists because single-shot GCM + bounded in-memory reads are load-bearing past it, and the upload-time resolver clamps again as defence-in-depth) and documentQuotaBytes (10 MiB – 1 TiB instance default). The admin user PUT accepts a nullable per-user quota override; null clears it back to the instance default. Cookie-only requireAdmin() guards both surfaces as before — Bearer tokens cannot reach them. Task: W1-T13. --- src/app/api/admin/settings/route.ts | 20 ++++++++++++++++++++ src/app/api/admin/users/[id]/route.ts | 26 +++++++++++++++++++++++++- src/lib/validations/admin.ts | 17 +++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 51ad16dcc..7cbb95b6c 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -51,6 +51,13 @@ export const GET = apiHandler(async () => { reminderLateMinutes: settings?.reminderLateMinutes ?? 120, reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240, moodLogGlobal: settings?.moodLogGlobal ?? true, + // Document vault limits (per-file cap + per-user quota default). BigInt + // column → Number for the JSON envelope (values are far below 2^53). + documentMaxFileBytes: settings?.documentMaxFileBytes ?? 26_214_400, + documentQuotaBytes: + settings?.documentQuotaBytes !== undefined + ? Number(settings.documentQuotaBytes) + : 1_073_741_824, // v1.4.25 W7 — null means "fall back to Europe/Berlin in the // resolver"; surfacing the raw value lets the admin UI render // an empty picker placeholder until they opt in. @@ -176,6 +183,17 @@ export const PUT = apiHandler(async (request: NextRequest) => { auditDetails.reminderMissedMinutes = data.reminderMissedMinutes; } + // Document vault limits (schema already bounds both; the upload-time + // resolver clamps the cap to the hard ceiling again as defence-in-depth). + if (data.documentMaxFileBytes !== undefined) { + updates.documentMaxFileBytes = data.documentMaxFileBytes; + auditDetails.documentMaxFileBytes = data.documentMaxFileBytes; + } + if (data.documentQuotaBytes !== undefined) { + updates.documentQuotaBytes = BigInt(data.documentQuotaBytes); + auditDetails.documentQuotaBytes = data.documentQuotaBytes; + } + // v1.4.25 W7 — server-default timezone for new signups. // Empty string clears the override (resolver falls back to // Europe/Berlin); a non-empty string must pass Intl validation @@ -240,6 +258,8 @@ export const PUT = apiHandler(async (request: NextRequest) => { reminderLateMinutes: settings.reminderLateMinutes, reminderMissedMinutes: settings.reminderMissedMinutes, moodLogGlobal: settings.moodLogGlobal, + documentMaxFileBytes: settings.documentMaxFileBytes, + documentQuotaBytes: Number(settings.documentQuotaBytes), defaultUserTimezone: settings.defaultUserTimezone, }); }); diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 7270e4b93..ea105fcc4 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -19,6 +19,16 @@ const updateUserSchema = z.object({ // v1.23 — per-user "require a second factor" override. Effective requirement // is the OR of this and the instance-wide AppSettings.mfaRequired policy. mfaEnforced: z.boolean().optional(), + // Document vault — per-user storage-quota override in bytes. Null clears + // the override (the instance default applies again). Same bounds as the + // instance-wide setting. + documentQuotaBytes: z + .number() + .int() + .min(10_485_760) + .max(1_099_511_627_776) + .nullable() + .optional(), }); export const PUT = apiHandler( @@ -70,6 +80,12 @@ export const PUT = apiHandler( ...(parsed.data.mfaEnforced !== undefined && { mfaEnforced: parsed.data.mfaEnforced, }), + ...(parsed.data.documentQuotaBytes !== undefined && { + documentQuotaBytes: + parsed.data.documentQuotaBytes === null + ? null + : BigInt(parsed.data.documentQuotaBytes), + }), }, select: { id: true, @@ -78,6 +94,7 @@ export const PUT = apiHandler( role: true, createdAt: true, mfaEnforced: true, + documentQuotaBytes: true, }, }); @@ -87,6 +104,13 @@ export const PUT = apiHandler( details: { targetUserId: id, changes: parsed.data }, }); - return apiSuccess(updatedUser); + return apiSuccess({ + ...updatedUser, + // BigInt → Number for the JSON envelope; null = no override. + documentQuotaBytes: + updatedUser.documentQuotaBytes === null + ? null + : Number(updatedUser.documentQuotaBytes), + }); }, ); diff --git a/src/lib/validations/admin.ts b/src/lib/validations/admin.ts index 441935479..889bdfe42 100644 --- a/src/lib/validations/admin.ts +++ b/src/lib/validations/admin.ts @@ -67,6 +67,23 @@ export const adminSettingsSchema = z reminderLateMinutes: z.number().int().min(15).max(480).optional(), reminderMissedMinutes: z.number().int().min(30).max(720).optional(), moodLogGlobal: z.boolean().optional(), + // Document vault — the two admin-tunable limits. The per-file cap is + // bounded by the hard 100 MiB ceiling (single-shot GCM + bounded + // in-memory reads are load-bearing past that; the runtime resolver + // clamps again as defence-in-depth). The quota accepts up to 1 TiB — + // BYTEA rides pg_dump, so anything larger is an operator error. + documentMaxFileBytes: z + .number() + .int() + .min(1_048_576) + .max(104_857_600) + .optional(), + documentQuotaBytes: z + .number() + .int() + .min(10_485_760) + .max(1_099_511_627_776) + .optional(), // v1.4.25 W7 — server-wide default timezone for new signups // that don't carry a browser-detected zone. The route validates // against Intl.supportedValuesOf at runtime; this schema just From ac88ce1a2d3a6dec00bb297c82c504aab9d757c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:06:21 +0200 Subject: [PATCH 08/34] feat(modules): re-enable the inboundDocuments module inboundDocuments leaves CODE_DISABLED_MODULE_KEYS (parked there since v1.25.3). The normal two-layer resolution applies again: the module ships dark (opt-in, per-user default off) and an operator false still disables it server-wide; nothing becomes user-visible without an explicit opt-in. The gate tests now pin the re-enabled behaviour instead of the hard-off short-circuit. Task: W1-T2. --- src/lib/modules/__tests__/gate.test.ts | 23 +++++++++++------------ src/lib/modules/registry.ts | 10 +++++----- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index 8785629eb..7065fa500 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -374,22 +374,21 @@ describe("registry — opt-in marker", () => { }); }); -describe("resolveModuleEnabled — inboundDocuments module (disabled in code)", () => { - // v1.25.3 — `inboundDocuments` is switched off in code pending a rebuild - // (`CODE_DISABLED_MODULE_KEYS`). It is hard-off ahead of both the operator - // and the per-user layer: no stored preference or availability blob can - // re-surface it. The registry entry + `optIn` marker stay intact. - it("is flagged as code-disabled in the registry", () => { - expect(isCodeDisabledModule("inboundDocuments")).toBe(true); +describe("resolveModuleEnabled — inboundDocuments module (document vault)", () => { + // Parked in code from v1.25.3; the document vault re-enabled it. Normal + // two-layer opt-in resolution applies again: dark by default, ON only on + // an explicit per-user opt-in, and an operator `false` still wins. + it("is no longer flagged as code-disabled in the registry", () => { + expect(isCodeDisabledModule("inboundDocuments")).toBe(false); }); - it("is OFF when no preference is recorded", () => { + it("is OFF when no preference is recorded (opt-in ships dark)", () => { expect( resolveModuleEnabled("inboundDocuments", inputs(), false, ALL_AVAILABLE), ).toBe(false); }); - it("stays OFF even when the user opted in (the in-code switch wins)", () => { + it("is ON when the user opted in", () => { expect( resolveModuleEnabled( "inboundDocuments", @@ -397,16 +396,16 @@ describe("resolveModuleEnabled — inboundDocuments module (disabled in code)", false, ALL_AVAILABLE, ), - ).toBe(false); + ).toBe(true); }); - it("stays OFF when the user opted in and the operator left it available", () => { + it("stays OFF when the operator disabled it, even with a user opt-in", () => { expect( resolveModuleEnabled( "inboundDocuments", inputs({ modulePreferences: { inboundDocuments: true } }), false, - operator({ inboundDocuments: true }), + operator({ inboundDocuments: false }), ), ).toBe(false); }); diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 97d422b38..a588307e7 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -338,12 +338,12 @@ export function isModuleKey(key: string): key is ModuleKey { * migration) stays intact — re-enabling the module is a one-line removal * from this list once the rebuild lands. * - * v1.25.3 — `inboundDocuments` is parked here: the inbound-documents surface - * is withdrawn from this release and returns later with more depth. Its - * registry entry, `optIn` marker, routes, view, and migration 0222 are all - * preserved; only the user-reachable switches are removed. + * Currently empty. `inboundDocuments` was parked here from v1.25.3 until the + * document vault re-enabled it (per-user opt-in + operator availability + * resume their normal two-layer resolution; the module still ships dark by + * default via `optIn`). */ -export const CODE_DISABLED_MODULE_KEYS = ["inboundDocuments"] as const; +export const CODE_DISABLED_MODULE_KEYS = [] as const; const CODE_DISABLED_MODULE_SET: ReadonlySet = new Set( CODE_DISABLED_MODULE_KEYS, From 8747c69156fa6075a1245d0b301e70361f03fe61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:06:21 +0200 Subject: [PATCH 09/34] docs(api): document-vault contract in the OpenAPI spec The documents route table picks up every delta: conditionLinks + servingClass on the document DTO (render/download by servingClass, never by MIME guess; unknown kind values decode as OTHER), the broadened upload accept-set with the 413/415 error contract and the 200+meta.duplicate dedupe outcome, episodeIds on upload and PATCH (replace-set), the kind[] / episodeId / year list facets, the class-aware /original posture, and the new /restore, /bulk, and /usage endpoints. Regenerated docs/api/openapi.yaml committed alongside; openapi:check is clean. Task: W1-T14. --- docs/api/openapi.yaml | 360 ++++++++++++++++++++++++++-- src/lib/openapi/routes/documents.ts | 193 ++++++++++++++- 2 files changed, 520 insertions(+), 33 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 138d1ae88..747976e0e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8669,20 +8669,39 @@ paths: maxLength: 100 description: Case-insensitive search over title + filename. - name: kind + in: query + required: false + style: form + explode: true + schema: + type: array + items: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - REFERRAL + - INSURANCE + - VACCINATION + - OTHER + description: Category filter — OR inside the facet. Repeat the parameter (or send a comma-separated value). + - name: episodeId in: query required: false schema: type: string - enum: - - DOCTOR_REPORT - - DISCHARGE_LETTER - - LAB_RESULT - - IMAGING - - PRESCRIPTION - - INSURANCE - - VACCINATION - - OTHER - description: Category filter. + description: Only documents linked to this illness/condition episode. + - name: year + in: query + required: false + schema: + type: integer + minimum: 1900 + maximum: 9999 + description: Only documents whose filing date falls in this calendar year (UTC). Takes precedence over from/to. - name: from in: query required: false @@ -8746,9 +8765,16 @@ paths: summary: Store a document (no extraction) description: "STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction — provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider - configured. `multipart/form-data`: a `file` (JPEG/PNG/WebP/PDF, validated by magic-byte MIME sniff, ≤ 12 MiB) - plus optional `title`, `kind`, and `documentDate` (YYYY-MM-DD) form fields. AI extraction is a separate opt-in - action — see `POST /api/documents/inbound/{id}/extract`." + configured. `multipart/form-data`: a `file` plus optional `title`, `kind`, `documentDate` (YYYY-MM-DD), and + repeated `episodeIds` form fields (pre-link to the caller's illness/condition episodes). Accepted types + (magic-byte sniffed, never the wire Content-Type): PDF/JPEG/PNG/WebP/GIF render inline; Office + (docx/xlsx/pptx/doc/xls/ppt), text/CSV/Markdown/RTF, TIFF, HEIC/HEIF, XML/JSON are stored verbatim and served + download-only. HEIC is stored as-is but attachment-only — prefer transcoding to JPEG client-side for inline + preview parity. Error contract: `413` with `meta.reason = \"fileTooLarge\"` (+ `maxFileBytes`) or + `\"quotaExceeded\"` (+ `quotaBytes`, `usedBytes`); `415` with `meta.reason = \"unsupportedType\"`. A same-user + duplicate (same bytes) returns 200 + `meta.duplicate: true` with the existing row — not an error. + `Idempotency-Key` honoured. Read `GET /api/documents/inbound/usage` for the effective limits before offering an + upload. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`." requestBody: required: true content: @@ -8759,7 +8785,7 @@ paths: file: type: string format: binary - description: The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type. + description: The document. Validated by magic-byte MIME sniff, not the wire Content-Type. title: description: Optional user label. type: string @@ -8772,6 +8798,7 @@ paths: - LAB_RESULT - IMAGING - PRESCRIPTION + - REFERRAL - INSURANCE - VACCINATION - OTHER @@ -8779,9 +8806,21 @@ paths: description: Optional user filing date (YYYY-MM-DD). Defaults to the upload day when omitted so the library sort/filter/display stay aligned. type: string + episodeIds: + description: Optional condition pre-links (repeat the field per id). Every id must be a live episode of the caller. + type: array + items: + type: string required: - file responses: + "200": + description: "Duplicate — the same bytes are already stored: the existing row, with `meta.duplicate: true` at the + envelope level." + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDocumentEnvelope" "201": description: The stored document. content: @@ -8789,6 +8828,99 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentEnvelope" "401": *a1 + "413": + description: "Too large: `meta.reason` is `fileTooLarge` (with `maxFileBytes`) or `quotaExceeded` (with `quotaBytes` + + `usedBytes`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: Unsupported or unidentifiable type (`meta.reason = "unsupportedType"`). Executables, HTML/SVG, and generic + archives are always refused. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/documents/inbound/usage: + get: + tags: + - Documents + summary: Storage usage + effective limits + description: "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the + 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` + (admin-tunable per-file cap), and `acceptedExtensions` for picker `accept` lists. Read this before offering an + upload." + responses: + "200": + description: Usage + limits. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentUsageEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/documents/inbound/bulk: + post: + tags: + - Documents + summary: Bulk action over documents + description: Applies one action to up to 100 owner-scoped documents and returns a per-id result array — a partial + failure never aborts the batch. `error` is `notFound` (unknown / foreign / tombstoned where liveness is + required) or `conflict` (restore blocked by a live duplicate of the same bytes). + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + ids: + minItems: 1 + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + action: + type: string + enum: + - setKind + - linkEpisode + - unlinkEpisode + - delete + - restore + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - REFERRAL + - INSURANCE + - VACCINATION + - OTHER + episodeId: + type: string + minLength: 1 + maxLength: 40 + required: + - ids + - action + responses: + "200": + description: Per-id outcomes. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentBulkEnvelope" + "401": *a1 "422": *a3 "429": *a4 /api/documents/inbound/{id}: @@ -8845,6 +8977,7 @@ paths: - LAB_RESULT - IMAGING - PRESCRIPTION + - REFERRAL - INSURANCE - VACCINATION - OTHER @@ -8853,6 +8986,13 @@ paths: - type: string pattern: ^\d{4}-\d{2}-\d{2}$ - type: "null" + episodeIds: + maxItems: 20 + type: array + items: + type: string + minLength: 1 + maxLength: 40 responses: "200": description: The updated document. @@ -8885,16 +9025,14 @@ paths: "401": *a1 "422": *a3 "429": *a4 - /api/documents/inbound/{id}/original: - get: + /api/documents/inbound/{id}/restore: + post: tags: - Documents - summary: View / download the original document - description: "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under - `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Uploads are - constrained at the store path to the inline-safe set (JPEG / PNG / WebP / PDF), so the document is always served - `Content-Disposition: inline` for in-tab rendering; a non-ASCII filename is carried via RFC 5987 `filename*`. - Fails closed (500) on a decrypt error — it never returns the ciphertext." + summary: Restore a discarded document + description: Clears the soft-delete tombstone within the 30-day undo grace. 409 when the tombstone is already purged + (the undo window is over) or when an identical live document blocks the restore (`meta.reason = + "duplicateExists"`). Restoring an already-live document is a no-op success. parameters: - name: id in: path @@ -8903,7 +9041,42 @@ paths: type: string responses: "200": - description: The decrypted original document bytes with the stored MIME type. + description: The restored document + its staged facts. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundRestoreEnvelope" + "401": *a1 + "409": + description: "Not restorable: purged past the grace window, or an identical live document exists." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/documents/inbound/{id}/original: + get: + tags: + - Documents + summary: View / download the original document + description: "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under + `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Serving + posture follows the document's `servingClass`: inline types (PDF/JPEG/PNG/WebP/GIF) come with their true + Content-Type, `Content-Disposition: inline`, `nosniff`, and a `Content-Security-Policy: sandbox` header; + attachment types (Office/text/TIFF/HEIC/XML/JSON) ALWAYS come as `application/octet-stream` + + `Content-Disposition: attachment` + `nosniff` — never inline. A non-ASCII filename is carried via RFC 5987 + `filename*`. Fails closed (500) on a decrypt error — it never returns the ciphertext." + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: The decrypted original document bytes. Inline types carry the stored MIME type; attachment types are served + as application/octet-stream. content: application/pdf: schema: @@ -8913,6 +9086,10 @@ paths: schema: type: string format: binary + application/octet-stream: + schema: + type: string + format: binary "401": *a1 "422": *a3 "429": *a4 @@ -10698,6 +10875,8 @@ components: type: boolean mcp: type: boolean + inboundDocuments: + type: boolean mentalHealth: type: boolean additionalProperties: false @@ -11256,6 +11435,7 @@ components: - LAB_RESULT - IMAGING - PRESCRIPTION + - REFERRAL - INSURANCE - VACCINATION - OTHER @@ -25699,6 +25879,7 @@ components: - LAB_RESULT - IMAGING - PRESCRIPTION + - REFERRAL - INSURANCE - VACCINATION - OTHER @@ -25743,6 +25924,15 @@ components: type: number pendingCount: type: number + conditionLinks: + type: array + items: + $ref: "#/components/schemas/DocumentConditionLink" + servingClass: + type: string + enum: + - inline + - attachment createdAt: type: string updatedAt: @@ -25761,13 +25951,29 @@ components: - errorReason - factCount - pendingCount + - conditionLinks + - servingClass - createdAt - updatedAt additionalProperties: false description: A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until - extraction runs). + extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or + download-only (`attachment`); render/download by it, never by MIME guess. Treat an unknown `kind` value as OTHER + when decoding. + DocumentConditionLink: + type: object + properties: + episodeId: + type: string + name: + type: string + required: + - episodeId + - name + additionalProperties: false + description: A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label. InboundDocumentEnvelope: type: object properties: @@ -25785,6 +25991,83 @@ components: - data - error additionalProperties: false + DocumentUsageEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentUsage" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentUsage: + type: object + properties: + usedBytes: + type: number + quotaBytes: + type: number + maxFileBytes: + type: number + acceptedExtensions: + type: array + items: + type: string + required: + - usedBytes + - quotaBytes + - maxFileBytes + - acceptedExtensions + additionalProperties: false + DocumentBulkEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentBulkResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentBulkResponse: + type: object + properties: + results: + type: array + items: + type: object + properties: + id: + type: string + ok: + type: boolean + error: + anyOf: + - type: string + - type: "null" + required: + - id + - ok + - error + additionalProperties: false + required: + - results + additionalProperties: false InboundDocumentDetailEnvelope: type: object properties: @@ -25815,6 +26098,7 @@ components: - LAB_RESULT - IMAGING - PRESCRIPTION + - REFERRAL - INSURANCE - VACCINATION - OTHER @@ -25859,6 +26143,15 @@ components: type: number pendingCount: type: number + conditionLinks: + type: array + items: + $ref: "#/components/schemas/DocumentConditionLink" + servingClass: + type: string + enum: + - inline + - attachment createdAt: type: string updatedAt: @@ -25881,6 +26174,8 @@ components: - errorReason - factCount - pendingCount + - conditionLinks + - servingClass - createdAt - updatedAt - facts @@ -25993,6 +26288,23 @@ components: - data - error additionalProperties: false + InboundRestoreEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundDocumentDetail" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false InboundExtractEnvelope: type: object properties: diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index b60e0c48e..c0798adcc 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -18,6 +18,7 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; import { + documentBulkSchema, documentUpdateSchema, inboundConfirmSchema, inboundFactEditSchema, @@ -45,7 +46,13 @@ inboundFactEditSchema.meta({ documentUpdateSchema.meta({ id: "DocumentUpdateRequest", description: - "Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.", + "Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.", +}); + +documentBulkSchema.meta({ + id: "DocumentBulkRequest", + description: + "One bulk action over up to 100 owner-scoped documents: `setKind` (requires `kind`), `linkEpisode` / `unlinkEpisode` (require `episodeId`), `delete` (tombstone, undo-able for 30 days), `restore` (clear tombstone). Partial failures never abort the batch — see the per-id result array.", }); const factProvenance = z.object({ @@ -72,6 +79,17 @@ const extractedFact = z "One STATED fact transcribed from the document, FHIR-staged (Condition / Observation / MedicationStatement) with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact scored below the confidence floor (it cannot be approved until edited).", }); +const conditionLink = z + .object({ + episodeId: z.string(), + name: z.string(), + }) + .meta({ + id: "DocumentConditionLink", + description: + "A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label.", + }); + const inboundDocument = z .object({ id: z.string(), @@ -87,13 +105,15 @@ const inboundDocument = z errorReason: z.string().nullable(), factCount: z.number(), pendingCount: z.number(), + conditionLinks: z.array(conditionLink), + servingClass: z.enum(["inline", "attachment"]), createdAt: z.string(), updatedAt: z.string(), }) .meta({ id: "InboundDocument", description: - "A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs).", + "A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. Treat an unknown `kind` value as OTHER when decoding.", }); const inboundDocumentDetail = inboundDocument @@ -132,6 +152,13 @@ const confirmResponse = z const kindValues = [...INBOUND_DOCUMENT_KINDS]; +// Shared by the POST 200 (duplicate) and 201 (created) responses — the +// envelope id must be registered exactly once. +const inboundDocumentEnvelope = dataEnvelope( + inboundDocument, + "InboundDocumentEnvelope", +); + export const inboundDocumentPaths: NonNullable = { "/api/documents/inbound": { get: { @@ -151,8 +178,30 @@ export const inboundDocumentPaths: NonNullable = { name: "kind", in: "query", required: false, - schema: { type: "string", enum: kindValues }, - description: "Category filter.", + style: "form", + explode: true, + schema: { + type: "array", + items: { type: "string", enum: kindValues }, + }, + description: + "Category filter — OR inside the facet. Repeat the parameter (or send a comma-separated value).", + }, + { + name: "episodeId", + in: "query", + required: false, + schema: { type: "string" }, + description: + "Only documents linked to this illness/condition episode.", + }, + { + name: "year", + in: "query", + required: false, + schema: { type: "integer", minimum: 1900, maximum: 9999 }, + description: + "Only documents whose filing date falls in this calendar year (UTC). Takes precedence over from/to.", }, { name: "from", @@ -219,7 +268,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["Documents"], summary: "Store a document (no extraction)", description: - "STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction — provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider configured. `multipart/form-data`: a `file` (JPEG/PNG/WebP/PDF, validated by magic-byte MIME sniff, ≤ 12 MiB) plus optional `title`, `kind`, and `documentDate` (YYYY-MM-DD) form fields. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`.", + 'STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction — provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider configured. `multipart/form-data`: a `file` plus optional `title`, `kind`, `documentDate` (YYYY-MM-DD), and repeated `episodeIds` form fields (pre-link to the caller\'s illness/condition episodes). Accepted types (magic-byte sniffed, never the wire Content-Type): PDF/JPEG/PNG/WebP/GIF render inline; Office (docx/xlsx/pptx/doc/xls/ppt), text/CSV/Markdown/RTF, TIFF, HEIC/HEIF, XML/JSON are stored verbatim and served download-only. HEIC is stored as-is but attachment-only — prefer transcoding to JPEG client-side for inline preview parity. Error contract: `413` with `meta.reason = "fileTooLarge"` (+ `maxFileBytes`) or `"quotaExceeded"` (+ `quotaBytes`, `usedBytes`); `415` with `meta.reason = "unsupportedType"`. A same-user duplicate (same bytes) returns 200 + `meta.duplicate: true` with the existing row — not an error. `Idempotency-Key` honoured. Read `GET /api/documents/inbound/usage` for the effective limits before offering an upload. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`.', requestBody: { required: true, content: { @@ -228,7 +277,7 @@ export const inboundDocumentPaths: NonNullable = { file: z.string().meta({ format: "binary", description: - "The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.", + "The document. Validated by magic-byte MIME sniff, not the wire Content-Type.", }), title: z .string() @@ -241,16 +290,104 @@ export const inboundDocumentPaths: NonNullable = { description: "Optional user filing date (YYYY-MM-DD). Defaults to the upload day when omitted so the library sort/filter/display stay aligned.", }), + episodeIds: z.array(z.string()).optional().meta({ + description: + "Optional condition pre-links (repeat the field per id). Every id must be a live episode of the caller.", + }), }), }, }, }, responses: { + "200": { + description: + "Duplicate — the same bytes are already stored: the existing row, with `meta.duplicate: true` at the envelope level.", + content: { + "application/json": { + schema: inboundDocumentEnvelope, + }, + }, + }, "201": { description: "The stored document.", content: { "application/json": { - schema: dataEnvelope(inboundDocument, "InboundDocumentEnvelope"), + schema: inboundDocumentEnvelope, + }, + }, + }, + "413": { + description: + "Too large: `meta.reason` is `fileTooLarge` (with `maxFileBytes`) or `quotaExceeded` (with `quotaBytes` + `usedBytes`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: + 'Unsupported or unidentifiable type (`meta.reason = "unsupportedType"`). Executables, HTML/SVG, and generic archives are always refused.', + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/documents/inbound/usage": { + get: { + tags: ["Documents"], + summary: "Storage usage + effective limits", + description: + "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` (admin-tunable per-file cap), and `acceptedExtensions` for picker `accept` lists. Read this before offering an upload.", + responses: { + "200": { + description: "Usage + limits.", + content: { + "application/json": { + schema: dataEnvelope( + z + .object({ + usedBytes: z.number(), + quotaBytes: z.number(), + maxFileBytes: z.number(), + acceptedExtensions: z.array(z.string()), + }) + .meta({ id: "DocumentUsage" }), + "DocumentUsageEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/documents/inbound/bulk": { + post: { + tags: ["Documents"], + summary: "Bulk action over documents", + description: + "Applies one action to up to 100 owner-scoped documents and returns a per-id result array — a partial failure never aborts the batch. `error` is `notFound` (unknown / foreign / tombstoned where liveness is required) or `conflict` (restore blocked by a live duplicate of the same bytes).", + requestBody: { + required: true, + content: { "application/json": { schema: documentBulkSchema } }, + }, + responses: { + "200": { + description: "Per-id outcomes.", + content: { + "application/json": { + schema: dataEnvelope( + z + .object({ + results: z.array( + z.object({ + id: z.string(), + ok: z.boolean(), + error: z.string().nullable(), + }), + ), + }) + .meta({ id: "DocumentBulkResponse" }), + "DocumentBulkEnvelope", + ), }, }, }, @@ -348,12 +485,47 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, + "/api/documents/inbound/{id}/restore": { + post: { + tags: ["Documents"], + summary: "Restore a discarded document", + description: + 'Clears the soft-delete tombstone within the 30-day undo grace. 409 when the tombstone is already purged (the undo window is over) or when an identical live document blocks the restore (`meta.reason = "duplicateExists"`). Restoring an already-live document is a no-op success.', + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "The restored document + its staged facts.", + content: { + "application/json": { + schema: dataEnvelope( + inboundDocumentDetail, + "InboundRestoreEnvelope", + ), + }, + }, + }, + "409": { + description: + "Not restorable: purged past the grace window, or an identical live document exists.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/documents/inbound/{id}/original": { get: { tags: ["Documents"], summary: "View / download the original document", description: - "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Uploads are constrained at the store path to the inline-safe set (JPEG / PNG / WebP / PDF), so the document is always served `Content-Disposition: inline` for in-tab rendering; a non-ASCII filename is carried via RFC 5987 `filename*`. Fails closed (500) on a decrypt error — it never returns the ciphertext.", + "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Serving posture follows the document's `servingClass`: inline types (PDF/JPEG/PNG/WebP/GIF) come with their true Content-Type, `Content-Disposition: inline`, `nosniff`, and a `Content-Security-Policy: sandbox` header; attachment types (Office/text/TIFF/HEIC/XML/JSON) ALWAYS come as `application/octet-stream` + `Content-Disposition: attachment` + `nosniff` — never inline. A non-ASCII filename is carried via RFC 5987 `filename*`. Fails closed (500) on a decrypt error — it never returns the ciphertext.", parameters: [ { name: "id", @@ -365,7 +537,7 @@ export const inboundDocumentPaths: NonNullable = { responses: { "200": { description: - "The decrypted original document bytes with the stored MIME type.", + "The decrypted original document bytes. Inline types carry the stored MIME type; attachment types are served as application/octet-stream.", content: { "application/pdf": { schema: { type: "string", format: "binary" }, @@ -373,6 +545,9 @@ export const inboundDocumentPaths: NonNullable = { "image/*": { schema: { type: "string", format: "binary" }, }, + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, }, }, ...stdResponses, From b1004958c32a04defec3392586eef9e7c19e14e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 11:06:33 +0200 Subject: [PATCH 10/34] test(documents): end-to-end vault contract against Postgres Pins what the unit mocks cannot: the binary2 round-trip through upload and /original with class-correct headers, the policy error contract, sha256 dedupe per user with the partial unique index live, Idempotency-Key replay without a second row, tombstone-inclusive quota accounting, pre-link + replace-set + kind/episode/year list filters, restore incl. both 409 shapes, bulk per-id semantics, usage with a per-user override, and the purge job claiming only past-grace tombstones. Migration 0227 applies clean on the fresh testcontainer as part of the ordered chain. Tasks: W1-T1, W1-T5, W1-T6, W1-T7, W1-T8, W1-T9, W1-T10, W1-T12. --- tests/integration/documents-vault.test.ts | 640 ++++++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100644 tests/integration/documents-vault.test.ts diff --git a/tests/integration/documents-vault.test.ts b/tests/integration/documents-vault.test.ts new file mode 100644 index 000000000..0325a9f68 --- /dev/null +++ b/tests/integration/documents-vault.test.ts @@ -0,0 +1,640 @@ +/** + * Document vault — end-to-end contract against a real Postgres. + * + * Covers the Wave-1 hardenings the unit mocks cannot pin: + * + * 1. Upload → binary2-encrypted row + plaintext sha256; /original + * round-trips the exact bytes with the class-correct headers. + * 2. Policy error contract: 413 fileTooLarge / quotaExceeded (with limits + * in meta), 415 unsupportedType. + * 3. sha256 dedupe: a same-user re-upload returns 200 + meta.duplicate and + * the existing row id; the partial unique index holds at the DB level. + * 4. Idempotency-Key replay returns the cached response without a second + * row. + * 5. Quota accounting is tombstone-inclusive (a soft-deleted row still + * counts against usage until purged). + * 6. episodeIds pre-link + PATCH replace-set + list filters (kind[], + * episodeId, year) — and the list never leaks the blob. + * 7. Restore: clears the tombstone; 409 after purge; 409 on a live + * duplicate of the same bytes. + * 8. Bulk endpoint per-id semantics. + * 9. Usage endpoint reflects overrides + tombstone-inclusive usage. + * 10. The purge job hard-deletes only past-grace tombstones. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==", + "base64", +); +const PDF_SMALL = Buffer.from("%PDF-1.7\n1 0 obj\n<<>>\nendobj\n%%EOF\n"); + +async function seedVaultUser(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role: "USER", + timezone: "Europe/Berlin", + // Opt-in module: the vault is dark until the user flips it on. + modulePreferencesJson: { inboundDocuments: true }, + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +function uploadRequest( + bytes: Buffer, + filename: string, + fields: Record = {}, + headers: Record = {}, +): Request { + const payload = new Uint8Array(bytes.byteLength); + payload.set(bytes); + const formData = new FormData(); + formData.append("file", new Blob([payload]), filename); + for (const [k, v] of Object.entries(fields)) { + for (const item of Array.isArray(v) ? v : [v]) formData.append(k, item); + } + return new Request("http://localhost/api/documents/inbound", { + method: "POST", + body: formData, + headers, + }); +} + +type RouteCtx = { params: Promise<{ id: string }> }; +const ctx = (id: string): RouteCtx => ({ params: Promise.resolve({ id }) }); + +async function routes() { + const list = await import("@/app/api/documents/inbound/route"); + const byId = await import("@/app/api/documents/inbound/[id]/route"); + const original = + await import("@/app/api/documents/inbound/[id]/original/route"); + const restore = + await import("@/app/api/documents/inbound/[id]/restore/route"); + const bulk = await import("@/app/api/documents/inbound/bulk/route"); + const usage = await import("@/app/api/documents/inbound/usage/route"); + return { + post: list.POST as unknown as (r: Request) => Promise, + get: list.GET as unknown as (r: Request) => Promise, + patch: byId.PATCH as unknown as ( + r: Request, + c: RouteCtx, + ) => Promise, + del: byId.DELETE as unknown as ( + r: Request, + c: RouteCtx, + ) => Promise, + getOriginal: original.GET as unknown as ( + r: Request, + c: RouteCtx, + ) => Promise, + postRestore: restore.POST as unknown as ( + r: Request, + c: RouteCtx, + ) => Promise, + postBulk: bulk.POST as unknown as (r: Request) => Promise, + getUsage: usage.GET as unknown as (r: Request) => Promise, + }; +} + +function jsonRequest(url: string, method: string, body: unknown): Request { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("document vault — upload + serve round-trip", () => { + it("stores binary2-encrypted with sha256 and serves the exact bytes back", async () => { + await seedVaultUser("vault-roundtrip"); + const { post, getOriginal } = await routes(); + + const res = await post( + uploadRequest(PNG_1X1, "scan.png", { title: "Röntgen Knie" }), + ); + expect(res.status).toBe(201); + const { data } = await res.json(); + expect(data.servingClass).toBe("inline"); + + // At rest: binary2 codec, plaintext sha256, ciphertext ≠ plaintext. + const row = await getPrismaClient().inboundDocument.findFirstOrThrow(); + expect(row.contentCodec).toBe("binary2"); + expect(row.contentSha256).toMatch(/^[0-9a-f]{64}$/); + expect(Buffer.from(row.contentEncrypted).equals(PNG_1X1)).toBe(false); + + // Round-trip through the serve route: exact bytes + Class A posture. + const orig = await getOriginal( + new Request(`http://localhost/api/documents/inbound/${data.id}/original`), + ctx(data.id), + ); + expect(orig.status).toBe(200); + expect(orig.headers.get("Content-Type")).toBe("image/png"); + expect(orig.headers.get("Content-Disposition")).toContain("inline"); + expect(orig.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(orig.headers.get("Content-Security-Policy")).toBe("sandbox"); + const served = Buffer.from(await orig.arrayBuffer()); + expect(served.equals(PNG_1X1)).toBe(true); + }); + + it("serves a Class B upload attachment-only as octet-stream", async () => { + await seedVaultUser("vault-classb"); + const { post, getOriginal } = await routes(); + + const res = await post( + uploadRequest(Buffer.from("Blutdruck 120/80 morgens\n"), "werte.csv"), + ); + expect(res.status).toBe(201); + const { data } = await res.json(); + expect(data.mimeType).toBe("text/csv"); + expect(data.servingClass).toBe("attachment"); + + const orig = await getOriginal( + new Request(`http://localhost/api/documents/inbound/${data.id}/original`), + ctx(data.id), + ); + expect(orig.status).toBe(200); + expect(orig.headers.get("Content-Type")).toBe("application/octet-stream"); + expect( + (orig.headers.get("Content-Disposition") ?? "").startsWith("attachment;"), + ).toBe(true); + expect(orig.headers.get("Content-Security-Policy")).toBeNull(); + }); +}); + +describe("document vault — policy error contract", () => { + it("415s an unsupported type with meta.reason", async () => { + await seedVaultUser("vault-415"); + const { post } = await routes(); + const res = await post( + uploadRequest(Buffer.from([0x01, 0x02, 0x03, 0x04]), "blob.bin"), + ); + expect(res.status).toBe(415); + const body = await res.json(); + expect(body.meta.reason).toBe("unsupportedType"); + }); + + it("413s over the admin-set per-file cap with the limit in meta", async () => { + await seedVaultUser("vault-cap"); + await getPrismaClient().appSettings.upsert({ + where: { id: "singleton" }, + update: { documentMaxFileBytes: 20 }, + create: { id: "singleton", documentMaxFileBytes: 20 }, + }); + const { post } = await routes(); + const res = await post(uploadRequest(PNG_1X1, "big.png")); + expect(res.status).toBe(413); + const body = await res.json(); + expect(body.meta.reason).toBe("fileTooLarge"); + expect(body.meta.maxFileBytes).toBe(20); + expect(await getPrismaClient().inboundDocument.count()).toBe(0); + }); + + it("413s past the quota — and tombstoned rows still count against it", async () => { + const user = await seedVaultUser("vault-quota"); + // Quota fits exactly one PNG (70 B) but not two. + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { documentQuotaBytes: BigInt(PNG_1X1.byteLength + 10) }, + }); + const { post, del } = await routes(); + + const first = await post(uploadRequest(PNG_1X1, "one.png")); + expect(first.status).toBe(201); + const firstId = (await first.json()).data.id as string; + + // Soft-delete the first upload: the tombstone KEEPS holding quota. + const delRes = await del( + new Request(`http://localhost/api/documents/inbound/${firstId}`, { + method: "DELETE", + }), + ctx(firstId), + ); + expect(delRes.status).toBe(200); + + const second = await post(uploadRequest(PDF_SMALL, "two.pdf")); + expect(second.status).toBe(413); + const body = await second.json(); + expect(body.meta.reason).toBe("quotaExceeded"); + expect(body.meta.usedBytes).toBe(PNG_1X1.byteLength); + expect(body.meta.quotaBytes).toBe(PNG_1X1.byteLength + 10); + }); +}); + +describe("document vault — dedupe + idempotency", () => { + it("returns the existing live row + meta.duplicate on a same-bytes re-upload", async () => { + await seedVaultUser("vault-dedupe"); + const { post } = await routes(); + + const first = await post(uploadRequest(PNG_1X1, "scan.png")); + expect(first.status).toBe(201); + const firstId = (await first.json()).data.id as string; + + const second = await post(uploadRequest(PNG_1X1, "scan-copy.png")); + expect(second.status).toBe(200); + const body = await second.json(); + expect(body.meta.duplicate).toBe(true); + expect(body.data.id).toBe(firstId); + expect(await getPrismaClient().inboundDocument.count()).toBe(1); + }); + + it("dedupes per user, not globally", async () => { + await seedVaultUser("vault-user-a"); + const { post } = await routes(); + expect((await post(uploadRequest(PNG_1X1, "a.png"))).status).toBe(201); + + cookieJar.clear(); + await seedVaultUser("vault-user-b"); + expect((await post(uploadRequest(PNG_1X1, "b.png"))).status).toBe(201); + expect(await getPrismaClient().inboundDocument.count()).toBe(2); + }); + + it("replays an Idempotency-Key without creating a second row", async () => { + await seedVaultUser("vault-idem"); + const { post } = await routes(); + const key = "vault-upload-abc123"; + + const first = await post( + uploadRequest(PDF_SMALL, "brief.pdf", {}, { "Idempotency-Key": key }), + ); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await post( + uploadRequest(PDF_SMALL, "brief.pdf", {}, { "Idempotency-Key": key }), + ); + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + const replayBody = await replay.json(); + expect(replayBody.data.id).toBe(firstBody.data.id); + expect(await getPrismaClient().inboundDocument.count()).toBe(1); + }); +}); + +describe("document vault — links + list filters", () => { + it("pre-links at upload, replace-sets via PATCH, filters by kind/episode/year", async () => { + const user = await seedVaultUser("vault-links"); + const prisma = getPrismaClient(); + const knie = await prisma.illnessEpisode.create({ + data: { + userId: user.id, + label: "Knie", + type: "OTHER", + onsetAt: new Date("2025-09-01T00:00:00.000Z"), + }, + }); + const ruecken = await prisma.illnessEpisode.create({ + data: { + userId: user.id, + label: "Rücken", + type: "OTHER", + onsetAt: new Date("2025-01-01T00:00:00.000Z"), + }, + }); + const { post, get, patch } = await routes(); + + const created = await post( + uploadRequest(PDF_SMALL, "mrt-knie.pdf", { + title: "MRT Knie", + kind: "IMAGING", + documentDate: "2025-10-04", + episodeIds: [knie.id], + }), + ); + expect(created.status).toBe(201); + const doc = (await created.json()).data; + expect(doc.conditionLinks).toEqual([{ episodeId: knie.id, name: "Knie" }]); + + // Second (unlinked, different year) document for filter contrast. + const other = await post( + uploadRequest(PNG_1X1, "impfpass.png", { + kind: "VACCINATION", + documentDate: "2024-03-01", + }), + ); + expect(other.status).toBe(201); + + // kind[] facet (OR inside), episode filter, year filter — AND across. + const filtered = await get( + new Request( + `http://localhost/api/documents/inbound?kind=IMAGING,LAB_RESULT&episodeId=${knie.id}&year=2025`, + ), + ); + expect(filtered.status).toBe(200); + const page = (await filtered.json()).data; + expect(page.documents).toHaveLength(1); + expect(page.documents[0].title).toBe("MRT Knie"); + // The list DTO never carries blob material. + expect("contentEncrypted" in page.documents[0]).toBe(false); + + // Year filter alone excludes the 2025 document. + const y2024 = await get( + new Request("http://localhost/api/documents/inbound?year=2024"), + ); + expect((await y2024.json()).data.documents).toHaveLength(1); + expect( + ( + await ( + await get(new Request("http://localhost/api/documents/inbound")) + ).json() + ).data.documents, + ).toHaveLength(2); + + // PATCH replace-set: Knie → Rücken (relink), then [] (unlink-all). + const relinked = await patch( + jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", { + episodeIds: [ruecken.id], + }), + ctx(doc.id), + ); + expect(relinked.status).toBe(200); + expect((await relinked.json()).data.conditionLinks).toEqual([ + { episodeId: ruecken.id, name: "Rücken" }, + ]); + + const unlinked = await patch( + jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", { + episodeIds: [], + }), + ctx(doc.id), + ); + expect((await unlinked.json()).data.conditionLinks).toEqual([]); + + // Foreign episode id → 404-shaped refusal. + cookieJar.clear(); + const stranger = await seedVaultUser("vault-links-stranger"); + void stranger; + const foreign = await patch( + jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", { + episodeIds: [knie.id], + }), + ctx(doc.id), + ); + expect(foreign.status).toBe(404); + }); +}); + +describe("document vault — delete / restore / purge", () => { + it("restore clears the tombstone; purge-then-restore 409s; duplicate restore 409s", async () => { + await seedVaultUser("vault-restore"); + const prisma = getPrismaClient(); + const { post, del, postRestore } = await routes(); + + const created = await post(uploadRequest(PNG_1X1, "scan.png")); + const docId = (await created.json()).data.id as string; + + await del( + new Request(`http://localhost/api/documents/inbound/${docId}`, { + method: "DELETE", + }), + ctx(docId), + ); + expect( + (await prisma.inboundDocument.findFirstOrThrow()).deletedAt, + ).not.toBeNull(); + + // Restore inside the grace window succeeds and the row is live again. + const restored = await postRestore( + new Request(`http://localhost/api/documents/inbound/${docId}/restore`, { + method: "POST", + }), + ctx(docId), + ); + expect(restored.status).toBe(200); + const row = await prisma.inboundDocument.findFirstOrThrow(); + expect(row.deletedAt).toBeNull(); + expect(row.status).toBe("STORED"); + + // Tombstone again, upload the same bytes fresh (allowed — the partial + // index only guards LIVE rows), then the tombstone's restore conflicts. + await del( + new Request(`http://localhost/api/documents/inbound/${docId}`, { + method: "DELETE", + }), + ctx(docId), + ); + const reupload = await post(uploadRequest(PNG_1X1, "scan-again.png")); + expect(reupload.status).toBe(201); + + const conflicted = await postRestore( + new Request(`http://localhost/api/documents/inbound/${docId}/restore`, { + method: "POST", + }), + ctx(docId), + ); + expect(conflicted.status).toBe(409); + expect((await conflicted.json()).meta.reason).toBe("duplicateExists"); + + // Hard-purge the tombstone → restore now answers 409 (undo window over). + await prisma.inboundDocument.delete({ where: { id: docId } }); + const gone = await postRestore( + new Request(`http://localhost/api/documents/inbound/${docId}/restore`, { + method: "POST", + }), + ctx(docId), + ); + expect(gone.status).toBe(409); + expect((await gone.json()).meta.reason).toBe("purged"); + }); + + it("the purge job hard-deletes only past-grace tombstones", async () => { + const user = await seedVaultUser("vault-purge"); + const prisma = getPrismaClient(); + const { post, del } = await routes(); + const { purgeExpiredDocumentTombstones } = + await import("@/lib/jobs/document-purge"); + + const a = (await (await post(uploadRequest(PNG_1X1, "old.png"))).json()) + .data.id as string; + const b = (await (await post(uploadRequest(PDF_SMALL, "fresh.pdf"))).json()) + .data.id as string; + const c = ( + await ( + await post(uploadRequest(Buffer.from("text file\n"), "live.txt")) + ).json() + ).data.id as string; + + // a: tombstoned 31 days ago (past grace). b: tombstoned now. c: live. + for (const id of [a, b]) { + await del( + new Request(`http://localhost/api/documents/inbound/${id}`, { + method: "DELETE", + }), + ctx(id), + ); + } + await prisma.inboundDocument.update({ + where: { id: a }, + data: { deletedAt: new Date(Date.now() - 31 * 86_400_000) }, + }); + + const purged = await purgeExpiredDocumentTombstones(prisma); + expect(purged).toBe(1); + + const remaining = await prisma.inboundDocument.findMany({ + where: { userId: user.id }, + select: { id: true }, + orderBy: { id: "asc" }, + }); + expect(remaining.map((r) => r.id).sort()).toEqual([b, c].sort()); + }); +}); + +describe("document vault — bulk + usage", () => { + it("bulk applies per-id with partial-failure results", async () => { + const user = await seedVaultUser("vault-bulk"); + const prisma = getPrismaClient(); + const episode = await prisma.illnessEpisode.create({ + data: { + userId: user.id, + label: "Knie", + type: "OTHER", + onsetAt: new Date("2025-09-01T00:00:00.000Z"), + }, + }); + const { post, postBulk } = await routes(); + + const id1 = (await (await post(uploadRequest(PNG_1X1, "a.png"))).json()) + .data.id as string; + const id2 = (await (await post(uploadRequest(PDF_SMALL, "b.pdf"))).json()) + .data.id as string; + + // setKind over one real + one unknown id → per-id ok/notFound. + const setKind = await postBulk( + jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", { + ids: [id1, "does-not-exist"], + action: "setKind", + kind: "LAB_RESULT", + }), + ); + expect(setKind.status).toBe(200); + const { results } = (await setKind.json()).data; + expect(results).toEqual([ + { id: id1, ok: true, error: null }, + { id: "does-not-exist", ok: false, error: "notFound" }, + ]); + expect( + (await prisma.inboundDocument.findUniqueOrThrow({ where: { id: id1 } })) + .kind, + ).toBe("LAB_RESULT"); + + // linkEpisode + delete + restore round-trip through bulk. + const link = await postBulk( + jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", { + ids: [id1, id2], + action: "linkEpisode", + episodeId: episode.id, + }), + ); + expect( + (await link.json()).data.results.every((r: { ok: boolean }) => r.ok), + ).toBe(true); + expect(await prisma.documentConditionLink.count()).toBe(2); + + const bulkDelete = await postBulk( + jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", { + ids: [id1, id2], + action: "delete", + }), + ); + expect(bulkDelete.status).toBe(200); + expect( + await prisma.inboundDocument.count({ where: { deletedAt: null } }), + ).toBe(0); + + const bulkRestore = await postBulk( + jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", { + ids: [id1, id2], + action: "restore", + }), + ); + expect(bulkRestore.status).toBe(200); + expect( + await prisma.inboundDocument.count({ where: { deletedAt: null } }), + ).toBe(2); + + // Cap: 101 ids → 422 (schema bound). + const over = await postBulk( + jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", { + ids: Array.from({ length: 101 }, (_, i) => `id-${i}`), + action: "delete", + }), + ); + expect(over.status).toBe(422); + }); + + it("usage reflects the override + tombstone-inclusive accounting", async () => { + const user = await seedVaultUser("vault-usage"); + const prisma = getPrismaClient(); + await prisma.user.update({ + where: { id: user.id }, + data: { documentQuotaBytes: BigInt(123_456_789) }, + }); + const { post, del, getUsage } = await routes(); + + const id = (await (await post(uploadRequest(PNG_1X1, "a.png"))).json()).data + .id as string; + await del( + new Request(`http://localhost/api/documents/inbound/${id}`, { + method: "DELETE", + }), + ctx(id), + ); + + const res = await getUsage( + new Request("http://localhost/api/documents/inbound/usage"), + ); + expect(res.status).toBe(200); + const { data } = await res.json(); + // Tombstoned bytes still count until the purge reclaims them. + expect(data.usedBytes).toBe(PNG_1X1.byteLength); + expect(data.quotaBytes).toBe(123_456_789); + expect(data.maxFileBytes).toBe(26_214_400); + expect(data.acceptedExtensions).toContain(".pdf"); + expect(data.acceptedExtensions).not.toContain(".heic"); + }); +}); From 37e9d933e2e28475bfeef96358a04495b60fe0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:25:45 +0200 Subject: [PATCH 11/34] feat(documents): vault list contract on the client URL-backed facets (q / kind / episode / year) parse and serialise through one pure helper pair so deep links round-trip byte-stable; the API search builder pins the timeline sort. Month bucketing + row chunking feed the virtualized grid as a flat item list. The upload-response parser owns the duplicate contract: HTTP 200 with envelope-level meta.duplicate returns the existing row, every 413/415 reason maps to a translated-copy class, and proxy-stripped bodies fall back to the HTTP status. Query keys move to the vault filter shape and gain the usage read. Plan-Task: W2-T1 (URL state), W2-T5 (keys) --- .../documents/__tests__/vault-utils.test.ts | 254 +++++++++++++++ .../documents/document-kind-meta.ts | 48 +++ src/components/documents/vault-utils.ts | 305 ++++++++++++++++++ src/lib/query-keys/documents.ts | 53 +-- 4 files changed, 636 insertions(+), 24 deletions(-) create mode 100644 src/components/documents/__tests__/vault-utils.test.ts create mode 100644 src/components/documents/document-kind-meta.ts create mode 100644 src/components/documents/vault-utils.ts diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts new file mode 100644 index 000000000..224b03cae --- /dev/null +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; + +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import { + buildTimelineItems, + buildVaultListApiSearch, + classifyUploadFailure, + countActiveFilters, + documentDateKey, + formatBytes, + formatMonthLabel, + parseUploadResponse, + parseVaultSearchParams, + vaultFiltersToSearch, +} from "../vault-utils"; + +function doc(overrides: Partial = {}): InboundDocumentDto { + return { + id: "doc-1", + kind: "DOCTOR_REPORT", + title: null, + filename: "letter.pdf", + mimeType: "application/pdf", + byteSize: 1024, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: "2026-03-10", + errorReason: null, + factCount: 0, + pendingCount: 0, + conditionLinks: [], + servingClass: "inline", + createdAt: "2026-03-11T08:00:00.000Z", + updatedAt: "2026-03-11T08:00:00.000Z", + ...overrides, + }; +} + +describe("URL facet round-trip", () => { + it("parses q / kind / episode / year and serialises back identically", () => { + const search = "q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025"; + const filters = parseVaultSearchParams(new URLSearchParams(search)); + expect(filters).toEqual({ + q: "MRT", + kinds: ["IMAGING", "LAB_RESULT"], + episodeId: "ep1", + year: 2025, + }); + expect(vaultFiltersToSearch(filters)).toBe( + "q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025", + ); + }); + + it("accepts repeated kind params and drops unknown kinds", () => { + const sp = new URLSearchParams(); + sp.append("kind", "IMAGING"); + sp.append("kind", "NOT_A_KIND"); + sp.append("kind", "IMAGING"); // duplicate collapses + const filters = parseVaultSearchParams(sp); + expect(filters.kinds).toEqual(["IMAGING"]); + }); + + it("normalises kind order so the cache key is stable", () => { + const a = parseVaultSearchParams( + new URLSearchParams("kind=LAB_RESULT,IMAGING"), + ); + const b = parseVaultSearchParams( + new URLSearchParams("kind=IMAGING,LAB_RESULT"), + ); + expect(a).toEqual(b); + }); + + it("ignores a malformed year and returns the empty default view", () => { + expect(parseVaultSearchParams(new URLSearchParams("year=20x5"))).toEqual( + {}, + ); + expect(vaultFiltersToSearch({})).toBe(""); + }); + + it("counts active facets per chip (search counts once)", () => { + expect(countActiveFilters({})).toBe(0); + expect( + countActiveFilters({ + q: "a", + kinds: ["IMAGING", "LAB_RESULT"], + episodeId: "e", + year: 2026, + }), + ).toBe(5); + }); +}); + +describe("API list search", () => { + it("pins timeline sort, maps episode → episodeId, threads the cursor", () => { + const search = buildVaultListApiSearch( + { kinds: ["IMAGING"], episodeId: "ep1", year: 2025 }, + "cursor-9", + 50, + ); + const sp = new URLSearchParams(search); + expect(sp.get("kind")).toBe("IMAGING"); + expect(sp.get("episodeId")).toBe("ep1"); + expect(sp.get("year")).toBe("2025"); + expect(sp.get("sort")).toBe("documentDate"); + expect(sp.get("order")).toBe("desc"); + expect(sp.get("cursor")).toBe("cursor-9"); + expect(sp.get("limit")).toBe("50"); + }); +}); + +describe("timeline items", () => { + it("buckets by month and chunks rows to the column count", () => { + const docs = [ + doc({ id: "a", documentDate: "2026-03-20" }), + doc({ id: "b", documentDate: "2026-03-15" }), + doc({ id: "c", documentDate: "2026-03-10" }), + doc({ id: "d", documentDate: "2026-02-28" }), + ]; + const items = buildTimelineItems(docs, 2); + expect(items.map((i) => i.type)).toEqual([ + "month", + "row", + "row", + "month", + "row", + ]); + expect(items[0]).toEqual({ type: "month", key: "2026-03" }); + expect(items[1]).toMatchObject({ documents: [docs[0], docs[1]] }); + expect(items[2]).toMatchObject({ documents: [docs[2]] }); + expect(items[4]).toMatchObject({ documents: [docs[3]] }); + }); + + it("files an undated document under its upload day", () => { + const d = doc({ documentDate: null, createdAt: "2026-01-05T10:00:00Z" }); + expect(documentDateKey(d)).toBe("2026-01-05"); + const items = buildTimelineItems([d], 3); + expect(items[0]).toEqual({ type: "month", key: "2026-01" }); + }); + + it("keeps the DOM bounded: item count scales with rows, not documents", () => { + const docs = Array.from({ length: 1000 }, (_, i) => + doc({ id: `d${i}`, documentDate: "2026-03-01" }), + ); + const items = buildTimelineItems(docs, 4); + // 1 month header + 250 rows — the virtualizer then windows over these. + expect(items).toHaveLength(251); + }); + + it("formats a month key in the viewer's locale", () => { + expect(formatMonthLabel("2026-03", "de")).toBe("März 2026"); + expect(formatMonthLabel("not-a-month", "de")).toBe("not-a-month"); + }); +}); + +describe("byte formatting", () => { + it("scales units and keeps one decimal below 10", () => { + expect(formatBytes(0, "en")).toBe("0 B"); + expect(formatBytes(512, "en")).toBe("512 B"); + expect(formatBytes(26_214_400, "en")).toBe("25 MB"); + expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB"); + }); +}); + +describe("upload response contract (§3.2)", () => { + it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => { + const body = JSON.stringify({ + data: doc({ id: "existing-row" }), + error: null, + meta: { duplicate: true }, + }); + const result = parseUploadResponse(200, body); + expect(result).toMatchObject({ + ok: true, + duplicate: true, + document: { id: "existing-row" }, + }); + }); + + it("a plain 201 success is not a duplicate", () => { + const body = JSON.stringify({ data: doc({ id: "fresh" }), error: null }); + expect(parseUploadResponse(201, body)).toMatchObject({ + ok: true, + duplicate: false, + document: { id: "fresh" }, + }); + }); + + it("maps every meta.reason to its translated-copy class", () => { + expect( + parseUploadResponse( + 413, + JSON.stringify({ + data: null, + error: "too large", + meta: { reason: "fileTooLarge", maxFileBytes: 26_214_400 }, + }), + ), + ).toEqual({ + ok: false, + reason: "fileTooLarge", + maxFileBytes: 26_214_400, + quotaBytes: undefined, + usedBytes: undefined, + existingId: undefined, + }); + + expect( + parseUploadResponse( + 413, + JSON.stringify({ + data: null, + error: "quota", + meta: { + reason: "quotaExceeded", + quotaBytes: 1_073_741_824, + usedBytes: 1_000_000_000, + }, + }), + ), + ).toMatchObject({ + ok: false, + reason: "quotaExceeded", + quotaBytes: 1_073_741_824, + usedBytes: 1_000_000_000, + }); + + expect( + classifyUploadFailure(415, { reason: "unsupportedType" }), + ).toMatchObject({ reason: "unsupportedType" }); + expect(classifyUploadFailure(409, { reason: "purged" })).toMatchObject({ + reason: "purged", + }); + expect( + classifyUploadFailure(409, { + reason: "duplicateExists", + existingId: "row-1", + }), + ).toMatchObject({ reason: "duplicateExists", existingId: "row-1" }); + }); + + it("falls back to the HTTP status when a proxy strips the body", () => { + expect(parseUploadResponse(413, "")).toMatchObject({ + reason: "fileTooLarge", + }); + expect(parseUploadResponse(415, "")).toMatchObject({ + reason: "unsupportedType", + }); + expect(parseUploadResponse(429, "")).toMatchObject({ + reason: "rateLimited", + }); + expect(parseUploadResponse(500, "")).toMatchObject({ reason: "generic" }); + }); +}); diff --git a/src/components/documents/document-kind-meta.ts b/src/components/documents/document-kind-meta.ts new file mode 100644 index 000000000..31569128c --- /dev/null +++ b/src/components/documents/document-kind-meta.ts @@ -0,0 +1,48 @@ +/** + * The vault's kind vocabulary: one Lucide glyph per document kind, in the + * fixed display order the type-chip rail and the cards share. Icons are + * passed as components — the consuming primitive owns size and colour per + * the design standards. + */ +import type { ComponentType } from "react"; +import { + ArrowRightLeft, + File, + FileText, + FlaskConical, + Pill, + ScanLine, + ShieldCheck, + Stethoscope, + Syringe, +} from "lucide-react"; + +import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; + +/** Display order for the type-chip rail (everyday kinds first, OTHER last). */ +export const DOCUMENT_KIND_ORDER: readonly InboundDocumentKindValue[] = [ + "DOCTOR_REPORT", + "LAB_RESULT", + "IMAGING", + "DISCHARGE_LETTER", + "PRESCRIPTION", + "REFERRAL", + "VACCINATION", + "INSURANCE", + "OTHER", +]; + +export const DOCUMENT_KIND_ICONS: Record< + InboundDocumentKindValue, + ComponentType<{ className?: string }> +> = { + DOCTOR_REPORT: Stethoscope, + DISCHARGE_LETTER: FileText, + LAB_RESULT: FlaskConical, + IMAGING: ScanLine, + PRESCRIPTION: Pill, + REFERRAL: ArrowRightLeft, + INSURANCE: ShieldCheck, + VACCINATION: Syringe, + OTHER: File, +}; diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts new file mode 100644 index 000000000..fb5f96f50 --- /dev/null +++ b/src/components/documents/vault-utils.ts @@ -0,0 +1,305 @@ +/** + * Pure helpers for the Dokumente vault surface: URL-facet parsing and + * serialisation (the page's filter state lives in the URL so every view is + * deep-linkable and back-button-safe), the API query-string builder, the + * month bucketing + row chunking the virtualized timeline renders from, the + * upload-response contract (including the HTTP-200 `meta.duplicate` path), + * and byte formatting. Kept out of the client components so they stay + * trivially unit-testable without a render harness. + */ +import { + DOCUMENT_LIST_DEFAULT_LIMIT, + INBOUND_DOCUMENT_KINDS, + type InboundDocumentDto, + type InboundDocumentKindValue, +} from "@/lib/validations/inbound-documents"; +import type { DocumentVaultFilters } from "@/lib/query-keys/documents"; + +const KIND_SET = new Set(INBOUND_DOCUMENT_KINDS); + +/** + * Parse the vault's URL search params (`?q&kind&episode&year`) into the + * filter object. Unknown kinds are dropped (a hand-edited URL never 422s the + * page), `kind` accepts repeats AND comma-separated values, and the result is + * normalised (kinds sorted, empties omitted) so the same view always produces + * the same TanStack cache key. + */ +export function parseVaultSearchParams( + params: URLSearchParams, +): DocumentVaultFilters { + const filters: DocumentVaultFilters = {}; + + const q = params.get("q")?.trim(); + if (q) filters.q = q.slice(0, 100); + + const kinds = params + .getAll("kind") + .flatMap((v) => v.split(",")) + .map((v) => v.trim()) + .filter((v): v is InboundDocumentKindValue => KIND_SET.has(v)); + if (kinds.length > 0) filters.kinds = [...new Set(kinds)].sort(); + + const episode = params.get("episode")?.trim(); + if (episode) filters.episodeId = episode.slice(0, 40); + + const yearRaw = params.get("year"); + if (yearRaw && /^\d{4}$/.test(yearRaw)) { + const year = Number(yearRaw); + if (year >= 1900 && year <= 9999) filters.year = year; + } + + return filters; +} + +/** + * Serialise the filter object back into the page URL's search string (no + * leading `?`; empty string for the default view). Inverse of + * `parseVaultSearchParams` — round-tripping is pinned by test so deep links + * from the illness page / labs (`?episode=`, `?kind=LAB_RESULT`) stay stable. + */ +export function vaultFiltersToSearch(filters: DocumentVaultFilters): string { + const sp = new URLSearchParams(); + if (filters.q) sp.set("q", filters.q); + if (filters.kinds && filters.kinds.length > 0) { + sp.set("kind", [...filters.kinds].sort().join(",")); + } + if (filters.episodeId) sp.set("episode", filters.episodeId); + if (filters.year !== undefined) sp.set("year", String(filters.year)); + return sp.toString(); +} + +/** Count of active facets (search counts as one) for the clear-all pill. */ +export function countActiveFilters(filters: DocumentVaultFilters): number { + let count = 0; + if (filters.q) count += 1; + if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length; + if (filters.episodeId) count += 1; + if (filters.year !== undefined) count += 1; + return count; +} + +/** + * Build the `/api/documents/inbound` query string from the active facets and + * a keyset cursor. The sort is pinned to the filing date, newest first — the + * timeline IS the sort order. The API takes `episodeId` (the page URL uses + * the shorter `episode`). + */ +export function buildVaultListApiSearch( + filters: DocumentVaultFilters, + cursor: string | null, + limit: number = DOCUMENT_LIST_DEFAULT_LIMIT, +): string { + const sp = new URLSearchParams(); + if (filters.q) sp.set("q", filters.q); + if (filters.kinds && filters.kinds.length > 0) { + sp.set("kind", filters.kinds.join(",")); + } + if (filters.episodeId) sp.set("episodeId", filters.episodeId); + if (filters.year !== undefined) sp.set("year", String(filters.year)); + sp.set("sort", "documentDate"); + sp.set("order", "desc"); + sp.set("limit", String(limit)); + if (cursor) sp.set("cursor", cursor); + return sp.toString(); +} + +/** The date a document files under — its user filing date, else upload day. */ +export function documentDateKey(doc: InboundDocumentDto): string { + if (doc.documentDate) return doc.documentDate; + // createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day. + return doc.createdAt.slice(0, 10); +} + +/** The YYYY-MM month bucket a document files under. */ +export function documentMonthKey(doc: InboundDocumentDto): string { + return documentDateKey(doc).slice(0, 7); +} + +/** + * One flat item in the virtualized timeline: a month section label or one + * grid row of up to `columns` documents. The virtualizer windows over this + * flat list so the mounted DOM stays bounded regardless of corpus size. + */ +export type TimelineItem = + | { type: "month"; key: string } + | { type: "row"; key: string; documents: InboundDocumentDto[] }; + +/** + * Bucket an already-sorted document list into consecutive YYYY-MM sections + * and chunk each section into rows of `columns` documents. Order is preserved + * exactly as received — the server stays the single source of truth for + * sorting; a month opens the first time its key appears. + */ +export function buildTimelineItems( + documents: InboundDocumentDto[], + columns: number, +): TimelineItem[] { + const cols = Math.max(1, columns); + const items: TimelineItem[] = []; + let month: string | null = null; + let row: InboundDocumentDto[] = []; + + const flushRow = () => { + if (row.length > 0 && month !== null) { + items.push({ type: "row", key: `${month}:${row[0].id}`, documents: row }); + row = []; + } + }; + + for (const doc of documents) { + const key = documentMonthKey(doc); + if (key !== month) { + flushRow(); + month = key; + items.push({ type: "month", key }); + } + row.push(doc); + if (row.length === cols) flushRow(); + } + flushRow(); + return items; +} + +/** + * Format a YYYY-MM month key as a locale month label ("März 2026"). Handed + * over as a noon-UTC instant so the month never shifts under the display + * timezone. + */ +export function formatMonthLabel(key: string, locale: string): string { + if (!/^\d{4}-\d{2}$/.test(key)) return key; + return new Intl.DateTimeFormat(locale, { + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(new Date(`${key}-15T12:00:00.000Z`)); +} + +/** Locale byte formatting: 0 B / 412 KB / 1.2 MB / 2.4 GB. */ +export function formatBytes(bytes: number, locale: string): string { + const units = ["B", "KB", "MB", "GB"] as const; + let value = Math.max(0, bytes); + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + const digits = unit === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 1; + return `${new Intl.NumberFormat(locale, { + maximumFractionDigits: digits, + }).format(value)} ${units[unit]}`; +} + +// ─── Upload response contract (§3.2) ─────────────────────────────────────── + +/** + * The §3.2 failure reasons the upload/restore paths translate client-side, + * plus the transport-level fallbacks. `rateLimited` maps 429; `generic` + * covers everything unclassifiable (proxy-stripped bodies included). + */ +export type UploadFailureReason = + | "fileTooLarge" + | "quotaExceeded" + | "unsupportedType" + | "purged" + | "duplicateExists" + | "rateLimited" + | "generic"; + +export interface UploadFailure { + ok: false; + reason: UploadFailureReason; + /** §3.2 — present on `fileTooLarge`. */ + maxFileBytes?: number; + /** §3.2 — present on `quotaExceeded`. */ + quotaBytes?: number; + usedBytes?: number; + /** §3.2 — present on `duplicateExists` (restore-conflict shape). */ + existingId?: string; +} + +export interface UploadSuccess { + ok: true; + document: InboundDocumentDto; + /** + * §3.2 — a same-user re-upload is HTTP 200 with envelope-level + * `meta.duplicate: true` and the EXISTING live row as `data`. Not an + * error: the UI toasts "already stored" and highlights that row. + */ + duplicate: boolean; +} + +export type UploadResult = UploadSuccess | UploadFailure; + +const REASONS: ReadonlySet = new Set([ + "fileTooLarge", + "quotaExceeded", + "unsupportedType", + "purged", + "duplicateExists", +]); + +function numberOrUndefined(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} + +/** Map a `{ status, meta }` failure onto the translated-reason union. */ +export function classifyUploadFailure( + status: number, + meta: Record | undefined, +): UploadFailure { + const reason = typeof meta?.reason === "string" ? meta.reason : ""; + if (REASONS.has(reason)) { + return { + ok: false, + reason: reason as UploadFailureReason, + maxFileBytes: numberOrUndefined(meta?.maxFileBytes), + quotaBytes: numberOrUndefined(meta?.quotaBytes), + usedBytes: numberOrUndefined(meta?.usedBytes), + existingId: + typeof meta?.existingId === "string" ? meta.existingId : undefined, + }; + } + // A reverse proxy can strip the JSON body (and its `meta`); fall back to + // the HTTP status so size / type / rate-limit still classify. + if (status === 413) return { ok: false, reason: "fileTooLarge" }; + if (status === 415) return { ok: false, reason: "unsupportedType" }; + if (status === 429) return { ok: false, reason: "rateLimited" }; + return { ok: false, reason: "generic" }; +} + +/** + * Parse a completed upload's raw response (status + body text) against the + * envelope contract. This is the ONE place the duplicate contract lives: + * unlike every other read (which unwraps `data` and ignores `meta`), the + * upload MUST also read the success envelope's `meta.duplicate` flag. + */ +export function parseUploadResponse( + status: number, + bodyText: string, +): UploadResult { + let envelope: { + data?: unknown; + meta?: Record; + } | null = null; + try { + const parsed: unknown = JSON.parse(bodyText); + if (parsed && typeof parsed === "object") { + envelope = parsed as { data?: unknown; meta?: Record }; + } + } catch { + envelope = null; + } + + if (status >= 200 && status < 300) { + const doc = envelope?.data; + if (doc && typeof doc === "object" && "id" in doc) { + return { + ok: true, + document: doc as InboundDocumentDto, + duplicate: envelope?.meta?.duplicate === true, + }; + } + return { ok: false, reason: "generic" }; + } + return classifyUploadFailure(status, envelope?.meta); +} diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index 73774354f..f86b147a5 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -1,42 +1,47 @@ /** - * Query keys — clinical documents library (v1.25, W-DOCS-IN): the browsable - * document list (search / category filter / sort, keyset-paginated) and a - * single document's detail (document + staged facts). Part of the centralized - * factory; aggregated in `./index.ts`. Any document write (upload / edit / - * extract / confirm / discard) invalidates the `["documents"]` prefix so the - * list and the open detail repaint in lockstep. + * Query keys — the Dokumente vault: the filtered timeline list (keyset- + * paginated), a single document's detail, and the usage/limits read the + * upload path pre-flights against. Part of the centralized factory; + * aggregated in `./index.ts`. Any document write (upload / edit / link / + * delete / restore / bulk) invalidates the `["documents"]` prefix so the + * timeline, an open detail sheet, and the quota bar repaint in lockstep. */ -import type { - DocumentListSort, - InboundDocumentKindValue, -} from "@/lib/validations/inbound-documents"; +import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; /** - * The parameter set that distinguishes one library list view from another. - * Lives in the key so two different filter/sort combinations never share a - * cache bucket. `undefined` fields are dropped by TanStack's stable hash, so a - * bare `{ sort, order }` and `{ q: undefined, kind: undefined, sort, order }` - * resolve to the same key. + * The parameter set that distinguishes one vault timeline view from another — + * mirrors the URL facets (`?q&kind&episode&year`) one-to-one so a deep link + * and an in-page filter change resolve to the same cache bucket. `undefined` + * fields are dropped by TanStack's stable hash, so an all-defaults view and + * an explicit `{ q: undefined, … }` share a key. Sort is pinned server-side + * (filing date, newest first) and is deliberately NOT part of the key. */ -export interface DocumentListParams { +export interface DocumentVaultFilters { q?: string; - kind?: InboundDocumentKindValue; - from?: string; - to?: string; - sort: DocumentListSort; - order: "asc" | "desc"; + /** Type facet — OR inside the facet, sorted before keying by the caller. */ + kinds?: InboundDocumentKindValue[]; + /** Only documents linked to this illness/condition episode. */ + episodeId?: string; + /** Only documents whose filing date falls in this calendar year. */ + year?: number; } export const documentKeys = { documents: () => ["documents"] as const, inboundDocuments: () => ["documents", "inbound"] as const, /** - * The parameterised library list (keyset-paginated via `useInfiniteQuery`). + * The parameterised vault list (keyset-paginated via `useInfiniteQuery`). * The literal `"list"` segment keeps it distinct from a single-document key * (`inboundDocument(id)`); cuid ids never collide with it. Stays under the * `["documents", "inbound"]` prefix so prefix invalidation still reaches it. */ - inboundDocumentList: (params: DocumentListParams) => - ["documents", "inbound", "list", params] as const, + inboundDocumentList: (filters: DocumentVaultFilters) => + ["documents", "inbound", "list", filters] as const, inboundDocument: (id: string) => ["documents", "inbound", id] as const, + /** + * Storage usage + effective limits (`GET /api/documents/inbound/usage`). + * Invalidated through the `["documents"]` prefix after every upload / + * delete so the quota bar tracks reality. + */ + inboundDocumentUsage: () => ["documents", "inbound", "usage"] as const, }; From 55367499858f904f3e314428bd1e3242bc41b1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:25:45 +0200 Subject: [PATCH 12/34] feat(documents): client upload queue with progress and duplicate handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One multipart XHR POST per file (real upload-progress events, no new dependency), concurrency capped at 3 to match the server's memory envelope, an Idempotency-Key per file so a retried blip cannot double-store, and a canvas downscale to 3000 px longest edge for raster photos only — PDFs, Office files, and GIFs upload verbatim. Entries appear synchronously before any network work; an over-limit file fails locally against the configured cap while the batch continues; a duplicate resolves to the existing row and hands its id to the timeline for a brief highlight. Plan-Task: W2-T4 (manager half) --- .../documents/use-document-upload.ts | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 src/components/documents/use-document-upload.ts diff --git a/src/components/documents/use-document-upload.ts b/src/components/documents/use-document-upload.ts new file mode 100644 index 000000000..8f7977a29 --- /dev/null +++ b/src/components/documents/use-document-upload.ts @@ -0,0 +1,282 @@ +"use client"; + +/** + * The vault's upload manager: a client-side queue with concurrency 3, one + * multipart XHR POST per file (XHR because it exposes real upload-progress + * events with zero dependencies — fetch upload streaming is not portable), + * client-side image downscaling, and per-file terminal states. + * + * Perceived-speed contract: `enqueue()` adds pending entries synchronously + * (< 100 ms — before any network work), each carrying live progress. A + * finished file leaves the queue and the list/usage queries refetch; a + * failed file stays as an inline error card until dismissed (an over-limit + * file fails individually, the batch continues). + * + * Duplicate contract (§3.2): a same-user re-upload returns HTTP 200 with + * envelope-level `meta.duplicate: true` and the EXISTING row — the manager + * toasts "already stored" and hands the row id to the timeline for a brief + * highlight. This is the one call site that must read success-side `meta`; + * `parseUploadResponse` in `vault-utils.ts` owns that parse. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { + parseUploadResponse, + type UploadFailure, + type UploadResult, +} from "./vault-utils"; + +/** Client-side parallelism — matches the plan's NAS memory envelope. */ +const CONCURRENCY = 3; + +/** Longest-edge ceiling for the client-side image downscale. */ +const IMAGE_MAX_EDGE = 3000; + +/** How long a duplicate-highlight ring stays on the existing row. */ +const HIGHLIGHT_MS = 2600; + +/** Only raster photo formats are re-encoded — never PDFs/Office, never GIF + * (re-encoding would flatten an animation and GIF photos don't exist). */ +const DOWNSCALE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]); + +export interface UploadQueueItem { + /** Local id — NOT a server id; stable for the item's queue lifetime. */ + localId: string; + fileName: string; + byteSize: number; + status: "uploading" | "error"; + /** 0..1 upload progress (transport bytes, not server processing). */ + progress: number; + /** Terminal failure detail, present when `status === "error"`. */ + failure?: UploadFailure; +} + +export interface EnqueueOptions { + /** Pre-link every uploaded document to this episode (deep-link uploads). */ + episodeId?: string; +} + +interface UploadLimits { + maxFileBytes: number; +} + +/** + * Downscale a raster photo to ≤ 3000 px longest edge (canvas, JPEG 0.85). + * Returns the original file untouched when the type is not a raster photo, + * the image is already small enough, or anything about decoding fails — + * a downscale must never block an upload. + */ +async function downscaleImage(file: File): Promise { + if (!DOWNSCALE_TYPES.has(file.type)) return file; + try { + const bitmap = await createImageBitmap(file); + const longest = Math.max(bitmap.width, bitmap.height); + if (longest <= IMAGE_MAX_EDGE) { + bitmap.close(); + return file; + } + const scale = IMAGE_MAX_EDGE / longest; + const width = Math.round(bitmap.width * scale); + const height = Math.round(bitmap.height * scale); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + bitmap.close(); + return file; + } + ctx.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/jpeg", 0.85), + ); + if (!blob) return file; + const stem = file.name.replace(/\.[^.]+$/u, "") || "photo"; + return new File([blob], `${stem}.jpg`, { type: "image/jpeg" }); + } catch { + return file; + } +} + +/** One multipart POST via XHR, reporting upload progress. */ +function uploadViaXhr( + file: File, + options: EnqueueOptions, + onProgress: (fraction: number) => void, +): Promise { + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/documents/inbound"); + // Retry-safe by construction: the server honours Idempotency-Key, so a + // network blip that DID land server-side cannot double-store on retry. + xhr.setRequestHeader("Idempotency-Key", crypto.randomUUID()); + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) { + onProgress(event.loaded / event.total); + } + }); + xhr.addEventListener("load", () => { + resolve(parseUploadResponse(xhr.status, xhr.responseText)); + }); + xhr.addEventListener("error", () => + resolve({ ok: false, reason: "generic" }), + ); + xhr.addEventListener("abort", () => + resolve({ ok: false, reason: "generic" }), + ); + const fd = new FormData(); + fd.append("file", file); + if (options.episodeId) fd.append("episodeIds", options.episodeId); + xhr.send(fd); + }); +} + +export interface DocumentUploadManager { + /** Live queue — uploading entries first-in-first, then error entries. */ + items: UploadQueueItem[]; + /** Add files to the queue; returns immediately (optimistic entries). */ + enqueue: (files: File[], options?: EnqueueOptions) => void; + /** Remove a terminal (error) entry from the queue. */ + dismiss: (localId: string) => void; + /** Row id to briefly highlight (duplicate upload → the existing row). */ + highlightId: string | null; +} + +export function useDocumentUpload( + limits: UploadLimits | undefined, +): DocumentUploadManager { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [items, setItems] = useState([]); + const [highlightId, setHighlightId] = useState(null); + + const activeRef = useRef(0); + const pendingRef = useRef< + { localId: string; file: File; options: EnqueueOptions }[] + >([]); + const mountedRef = useRef(true); + const highlightTimer = useRef | null>(null); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (highlightTimer.current) clearTimeout(highlightTimer.current); + }; + }, []); + + const patchItem = useCallback( + (localId: string, patch: Partial) => { + if (!mountedRef.current) return; + setItems((prev) => + prev.map((it) => (it.localId === localId ? { ...it, ...patch } : it)), + ); + }, + [], + ); + + const removeItem = useCallback((localId: string) => { + if (!mountedRef.current) return; + setItems((prev) => prev.filter((it) => it.localId !== localId)); + }, []); + + const flashHighlight = useCallback((id: string) => { + if (!mountedRef.current) return; + setHighlightId(id); + if (highlightTimer.current) clearTimeout(highlightTimer.current); + highlightTimer.current = setTimeout(() => { + if (mountedRef.current) setHighlightId(null); + }, HIGHLIGHT_MS); + }, []); + + const settle = useCallback( + (localId: string, result: UploadResult) => { + if (result.ok) { + removeItem(localId); + void invalidateKeys(queryClient, [queryKeys.documents()]); + if (result.duplicate) { + toast.info(t("documents.toast.duplicate")); + flashHighlight(result.document.id); + } + return; + } + patchItem(localId, { status: "error", failure: result, progress: 0 }); + }, + [flashHighlight, patchItem, queryClient, removeItem, t], + ); + + const pump = useCallback(() => { + while ( + activeRef.current < CONCURRENCY && + pendingRef.current.length > 0 && + mountedRef.current + ) { + const next = pendingRef.current.shift(); + if (!next) return; + activeRef.current += 1; + void (async () => { + try { + const prepared = await downscaleImage(next.file); + const result = await uploadViaXhr(prepared, next.options, (f) => + patchItem(next.localId, { progress: f }), + ); + settle(next.localId, result); + } finally { + activeRef.current -= 1; + pump(); + } + })(); + } + }, [patchItem, settle]); + + const enqueue = useCallback( + (files: File[], options: EnqueueOptions = {}) => { + if (files.length === 0) return; + const fresh: UploadQueueItem[] = []; + for (const file of files) { + const localId = crypto.randomUUID(); + // Client pre-flight against the server's configured cap: an obvious + // giant (a CD image) fails instantly and locally — friendly reason, + // no wasted transfer. The server re-enforces regardless. + if (limits && file.size > limits.maxFileBytes) { + fresh.push({ + localId, + fileName: file.name, + byteSize: file.size, + status: "error", + progress: 0, + failure: { + ok: false, + reason: "fileTooLarge", + maxFileBytes: limits.maxFileBytes, + }, + }); + continue; + } + fresh.push({ + localId, + fileName: file.name, + byteSize: file.size, + status: "uploading", + progress: 0, + }); + pendingRef.current.push({ localId, file, options }); + } + setItems((prev) => [...prev, ...fresh]); + pump(); + }, + [limits, pump], + ); + + const dismiss = useCallback( + (localId: string) => removeItem(localId), + [removeItem], + ); + + return { items, enqueue, dismiss, highlightId }; +} From 53d0d8cc33eadba4e77ca6c458f4ad5da24d712e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:26:06 +0200 Subject: [PATCH 13/34] feat(documents): virtualized month timeline and document cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeline windows a flat item list (month labels + chunked card rows) with @tanstack/react-virtual over the shell's scroll container — single scroll owner, bounded DOM at four-digit corpus sizes. Columns are measured by a ResizeObserver so the row model and the painted grid never disagree. Cards are compact-density: kind glyph, foreground title, one muted meta line, condition pills, a download-only badge for attachment- class formats, and a hover/focus selection checkbox above the whole-card click target. In-flight and failed uploads share the same footprint with a live progress ring and translated failure copy. Plan-Task: W2-T2, W2-T4 (upload cards) --- package.json | 1 + pnpm-lock.yaml | 20 ++ .../__tests__/document-card.test.tsx | 155 +++++++++++ src/components/documents/document-card.tsx | 249 ++++++++++++++++++ .../documents/document-timeline.tsx | 223 ++++++++++++++++ 5 files changed, 648 insertions(+) create mode 100644 src/components/documents/__tests__/document-card.test.tsx create mode 100644 src/components/documents/document-card.tsx create mode 100644 src/components/documents/document-timeline.tsx diff --git a/package.json b/package.json index 4b0145bdc..6d0726baf 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.1", "@tanstack/react-query": "5.101.2", + "@tanstack/react-virtual": "^3.14.5", "@types/sax": "^1.2.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a23c7c62a..4772a0b7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@tanstack/react-query': specifier: 5.101.2 version: 5.101.2(react@19.2.7) + '@tanstack/react-virtual': + specifier: ^3.14.5 + version: 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/sax': specifier: ^1.2.7 version: 1.2.7 @@ -2868,6 +2871,15 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.14.5': + resolution: {integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.3': + resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} + '@testcontainers/postgresql@12.0.3': resolution: {integrity: sha512-y0l/ItWxNfdMU783EOZ66lMq90uuuXPqE+fo1jNSRfMbtfUq/9Ez/wluDxxaS+D/i3I0juep+jmz/WVuip8i9Q==} @@ -9450,6 +9462,14 @@ snapshots: '@tanstack/query-core': 5.101.2 react: 19.2.7 + '@tanstack/react-virtual@3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/virtual-core@3.17.3': {} + '@testcontainers/postgresql@12.0.3': dependencies: testcontainers: 12.0.3 diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx new file mode 100644 index 000000000..df7c5d249 --- /dev/null +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import { DocumentCard, UploadStateCard } from "../document-card"; +import type { UploadQueueItem } from "../use-document-upload"; + +/** + * The vault card's render contract: title falls back filename → "untitled", + * the meta line stays muted, the attachment-class badge appears ONLY for + * download-only formats, condition links paint as pills, and the two + * transient upload cards (in-flight ring / translated failure) render from + * the same footprint. + */ + +function doc(overrides: Partial = {}): InboundDocumentDto { + return { + id: "doc-1", + kind: "IMAGING", + title: "MRT Knie", + filename: "mrt-knie.pdf", + mimeType: "application/pdf", + byteSize: 2_500_000, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: "2025-10-04", + errorReason: null, + factCount: 0, + pendingCount: 0, + conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }], + servingClass: "inline", + createdAt: "2025-10-05T08:00:00.000Z", + updatedAt: "2025-10-05T08:00:00.000Z", + ...overrides, + }; +} + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const noop = () => {}; + +describe("", () => { + it("renders title, condition pill, and NO attachment badge for inline docs", () => { + const html = render( + , + ); + expect(html).toContain("MRT Knie"); + expect(html).toContain("Knie"); + expect(html).toContain('data-document-id="doc-1"'); + expect(html).not.toContain("Download only"); + }); + + it("shows the download-only badge for attachment-class documents", () => { + const html = render( + , + ); + expect(html).toContain("Download only"); + // Title falls back to the filename. + expect(html).toContain("befund.docx"); + }); + + it("falls back to the untitled label and rings when highlighted", () => { + const html = render( + , + ); + expect(html).toContain("Untitled document"); + expect(html).toContain("ring-2"); + }); +}); + +describe("", () => { + const base: UploadQueueItem = { + localId: "u1", + fileName: "scan.jpg", + byteSize: 4_000_000, + status: "uploading", + progress: 0.4, + }; + + it("paints the progress ring while uploading", () => { + const html = render(); + expect(html).toContain('role="progressbar"'); + expect(html).toContain('aria-valuenow="40"'); + expect(html).toContain("scan.jpg"); + }); + + it("renders the translated over-limit reason with the configured cap", () => { + const html = render( + , + ); + expect(html).toContain('role="alert"'); + expect(html).toContain("25 MB"); + expect(html).not.toContain('role="progressbar"'); + }); + + it("renders the quota reason with used/quota figures", () => { + const html = render( + , + ); + expect(html).toContain("Storage is full"); + expect(html).toContain("1 GB"); + }); +}); diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx new file mode 100644 index 000000000..f54f9c571 --- /dev/null +++ b/src/components/documents/document-card.tsx @@ -0,0 +1,249 @@ +"use client"; + +/** + * One document in the vault timeline (compact-density Card) plus the two + * transient upload-state cards (in-flight with a live progress ring, failed + * with the translated §3.2 reason). All three share the card footprint so an + * optimistic entry morphs into the stored row without the grid jumping. + * + * Anatomy per the design standards: kind icon `text-foreground size-5`, + * title in foreground (`text-sm font-medium`, truncated), one muted meta + * line (date · size · filename), condition-tag pills, an attachment-class + * badge for download-only formats, and a selection checkbox that appears on + * hover / focus / while selected. The whole card is clickable through an + * invisible overlay button; the checkbox floats above it. + */ +import { Download, X } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; +import type { UploadQueueItem } from "./use-document-upload"; +import { documentDateKey, formatBytes } from "./vault-utils"; + +export function DocumentCard({ + document, + selected, + onToggleSelected, + onOpen, + highlighted, + onPrefetch, +}: { + document: InboundDocumentDto; + selected: boolean; + onToggleSelected: (id: string) => void; + onOpen: (id: string) => void; + /** Brief ring after a duplicate upload resolved to this existing row. */ + highlighted: boolean; + /** Hover/focus intent — prefetches the detail metadata (never the blob). */ + onPrefetch?: (id: string) => void; +}) { + const { t, locale } = useTranslations(); + const format = useFormatters(); + + const title = + document.title ?? document.filename ?? t("documents.card.untitled"); + const Icon = DOCUMENT_KIND_ICONS[document.kind]; + const date = format.date(`${documentDateKey(document)}T12:00:00.000Z`); + const size = formatBytes(document.byteSize, locale); + const showFilename = + document.filename !== null && document.filename !== title; + + return ( + + +
+ +

{title}

+ onToggleSelected(document.id)} + aria-label={t("documents.card.selectLabel", { title })} + className={cn( + "relative z-10 shrink-0 transition-opacity", + "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100", + selected && "opacity-100", + )} + /> +
+

+ {date} · {size} + {showFilename ? ` · ${document.filename}` : ""} +

+ {document.conditionLinks.length > 0 || + document.servingClass === "attachment" ? ( +
+ {document.conditionLinks.map((link) => ( + + {link.name} + + ))} + {document.servingClass === "attachment" ? ( + + + {t("documents.card.attachmentBadge")} + + ) : null} +
+ ) : null} +
+ {/* Whole-card click target. Painted last so it sits above the content + (the checkbox stays reachable via its own z-10). */} + + ) : null} + + {failureCopy ? ( +

+ {failureCopy} +

+ ) : null} + +
+ ); +} diff --git a/src/components/documents/document-timeline.tsx b/src/components/documents/document-timeline.tsx new file mode 100644 index 000000000..3cd3aed8c --- /dev/null +++ b/src/components/documents/document-timeline.tsx @@ -0,0 +1,223 @@ +"use client"; + +/** + * The vault timeline: a virtualized, month-sectioned card grid windowed + * with `@tanstack/react-virtual` over the shell's scroll container + * (`#main-content` — single scroll owner per the design standards; the + * timeline never brings its own scrollport). The flat item list (month + * labels + chunked card rows) comes from `buildTimelineItems`, so the + * mounted DOM stays bounded (< ~400 nodes) regardless of corpus size. + * + * Columns are measured, not breakpoint-classed: a ResizeObserver on the + * grid container drives the per-row chunking (4 / 3 / 2 / 1), which keeps + * the virtualizer's row model and the painted grid in lockstep. + * + * In-flight / failed uploads render as a small non-virtualized grid above + * the timeline — they are few (client concurrency 3) and must appear + * < 100 ms after file selection, before any query settles. + */ +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; + +import { useTranslations } from "@/lib/i18n/context"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import { DocumentCard, UploadStateCard } from "./document-card"; +import type { UploadQueueItem } from "./use-document-upload"; +import { buildTimelineItems, formatMonthLabel } from "./vault-utils"; + +const SCROLL_CONTAINER_ID = "main-content"; + +/** Measured-width → column count (desktop 4/3, tablet 2, phone 1). */ +function columnsForWidth(width: number): number { + if (width >= 1200) return 4; + if (width >= 900) return 3; + if (width >= 600) return 2; + return 1; +} + +export function DocumentTimeline({ + documents, + uploadItems, + onDismissUpload, + hasNextPage, + isFetchingNextPage, + onLoadMore, + selectedIds, + onToggleSelected, + onOpen, + highlightId, + onPrefetch, +}: { + documents: InboundDocumentDto[]; + uploadItems: UploadQueueItem[]; + onDismissUpload: (localId: string) => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; + selectedIds: ReadonlySet; + onToggleSelected: (id: string) => void; + onOpen: (id: string) => void; + highlightId: string | null; + onPrefetch?: (id: string) => void; +}) { + const { t, locale } = useTranslations(); + const listRef = useRef(null); + const [columns, setColumns] = useState(1); + const [scrollMargin, setScrollMargin] = useState(0); + + // Measured columns — the ResizeObserver drives the row chunking. + useEffect(() => { + const el = listRef.current; + if (!el) return; + const update = () => setColumns(columnsForWidth(el.clientWidth)); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const items = useMemo( + () => buildTimelineItems(documents, columns), + [documents, columns], + ); + + // The timeline does not start at the scrollport's top edge (page header, + // filter bar, upload row sit above it) — feed the offset to the + // virtualizer so window positions line up. Re-measured when the content + // above changes height (upload cards appearing/leaving) and on resize. + useEffect(() => { + const scrollEl = document.getElementById(SCROLL_CONTAINER_ID); + const listEl = listRef.current; + if (!scrollEl || !listEl) return; + const measure = () => { + const margin = + listEl.getBoundingClientRect().top - + scrollEl.getBoundingClientRect().top + + scrollEl.scrollTop; + setScrollMargin(margin); + }; + measure(); + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, [uploadItems.length, columns]); + + // The React Compiler cannot memoize across TanStack Virtual's instance + // API (library-level opt-out, not a fixable call-site problem) — the + // windowing still works, the compiler just skips this component. + // eslint-disable-next-line react-hooks/incompatible-library + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => document.getElementById(SCROLL_CONTAINER_ID), + estimateSize: (index) => (items[index].type === "month" ? 40 : 140), + getItemKey: (index) => items[index].key, + overscan: 6, + scrollMargin, + }); + + const virtualItems = virtualizer.getVirtualItems(); + + // Keyset infinite feed: pull the next page when the window nears the end. + const lastIndex = virtualItems[virtualItems.length - 1]?.index ?? -1; + useEffect(() => { + if ( + hasNextPage && + !isFetchingNextPage && + lastIndex >= items.length - columns * 3 - 1 + ) { + onLoadMore(); + } + }, [ + lastIndex, + items.length, + columns, + hasNextPage, + isFetchingNextPage, + onLoadMore, + ]); + + return ( +
+ {uploadItems.length > 0 ? ( +
+ {uploadItems.map((item) => ( + + ))} +
+ ) : null} + +
+
+ {virtualItems.map((virtualItem) => { + const item = items[virtualItem.index]; + return ( +
+ {item.type === "month" ? ( +

+ {formatMonthLabel(item.key, locale)} +

+ ) : ( +
+ {item.documents.map((doc) => ( + + ))} +
+ )} +
+ ); + })} +
+
+ + {isFetchingNextPage ? ( +
+ + {t("documents.timeline.loadingMore")} +
+ ) : null} +
+ ); +} From 4014d77a2189bd6dd230e00cdc151a07c197f90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:26:06 +0200 Subject: [PATCH 14/34] feat(documents): detail sheet with inline preview and undo-able delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResponsiveSheet (bottom sheet on phones, dialog on desktop): inline-class documents preview against the owner-scoped serve route — skeleton first, internal scroll only; attachment-class documents get no fake preview, just glyph, filename, size, and a prominent Download. Title edits click-to-edit and commit on Enter/blur, kind and filing date save on change, condition links PATCH as a replace-set with chips navigating to the episode page. Delete soft-deletes behind an Undo toast; a refused restore translates the purged / duplicate reasons. Mutation errors stay inline. No share affordance — deliberately. Plan-Task: W2-T3 --- .../documents/document-detail-sheet.tsx | 463 ++++++++++++++++++ 1 file changed, 463 insertions(+) create mode 100644 src/components/documents/document-detail-sheet.tsx diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx new file mode 100644 index 000000000..e376ba082 --- /dev/null +++ b/src/components/documents/document-detail-sheet.tsx @@ -0,0 +1,463 @@ +"use client"; + +/** + * Document detail on `ResponsiveSheet` (bottom sheet on phones, dialog on + * desktop). Inline-class documents (PDF / browser-native images) preview + * inline against the owner-scoped decrypt-and-serve route — skeleton first, + * content on load, internal scroll only (`overscroll-contain`). Attachment- + * class documents get NO fake preview: type glyph, filename, size, and a + * prominent Download button. + * + * Metadata edits save on commit (title on Enter/blur, kind and filing date + * on change, condition links on toggle — replace-set PATCH). Mutation + * errors surface inline (`role="alert"`), never as a QueryErrorCard. + * Delete soft-deletes with an Undo toast (restore endpoint); a restore + * refusal maps `meta.reason` (`purged`, `duplicateExists`) to translated + * copy. No share affordance anywhere — deliberately. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Download, Pencil, Plus, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { useIllnessEpisodes } from "@/components/illness/use-illness"; +import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + apiDelete, + ApiError, + apiGet, + apiPatch, + apiPost, +} from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; +import { + INBOUND_DOCUMENT_KINDS, + type InboundDocumentDetailDto, + type InboundDocumentKindValue, +} from "@/lib/validations/inbound-documents"; +import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; +import { formatBytes } from "./vault-utils"; + +type PatchInput = { + title?: string | null; + kind?: InboundDocumentKindValue; + documentDate?: string | null; + episodeIds?: string[]; +}; + +/** Inline preview for Class A documents; skeleton until the blob paints. */ +function InlinePreview({ + documentId, + mimeType, + title, +}: { + documentId: string; + mimeType: string; + title: string; +}) { + const [loaded, setLoaded] = useState(false); + const src = `/api/documents/inbound/${documentId}/original`; + const isPdf = mimeType === "application/pdf"; + + return ( +
+ {!loaded ? ( + + ) : null} + {isPdf ? ( +