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] 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[]; +}