diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3cdd13167..77784d252 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8595,12 +8595,82 @@ paths: get: tags: - Documents - summary: List inbound clinical documents - description: The caller's uploaded doctor reports / discharge letters (newest first, live only). Owner-scoped; gated on - the opt-in `inboundDocuments` module. + summary: List stored documents + description: The caller's stored documents with title/filename search, category filter, a `documentDate` range, sort, + and keyset pagination. Owner-scoped; gated on the opt-in `inboundDocuments` module. + parameters: + - name: q + in: query + required: false + schema: + type: string + maxLength: 100 + description: Case-insensitive search over title + filename. + - name: kind + in: query + required: false + schema: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION + - OTHER + description: Category filter. + - name: from + in: query + required: false + schema: + type: string + format: date + description: Inclusive lower bound on documentDate (YYYY-MM-DD). + - name: to + in: query + required: false + schema: + type: string + format: date + description: Inclusive upper bound on documentDate (YYYY-MM-DD). + - name: sort + in: query + required: false + schema: + type: string + enum: + - documentDate + - createdAt + - title + default: documentDate + - name: order + in: query + required: false + schema: + type: string + enum: + - asc + - desc + default: desc + - name: cursor + in: query + required: false + schema: + type: string + description: Keyset cursor (the `nextCursor` from a prior page). + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 responses: "200": - description: The document list. + description: A page of documents. content: application/json: schema: @@ -8611,14 +8681,12 @@ paths: post: tags: - Documents - summary: Upload + extract a clinical document - description: 'Ingests a doctor report / discharge letter through the dedicated OCR/vision provider, stores the raw - document ENCRYPTED at rest, and stages the extracted STRUCTURED FACTS for review. Two modes by content-type. - VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) plus - an optional `kind`. TEXT (`application/json`, opt-in local OCR): `{ mode: "text", text, kind? }` — only the - extracted text reaches the server. Both modes are AI-consent / rate / budget gated. Extraction reproduces what - the document states (stated codes/status only) — it never interprets. Nothing reaches the structured stores - here; the confirm route is the only write path.' + 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`." requestBody: required: true content: @@ -8629,22 +8697,30 @@ paths: file: type: string format: binary - description: The clinical-document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type. + description: The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type. + title: + description: Optional user label. + type: string kind: - description: Optional document-type label. + description: Optional category. type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION - OTHER + documentDate: + description: Optional user filing date (YYYY-MM-DD). + type: string required: - file - application/json: - schema: - $ref: "#/components/schemas/InboundTextUploadRequest" responses: "201": - description: The created document + staging summary. + description: The stored document. content: application/json: schema: @@ -8674,10 +8750,60 @@ paths: "401": *a1 "422": *a3 "429": *a4 + patch: + tags: + - Documents + summary: Edit a document's metadata + description: Rename / recategorise / set the user filing date on a stored document. Owner-scoped; no mass assignment. + Returns the updated document + its staged facts. + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + anyOf: + - type: string + maxLength: 200 + - type: "null" + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION + - OTHER + documentDate: + anyOf: + - type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - type: "null" + responses: + "200": + description: The updated document. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDocumentUpdateEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 delete: tags: - Documents - summary: Discard an inbound document + summary: Discard a document description: Soft-deletes the document + its staging. Facts already approved into the structured stores are independent rows and are NOT affected. parameters: @@ -8730,6 +8856,40 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/documents/inbound/{id}/extract: + post: + tags: + - Documents + summary: Extract facts from a stored document + description: "Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the + stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider + configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only + the enhancement fails. Two modes by content-type: VISION (empty body) decrypts and scans the stored original + (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }` + structures browser-OCR'd text. Extraction reproduces what the document states — it never interprets. Nothing + reaches the structured stores here; the confirm route is the only write path." + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/InboundTextExtractRequest" + responses: + "200": + description: The document with its newly staged facts. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundExtractEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -10663,7 +10823,7 @@ components: required: - mode - text - InboundTextUploadRequest: + InboundTextExtractRequest: type: object properties: mode: @@ -10676,6 +10836,11 @@ components: enum: - DOCTOR_REPORT - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION - OTHER required: - mode @@ -24941,9 +25106,16 @@ components: type: array items: $ref: "#/components/schemas/InboundDocument" + nextCursor: + anyOf: + - type: string + - type: "null" required: - documents + - nextCursor additionalProperties: false + description: A page of documents plus an opaque `nextCursor` (the id to pass back as `cursor` for the next page; null + when the list is exhausted). InboundDocument: type: object properties: @@ -24954,7 +25126,16 @@ components: enum: - DOCTOR_REPORT - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION - OTHER + title: + anyOf: + - type: string + - type: "null" filename: anyOf: - type: string @@ -24966,6 +25147,7 @@ components: status: type: string enum: + - STORED - EXTRACTING - EXTRACTED - FAILED @@ -24979,6 +25161,10 @@ components: anyOf: - type: string - type: "null" + documentDate: + anyOf: + - type: string + - type: "null" errorReason: anyOf: - type: string @@ -24994,20 +25180,24 @@ components: required: - id - kind + - title - filename - mimeType - byteSize - status - providerType - reportDate + - documentDate - errorReason - factCount - pendingCount - createdAt - updatedAt additionalProperties: false - description: An uploaded clinical document. The raw bytes are stored encrypted at rest and never returned; this is the - metadata + staging summary. + 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). InboundDocumentEnvelope: type: object properties: @@ -25052,7 +25242,16 @@ components: enum: - DOCTOR_REPORT - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION - OTHER + title: + anyOf: + - type: string + - type: "null" filename: anyOf: - type: string @@ -25064,6 +25263,7 @@ components: status: type: string enum: + - STORED - EXTRACTING - EXTRACTED - FAILED @@ -25077,6 +25277,10 @@ components: anyOf: - type: string - type: "null" + documentDate: + anyOf: + - type: string + - type: "null" errorReason: anyOf: - type: string @@ -25096,12 +25300,14 @@ components: required: - id - kind + - title - filename - mimeType - byteSize - status - providerType - reportDate + - documentDate - errorReason - factCount - pendingCount @@ -25174,6 +25380,23 @@ components: 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). + InboundDocumentUpdateEnvelope: + 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 InboundDiscardEnvelope: type: object properties: @@ -25200,6 +25423,23 @@ components: - data - error additionalProperties: false + InboundExtractEnvelope: + 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 ExtractedFactEnvelope: type: object properties: diff --git a/prisma/migrations/0222_v125_documents_library/migration.sql b/prisma/migrations/0222_v125_documents_library/migration.sql new file mode 100644 index 000000000..8ee63ab32 --- /dev/null +++ b/prisma/migrations/0222_v125_documents_library/migration.sql @@ -0,0 +1,84 @@ +-- v1.25 — documents library: store-first, optional extraction. +-- +-- Inverts the inbound-documents surface from an OCR inbox into a document +-- library. A user can now STORE any file (encrypted at rest) without a +-- configured document-scan provider; extraction becomes an optional action +-- on an already-stored row. +-- +-- Schema deltas on `inbound_documents`: +-- * `title` — user-given label, plaintext (mirrors `filename`) so +-- the list can ILIKE-search + ORDER BY. The document +-- BODY stays encrypted; only this short user-authored +-- title is clear-text. +-- * `document_date` — user-set filing date (editable). Distinct from +-- `report_date`, which stays the model-transcribed +-- value. +-- * a `(user_id, document_date)` index for date-grouped browse + range +-- filter. +-- * `inbound_document_kind` gains LAB_RESULT / IMAGING / PRESCRIPTION / +-- INSURANCE / VACCINATION (the existing 3 stay; OTHER stays the fallback). +-- * `inbound_document_status` gains STORED, made the column DEFAULT (the +-- library default — uploaded, no extraction run). +-- +-- Additive; no existing row rewritten (the status type-swap maps every +-- existing label to itself). Nullable columns, no backfill: existing rows +-- keep their current status; `title` / `document_date` start NULL and resolve +-- through the display fallback. Idempotent guards so reruns are safe on prod. +-- +-- Postgres note: a freshly-added enum value cannot be USED (e.g. as a column +-- DEFAULT) in the same transaction it is added in. Because Prisma runs each +-- migration inside a transaction, STORED cannot be added with `ALTER TYPE ... +-- ADD VALUE` and then set as the default in the same file. The status enum is +-- therefore recreated (RENAME + CREATE with the full value set + column +-- type-swap + SET DEFAULT): a type CREATEd in the current transaction MAY +-- have its values used in that same transaction, which makes the default +-- change safe. The `kind` additions are not used in this migration, so the +-- cheap `ADD VALUE IF NOT EXISTS` form is used for them. +-- +-- Reversibility (down): +-- DROP INDEX IF EXISTS "inbound_documents_user_id_document_date_idx"; +-- ALTER TABLE "inbound_documents" DROP COLUMN IF EXISTS "document_date"; +-- ALTER TABLE "inbound_documents" DROP COLUMN IF EXISTS "title"; +-- -- (added enum values are inert if unused; a roll-back leaves them +-- -- harmless rather than rewriting the type back.) + +-- AlterTable: user-given title + user-set filing date (both nullable). +ALTER TABLE "inbound_documents" ADD COLUMN IF NOT EXISTS "title" TEXT; +ALTER TABLE "inbound_documents" ADD COLUMN IF NOT EXISTS "document_date" TIMESTAMP(3); + +-- AlterEnum: richer document-kind taxonomy (additive; not used in this +-- migration, so the new values are safe to add in-place). +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'LAB_RESULT'; +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'IMAGING'; +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'PRESCRIPTION'; +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'INSURANCE'; +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'VACCINATION'; + +-- AlterEnum: add STORED and make it the column default. Recreate the type so +-- the new value can be USED as the default in this same transaction (a +-- same-transaction CREATE TYPE permits same-transaction use of its values). +-- Guarded on STORED-absence so a rerun is a no-op. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = 'inbound_document_status' AND e.enumlabel = 'STORED' + ) THEN + ALTER TYPE "inbound_document_status" RENAME TO "inbound_document_status_old"; + CREATE TYPE "inbound_document_status" AS ENUM ( + 'STORED', 'EXTRACTING', 'EXTRACTED', 'FAILED', 'CONFIRMED', 'DISCARDED' + ); + ALTER TABLE "inbound_documents" ALTER COLUMN "status" DROP DEFAULT; + ALTER TABLE "inbound_documents" + ALTER COLUMN "status" TYPE "inbound_document_status" + USING ("status"::text::"inbound_document_status"); + ALTER TABLE "inbound_documents" ALTER COLUMN "status" SET DEFAULT 'STORED'; + DROP TYPE "inbound_document_status_old"; + END IF; +END $$; + +-- CreateIndex: date-grouped browse + document_date range filter. +CREATE INDEX IF NOT EXISTS "inbound_documents_user_id_document_date_idx" + ON "inbound_documents"("user_id", "document_date"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f10c6427f..fccb5fc20 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5527,20 +5527,29 @@ model CyclePrediction { // ───────────────────────────────────────────────────────────────────────── /// What kind of clinical document was uploaded (free choice by the user; -/// drives no interpretation — it is a label only). +/// drives no interpretation — it is a label only). The richer set +/// (lab/imaging/prescription/insurance/vaccination) makes the library +/// browsable by category; the value still drives no interpretation. enum InboundDocumentKind { DOCTOR_REPORT DISCHARGE_LETTER + LAB_RESULT + IMAGING + PRESCRIPTION + INSURANCE + VACCINATION OTHER @@map("inbound_document_kind") } -/// Lifecycle of an uploaded document. EXTRACTING while the provider runs, -/// EXTRACTED once facts are staged for review, FAILED on a provider/parse -/// miss, CONFIRMED once the user has acted on every fact, DISCARDED on a -/// soft delete. +/// Lifecycle of an uploaded document. STORED is the library default — the +/// file is uploaded + encrypted at rest with no extraction run. EXTRACTING +/// while the provider runs, EXTRACTED once facts are staged for review, +/// FAILED on a provider/parse miss, CONFIRMED once the user has acted on +/// every fact, DISCARDED on a soft delete. enum InboundDocumentStatus { + STORED EXTRACTING EXTRACTED FAILED @@ -5575,6 +5584,11 @@ model InboundDocument { id String @id @default(cuid()) userId String @map("user_id") kind InboundDocumentKind @default(OTHER) + /// User-given label. Plaintext (like `filename`) so the library list can + /// sort + search in SQL. The document BODY stays encrypted; only this + /// short, user-authored title is clear-text — it is what makes ILIKE + /// search and ORDER BY possible. + title String? /// Original filename as uploaded (sanitised, optional). filename String? /// Magic-byte-sniffed MIME type the document was stored as. @@ -5586,13 +5600,18 @@ model InboundDocument { /// shared with the note columns. The original is sensitive and never /// stored in the clear or logged. contentEncrypted Bytes @map("content_encrypted") - status InboundDocumentStatus @default(EXTRACTING) + status InboundDocumentStatus @default(STORED) /// Provider type that ran the extraction (e.g. "anthropic", "openai", - /// "local"). Diagnostic only. + /// "local"). Diagnostic only. Null on a store-only document. providerType String? @map("provider_type") /// The document's stated report/collection date (YYYY-MM-DD at UTC), as - /// transcribed — never inferred. + /// transcribed by the model — never inferred, never user-set. reportDate DateTime? @map("report_date") + /// User-set filing date (editable). Distinct from `reportDate`, which is + /// the model-transcribed value. The library shows `documentDate`, + /// defaulting to `reportDate` then `createdAt` when the user hasn't set + /// one. + documentDate DateTime? @map("document_date") /// Failure reason when status = FAILED (a short code, never the document). errorReason String? @map("error_reason") @@ -5607,6 +5626,7 @@ model InboundDocument { @@index([userId, createdAt]) @@index([userId, deletedAt]) + @@index([userId, documentDate]) @@map("inbound_documents") } diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..b19791049 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.25 — documents library: owner-scoped metadata edit. + * + * Pins that PATCH is owner-scoped (a caller cannot touch another user's + * document — the `where` carries the session userId, so a foreign row resolves + * to a 404) and that an own-row edit sets only the sent fields (no mass + * assignment). + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { + findFirst: vi.fn(), + update: vi.fn(), + findFirstOrThrow: 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/ai/coach/bytes-codec", () => ({ + encryptToBytes: vi.fn(() => new Uint8Array([1])), + decryptFromBytes: vi.fn(() => "{}"), +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function mkReq(id: string, body: unknown): NextRequest { + return new NextRequest( + new URL(`http://localhost/api/documents/inbound/${id}`), + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); + +function docRow(over: Record = {}) { + return { + id: "doc-1", + userId: "user-1", + kind: "DOCTOR_REPORT", + title: "Renamed", + filename: "x.png", + mimeType: "image/png", + byteSize: 70, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: new Date("2026-05-01T00:00:00.000Z"), + errorReason: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-02T00:00:00.000Z"), + facts: [], + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); +}); + +describe("PATCH /api/documents/inbound/[id]", () => { + it("cannot touch another user's document (404, no update)", async () => { + // The foreign row does not satisfy `{ id, userId, deletedAt: null }`. + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue( + null as never, + ); + + const res = await PATCH( + mkReq("foreign-doc", { title: "hijack" }) as never, + ctx("foreign-doc") as never, + ); + expect(res.status).toBe(404); + expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); + + const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0][0] + .where; + expect(where.userId).toBe("user-1"); + expect(where.deletedAt).toBeNull(); + }); + + it("edits only the sent fields on an owned document", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + } as never); + vi.mocked(prisma.inboundDocument.update).mockResolvedValue( + docRow() as never, + ); + vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue( + docRow({ kind: "IMAGING", title: "Renamed" }) as never, + ); + + const res = await PATCH( + mkReq("doc-1", { title: "Renamed", kind: "IMAGING" }) as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(200); + + const arg = vi.mocked(prisma.inboundDocument.update).mock.calls[0][0]; + expect(arg.where).toEqual({ id: "doc-1" }); + expect(arg.data.title).toBe("Renamed"); + expect(arg.data.kind).toBe("IMAGING"); + // documentDate was not sent → not present in the update payload. + expect("documentDate" in arg.data).toBe(false); + }); + + it("422s on an empty update", async () => { + const res = await PATCH(mkReq("doc-1", {}) as never, ctx("doc-1") as never); + expect(res.status).toBe(422); + expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts new file mode 100644 index 000000000..bdc0d2d6f --- /dev/null +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.25 — optional extraction action. + * + * Pins the store-first fix: with NO vision provider configured, extraction + * 422s on the ENHANCEMENT only — the already-stored document is left intact + * (no fact-staging transaction, no status flip). This is the inverse of the + * old behaviour where the absence of a provider blocked the upload itself. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { findFirst: vi.fn(), update: vi.fn() }, + extractedFact: { deleteMany: vi.fn() }, + user: { findUnique: vi.fn() }, + $transaction: 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/ai/coach/bytes-codec", () => ({ + encryptToBytes: vi.fn(() => new Uint8Array([1])), + decryptFromBytes: vi.fn(() => "{}"), +})); + +vi.mock("@/lib/labs/ocr-capability", () => ({ + resolveVisionProvider: vi.fn(), + resolveTextProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ + assertConsentForChain: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-06-27"), + reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), + reconcileSpend: vi.fn().mockResolvedValue(undefined), + resolveDailyCap: vi.fn(() => 1000), +})); +vi.mock("@/lib/documents/extract", () => ({ + runInboundExtraction: vi.fn(), + InboundExtractError: class InboundExtractError extends Error {}, +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 5, resetAt: Date.now() }), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); + +function visionReq(id: string): NextRequest { + // No JSON content-type → vision mode (operates on the stored original). + return new NextRequest( + new URL(`http://localhost/api/documents/inbound/${id}/extract`), + { method: "POST" }, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + kind: "OTHER", + contentEncrypted: new Uint8Array([1, 2, 3]), + mimeType: "image/png", + status: "STORED", + } as never); +}); + +describe("POST /api/documents/inbound/[id]/extract", () => { + it("422s without a vision provider and leaves the stored row intact", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue({ + chain: [], + pick: null, + } as never); + + const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta?.errorCode).toBe("documents.inbound.providerUnsupported"); + + // The stored document is untouched — no staging transaction, no flip. + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); + }); + + it("404s for a document the caller does not own", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue( + null as never, + ); + const res = await POST( + visionReq("foreign") as never, + ctx("foreign") as never, + ); + expect(res.status).toBe(404); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts new file mode 100644 index 000000000..8d382bb5c --- /dev/null +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -0,0 +1,390 @@ +/** + * v1.25 — optional AI extraction on an already-STORED document. + * + * Extraction is no longer part of upload: a document is stored first + * (provider-free), and THIS route is the explicit, user-triggered enhancement. + * It carries the entire ingest gauntlet that upload used to run inline — + * resolve provider → assertConsentForChain → rate-limit → reserveBudget → + * runInboundExtraction → reconcileSpend → stage facts — but now against a row + * that already exists. Absent a provider this 422s the ENHANCEMENT only; the + * stored document is untouched and remains filed. + * + * Two modes, dispatched on content-type (mirroring the original upload): + * - VISION (no JSON body): decrypt the stored original, re-derive its MIME, + * and run the vision-capable provider over it. + * - TEXT (application/json, opt-in local OCR): `{ mode: "text", text }` — the + * browser OCR'd the document locally and posts only the text to structure. + * + * The document is UNTRUSTED (prompt-injection): the server never acts on an + * instruction inside it. The staged facts land PENDING for the mandatory + * review-then-confirm screen; nothing reaches the structured stores here. + */ +import { Buffer } from "node:buffer"; + +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { + buildDateKey, + reconcileSpend, + reserveBudget, + resolveDailyCap, +} from "@/lib/ai/coach/budget"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { + InboundExtractError, + runInboundExtraction, + type InboundExtractionResult, +} from "@/lib/documents/extract"; +import { + decryptDocumentFromBytes, + encryptFactData, + encryptFactProvenance, + serialiseDocumentDetail, +} from "@/lib/documents/store"; +import { + resolveTextProvider, + resolveVisionProvider, +} from "@/lib/labs/ocr-capability"; +import { detectOcrMimeType } from "@/lib/labs/ocr-upload"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */ +const EXTRACT_LIMIT_PER_HOUR = 6; +const EXTRACT_WINDOW_MS = 60 * 60 * 1000; +const TEXT_BODY_MAX_BYTES = 512 * 1024; + +/** A loaded, owner-scoped, still-extractable document row. */ +type LoadedDocument = { + id: string; + kind: string; + contentEncrypted: Uint8Array; + mimeType: string; + status: string; +}; + +/** + * Replace the document's staged facts and flip it to EXTRACTED in one + * transaction. Re-extraction is allowed (a STORED / FAILED / EXTRACTED row), + * so any prior PENDING staging is cleared first; facts already APPROVED into + * the structured stores are independent rows and are not touched here. + */ +async function stageExtraction( + documentId: string, + userId: string, + result: InboundExtractionResult, +) { + return prisma.$transaction(async (tx) => { + await tx.extractedFact.deleteMany({ where: { documentId, userId } }); + await tx.inboundDocument.update({ + where: { id: documentId }, + data: { + status: "EXTRACTED", + providerType: result.providerType, + reportDate: result.reportDate + ? new Date(`${result.reportDate}T00:00:00.000Z`) + : null, + facts: { + create: result.facts.map((f) => ({ + userId, + factType: f.factType, + status: "PENDING" as const, + confidence: f.confidence, + needsReview: f.needsReview, + dataEncrypted: encryptFactData(f.data), + provenanceEncrypted: encryptFactProvenance(f.provenance), + })), + }, + }, + }); + return tx.inboundDocument.findUniqueOrThrow({ + where: { id: documentId }, + include: { facts: { orderBy: { createdAt: "asc" } } }, + }); + }); +} + +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 { id } = await params; + const document = await prisma.inboundDocument.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + select: { + id: true, + kind: true, + contentEncrypted: true, + mimeType: true, + status: true, + }, + }); + if (!document) { + return apiError("Document not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + if (document.status === "CONFIRMED") { + return apiError("This document has already been confirmed.", 422, { + errorCode: "documents.inbound.alreadyConfirmed", + }); + } + + const contentType = request.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return handleTextExtract(request, user.id, document); + } + return handleVisionExtract(request, user.id, document); + }, +); + +/** TEXT mode — structure in-browser-OCR'd text against the stored row. */ +async function handleTextExtract( + request: NextRequest, + userId: string, + document: LoadedDocument, +): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { labsLocalOcrEnabled: true }, + }); + if (!row?.labsLocalOcrEnabled) { + annotate({ action: { name: "documents.inbound.providerUnsupported" } }); + return apiError("Local OCR is not enabled", 422, { + errorCode: "documents.inbound.localOcrDisabled", + }); + } + + const { chain, pick } = await resolveTextProvider(userId); + if (!pick) { + return apiError("No AI provider is configured", 422, { + errorCode: "documents.inbound.providerUnsupported", + }); + } + + await assertConsentForChain({ userId, chain, surface: "insights" }); + + const rl = await checkRateLimit( + `documents-inbound:${userId}`, + EXTRACT_LIMIT_PER_HOUR, + EXTRACT_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many extractions. 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: TEXT_BODY_MAX_BYTES, + }); + if (jsonError) return jsonError; + + const parsed = inboundTextExtractSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid document text payload", 422, { + errorCode: "documents.inbound.extractFailed", + }); + } + + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + userId, + AI_BUDGETS.ocrExtractText.maxTokens, + dateKey, + resolveDailyCap([{ providerType: pick.entry.providerType }]), + ); + if (!reservation.allowed) { + return apiError("Your AI usage budget for today is reached.", 429, { + errorCode: "documents.inbound.budgetExceeded", + }); + } + + try { + const result = await runInboundExtraction({ + provider: pick.entry.instance, + providerType: pick.providerType, + ocrText: parsed.data.text, + }); + await reconcileSpend( + userId, + reservation.reserved, + reservation.reserved, + dateKey, + ); + + const updated = await stageExtraction(document.id, userId, result); + + await auditLog("documents.inbound.extract", { + userId, + ipAddress: getClientIp(request), + details: { + documentId: document.id, + facts: updated.facts.length, + mode: "text", + }, + }); + + return apiSuccess(serialiseDocumentDetail(updated, updated.facts)); + } catch (err) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + if (err instanceof InboundExtractError) { + return apiError("Couldn't read the document. Try a clearer copy.", 422, { + errorCode: "documents.inbound.extractFailed", + }); + } + annotate({ + action: { name: "documents.inbound.extractFailed" }, + meta: { reason: "provider_error", mode: "text" }, + }); + return apiError("Couldn't read the document. Try a clearer copy.", 502, { + errorCode: "documents.inbound.extractFailed", + }); + } +} + +/** VISION mode — run the stored original through the vision provider. */ +async function handleVisionExtract( + request: NextRequest, + userId: string, + document: LoadedDocument, +): Promise { + const { chain, pick } = await resolveVisionProvider(userId); + if (!pick) { + return apiError("No vision-capable AI provider is configured", 422, { + errorCode: "documents.inbound.providerUnsupported", + }); + } + + await assertConsentForChain({ userId, chain, surface: "insights" }); + + const rl = await checkRateLimit( + `documents-inbound:${userId}`, + EXTRACT_LIMIT_PER_HOUR, + EXTRACT_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many extractions. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + // Decrypt the stored original and re-derive its MIME from the bytes (never + // trust a stored label for the provider call). + let buffer: Buffer; + try { + buffer = decryptDocumentFromBytes(document.contentEncrypted); + } catch { + return apiError("Couldn't read the stored document.", 422, { + errorCode: "documents.inbound.extractFailed", + }); + } + + const mime = detectOcrMimeType(buffer); + if (!mime) { + return apiError( + "This document can't be scanned. Use local OCR (text mode).", + 422, + { errorCode: "documents.inbound.fileType" }, + ); + } + + if (mime === "application/pdf" && !pick.pdfSupported) { + return apiError( + "PDF scanning needs a Claude vision provider; use local OCR instead.", + 422, + { errorCode: "documents.inbound.pdfNeedsAnthropic" }, + ); + } + + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + userId, + AI_BUDGETS.ocrExtract.maxTokens, + dateKey, + resolveDailyCap([{ providerType: pick.entry.providerType }]), + ); + if (!reservation.allowed) { + return apiError("Your AI usage budget for today is reached.", 429, { + errorCode: "documents.inbound.budgetExceeded", + }); + } + + const dataBase64 = buffer.toString("base64"); + const images = + mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }]; + const documents = + mime === "application/pdf" + ? [{ mediaType: "application/pdf" as const, dataBase64 }] + : []; + + try { + const result = await runInboundExtraction({ + provider: pick.entry.instance, + providerType: pick.providerType, + images, + documents, + }); + await reconcileSpend( + userId, + reservation.reserved, + reservation.reserved, + dateKey, + ); + + const updated = await stageExtraction(document.id, userId, result); + + await auditLog("documents.inbound.extract", { + userId, + ipAddress: getClientIp(request), + details: { + documentId: document.id, + facts: updated.facts.length, + mode: "vision", + }, + }); + + return apiSuccess(serialiseDocumentDetail(updated, updated.facts)); + } catch (err) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + if (err instanceof InboundExtractError) { + return apiError("Couldn't read the document. Try a clearer copy.", 422, { + errorCode: "documents.inbound.extractFailed", + }); + } + annotate({ + action: { name: "documents.inbound.extractFailed" }, + meta: { reason: "provider_error", mode: "vision" }, + }); + return apiError("Couldn't read the document. Try a clearer copy.", 502, { + 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 6add4331c..b8223a1c9 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -1,19 +1,28 @@ /** - * v1.25 (W-DOCS-IN) — inbound clinical document by id: detail + discard. + * v1.25 — inbound clinical document by id: detail + metadata edit + discard. * * GET returns the document plus its staged facts for the review screen. + * PATCH renames / recategorises / sets the user filing date (no mass + * assignment; `userId` from the session feeds the `where`). * DELETE soft-deletes (tombstone) and marks the document DISCARDED — facts * already approved into the structured stores are independent rows and stay. */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { serialiseDocumentDetail } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { documentUpdateSchema } from "@/lib/validations/inbound-documents"; +import type { Prisma } from "@/generated/prisma/client"; type RouteParams = { params: Promise<{ id: string }> }; @@ -45,6 +54,73 @@ export const GET = apiHandler( }, ); +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { id } = await params; + + const { data: body, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = documentUpdateSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid document update", 422, { + errorCode: "documents.inbound.invalidUpdate", + }); + } + + const existing = await prisma.inboundDocument.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!existing) { + return apiError("Document not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + + // 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 = {}; + if (parsed.data.title !== undefined) data.title = parsed.data.title; + if (parsed.data.kind !== undefined) data.kind = parsed.data.kind; + if (parsed.data.documentDate !== undefined) { + data.documentDate = parsed.data.documentDate + ? new Date(`${parsed.data.documentDate}T00:00:00.000Z`) + : null; + } + + await prisma.inboundDocument.update({ + where: { id: existing.id }, + data, + }); + + const document = await prisma.inboundDocument.findFirstOrThrow({ + where: { id: existing.id }, + include: { facts: { orderBy: { createdAt: "asc" } } }, + }); + + await auditLog("documents.inbound.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + documentId: existing.id, + fields: Object.keys(data), + }, + }); + + annotate({ + action: { name: "documents.inbound.update" }, + meta: { documentId: existing.id, fields: Object.keys(data).join(",") }, + }); + + return apiSuccess(serialiseDocumentDetail(document, document.facts)); + }, +); + export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts new file mode 100644 index 000000000..c58995afb --- /dev/null +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * v1.25 — documents library: 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. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { + create: vi.fn(), + findMany: 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/modules/gate", () => ({ + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 3_600_000, + }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST, GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const post = POST as unknown as (r: Request) => Promise; +const get = GET as unknown as (r: Request) => Promise; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const ONE_BY_ONE_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg=="; + +function mkUpload(fields: Record = {}) { + const body = Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64"); + const blobBytes = new Uint8Array(body.byteLength); + blobBytes.set(body); + 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); + return new Request("http://localhost/api/documents/inbound", { + method: "POST", + body: formData, + }); +} + +function docRow(over: Record = {}) { + return { + id: "doc-1", + userId: "user-1", + kind: "OTHER", + title: null, + filename: "x.png", + mimeType: "image/png", + byteSize: 70, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: null, + errorReason: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } 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( + docRow({ kind: "LAB_RESULT", title: "Blood panel" }) as never, + ); + + const res = await post( + mkUpload({ title: "Blood panel", kind: "LAB_RESULT" }), + ); + expect(res.status).toBe(201); + + const body = await res.json(); + expect(body.error).toBeNull(); + expect(body.data.status).toBe("STORED"); + expect(body.data.kind).toBe("LAB_RESULT"); + expect(body.data.title).toBe("Blood panel"); + + // The row is created with status STORED, no provider, userId from session. + const arg = vi.mocked(prisma.inboundDocument.create).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("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"); + }); + + 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); + expect(res.status).toBe(415); + expect(prisma.inboundDocument.create).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/documents/inbound (list)", () => { + it("applies q + kind filters and returns a keyset page", async () => { + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { ...docRow({ id: "d1" }), _count: { facts: 0 }, facts: [] }, + { ...docRow({ id: "d2" }), _count: { facts: 2 }, facts: [{ id: "f1" }] }, + ] as never); + + const res = await get( + new Request( + "http://localhost/api/documents/inbound?q=panel&kind=LAB_RESULT&sort=createdAt&order=desc&limit=10", + ), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.documents).toHaveLength(2); + expect(body.data.nextCursor).toBeNull(); + + const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0][0]; + expect(arg.where.userId).toBe("user-1"); + expect(arg.where.deletedAt).toBeNull(); + expect(arg.where.kind).toBe("LAB_RESULT"); + expect(arg.where.OR).toEqual([ + { title: { contains: "panel", mode: "insensitive" } }, + { filename: { contains: "panel", mode: "insensitive" } }, + ]); + // limit+1 fetched for the has-more probe. + expect(arg.take).toBe(11); + }); + + 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: [], + })); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue(rows as never); + + const res = await get( + new Request("http://localhost/api/documents/inbound?limit=2"), + ); + const body = await res.json(); + expect(body.data.documents).toHaveLength(2); + expect(body.data.nextCursor).toBe("d1"); + }); + + it("422s on a bad sort value", async () => { + const res = await get( + new Request("http://localhost/api/documents/inbound?sort=nope"), + ); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 4b8fe1fc6..5b270cc5a 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -1,61 +1,31 @@ /** - * v1.25 (W-DOCS-IN) — inbound clinical documents: upload + list. + * v1.25 — documents library: store-first upload + browsable list. * - * POST ingests an uploaded doctor report / discharge letter through the - * EXISTING dedicated OCR/vision provider rails (the v1.22 document-scan - * provider), stores the raw document ENCRYPTED at rest, and stages the - * extracted STRUCTURED FACTS for a mandatory review-then-confirm screen. - * Nothing reaches the structured stores here — the confirm route is the only - * write path, and a low-confidence fact fails closed. + * 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`). * - * Two modes, dispatched on the request content-type (mirroring the Lab-OCR - * extract route): - * - VISION (multipart/form-data): a photo / PDF run through the vision-capable - * provider; the original is stored encrypted. - * - TEXT (application/json, opt-in local OCR): the browser OCR's the image - * and POSTs only the extracted text; the text is stored encrypted (the raw - * image never reaches the server). + * 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). * - * Guards mirror the Lab-OCR discipline: requireAuth → module gate → resolve - * provider → assertConsentForChain → rate-limit → reserveBudget → extract → - * reconcile budget. The document is UNTRUSTED (prompt-injection): the server - * never acts on an instruction inside it — the review step is the safety - * boundary, and "extract, never interpret" is enforced end-to-end. + * 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. */ import { Buffer } from "node:buffer"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - safeJson, -} from "@/lib/api-response"; -import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; -import { - buildDateKey, - reconcileSpend, - reserveBudget, - resolveDailyCap, -} from "@/lib/ai/coach/budget"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; -import { - InboundExtractError, - runInboundExtraction, - type StagedFactInput, -} from "@/lib/documents/extract"; import { encryptDocumentToBytes, - encryptFactData, - encryptFactProvenance, serialiseDocument, } from "@/lib/documents/store"; -import { - resolveTextProvider, - resolveVisionProvider, -} from "@/lib/labs/ocr-capability"; import { BodyTooLargeError, detectOcrMimeType, @@ -66,60 +36,26 @@ import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { - inboundTextExtractSchema, - INBOUND_DOCUMENT_KINDS, - type InboundDocumentKindValue, + documentCreateSchema, + documentListQuerySchema, } from "@/lib/validations/inbound-documents"; +import type { Prisma } from "@/generated/prisma/client"; export const dynamic = "force-dynamic"; -/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */ -const EXTRACT_LIMIT_PER_HOUR = 6; -const EXTRACT_WINDOW_MS = 60 * 60 * 1000; -const TEXT_BODY_MAX_BYTES = 512 * 1024; +/** + * A store-only upload touches no provider — the only abuse vector is disk, so + * a generous per-user ceiling is enough (extraction has its own tight gate). + */ +const UPLOAD_LIMIT_PER_HOUR = 60; +const UPLOAD_WINDOW_MS = 60 * 60 * 1000; -/** Persist the document + its staged facts and return the list DTO. */ -async function persistExtraction(args: { - userId: string; - kind: InboundDocumentKindValue; - filename: string | null; - mimeType: string; - byteSize: number; - contentEncrypted: Uint8Array; - providerType: string; - reportDate: string | null; - facts: StagedFactInput[]; -}) { - const document = await prisma.inboundDocument.create({ - data: { - userId: args.userId, - kind: args.kind, - filename: args.filename, - mimeType: args.mimeType, - byteSize: args.byteSize, - contentEncrypted: args.contentEncrypted, - status: "EXTRACTED", - providerType: args.providerType, - reportDate: args.reportDate - ? new Date(`${args.reportDate}T00:00:00.000Z`) - : null, - facts: { - create: args.facts.map((f) => ({ - userId: args.userId, - factType: f.factType, - status: "PENDING" as const, - confidence: f.confidence, - needsReview: f.needsReview, - dataEncrypted: encryptFactData(f.data), - provenanceEncrypted: encryptFactProvenance(f.provenance), - })), - }, - }, - include: { facts: true }, - }); - return document; +/** 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`); } +/** POST — store an uploaded document encrypted at rest. No extraction. */ export const POST = apiHandler(async (request: Request) => { const { user } = await requireAuth(); @@ -128,42 +64,10 @@ export const POST = apiHandler(async (request: Request) => { const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; - const contentType = request.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - return handleTextUpload(request, user.id); - } - return handleVisionUpload(request, user.id); -}); - -/** TEXT mode — structure in-browser-OCR'd text; store the text encrypted. */ -async function handleTextUpload( - request: Request, - userId: string, -): Promise { - const row = await prisma.user.findUnique({ - where: { id: userId }, - select: { labsLocalOcrEnabled: true }, - }); - if (!row?.labsLocalOcrEnabled) { - annotate({ action: { name: "documents.inbound.providerUnsupported" } }); - return apiError("Local OCR is not enabled", 422, { - errorCode: "documents.inbound.localOcrDisabled", - }); - } - - const { chain, pick } = await resolveTextProvider(userId); - if (!pick) { - return apiError("No AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - - await assertConsentForChain({ userId, chain, surface: "insights" }); - const rl = await checkRateLimit( - `documents-inbound:${userId}`, - EXTRACT_LIMIT_PER_HOUR, - EXTRACT_WINDOW_MS, + `documents-upload:${user.id}`, + UPLOAD_LIMIT_PER_HOUR, + UPLOAD_WINDOW_MS, ); if (!rl.allowed) { const response = apiError("Too many uploads. Try again later.", 429, { @@ -175,304 +79,174 @@ async function handleTextUpload( return response; } - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: TEXT_BODY_MAX_BYTES, - }); - if (jsonError) return jsonError; - - const parsed = inboundTextExtractSchema.safeParse(body); - if (!parsed.success) { - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); - } - - const dateKey = buildDateKey(); - const reservation = await reserveBudget( - userId, - AI_BUDGETS.ocrExtractText.maxTokens, - dateKey, - resolveDailyCap([{ providerType: pick.entry.providerType }]), - ); - if (!reservation.allowed) { - return apiError("Your AI usage budget for today is reached.", 429, { - errorCode: "documents.inbound.budgetExceeded", + 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", }); } + let formData: FormData; try { - const result = await runInboundExtraction({ - provider: pick.entry.instance, - providerType: pick.providerType, - ocrText: parsed.data.text, - }); - await reconcileSpend( - userId, - reservation.reserved, - reservation.reserved, - dateKey, - ); - - const textBytes = Buffer.from(parsed.data.text, "utf8"); - const document = await persistExtraction({ - userId, - kind: parsed.data.kind ?? result.kind, - filename: null, - mimeType: "text/plain", - byteSize: textBytes.byteLength, - contentEncrypted: encryptDocumentToBytes(textBytes), - providerType: result.providerType, - reportDate: result.reportDate, - facts: result.facts, - }); - - await auditLog("documents.inbound.upload", { - userId, - ipAddress: getClientIp(request), - details: { - documentId: document.id, - facts: document.facts.length, - mode: "text", - }, - }); - - return apiSuccess( - serialiseDocument(document, { - factCount: document.facts.length, - pendingCount: document.facts.length, - }), - 201, - ); + const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES); + formData = await new Response(new Blob([bytes]), { + headers: { "content-type": request.headers.get("content-type") ?? "" }, + }).formData(); } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - if (err instanceof InboundExtractError) { - return apiError("Couldn't read the document. Try a clearer copy.", 422, { - errorCode: "documents.inbound.extractFailed", - }); - } - annotate({ - action: { name: "documents.inbound.extractFailed" }, - meta: { reason: "provider_error", mode: "text" }, - }); - return apiError("Couldn't read the document. Try a clearer copy.", 502, { - errorCode: "documents.inbound.extractFailed", - }); - } -} - -/** VISION mode — run a photo / PDF through the vision provider; store it. */ -async function handleVisionUpload( - request: Request, - userId: string, -): Promise { - const { chain, pick } = await resolveVisionProvider(userId); - if (!pick) { - return apiError("No vision-capable AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - - await assertConsentForChain({ userId, chain, surface: "insights" }); - - const rl = await checkRateLimit( - `documents-inbound:${userId}`, - EXTRACT_LIMIT_PER_HOUR, - EXTRACT_WINDOW_MS, - ); - if (!rl.allowed) { - const response = apiError("Too many uploads. 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 dateKey = buildDateKey(); - const reservation = await reserveBudget( - userId, - AI_BUDGETS.ocrExtract.maxTokens, - dateKey, - resolveDailyCap([{ providerType: pick.entry.providerType }]), - ); - if (!reservation.allowed) { - return apiError("Your AI usage budget for today is reached.", 429, { - errorCode: "documents.inbound.budgetExceeded", - }); - } - - try { - const contentLength = Number(request.headers.get("content-length") ?? 0); - if (contentLength > OCR_MAX_BYTES) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); + if (err instanceof BodyTooLargeError) { return apiError("File is too large (max 12 MB).", 413, { errorCode: "documents.inbound.fileTooLarge", }); } - - let formData: FormData; - try { - const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES); - formData = await new Response(new Blob([bytes]), { - headers: { "content-type": request.headers.get("content-type") ?? "" }, - }).formData(); - } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - if (err instanceof BodyTooLargeError) { - return apiError("File is too large (max 12 MB).", 413, { - errorCode: "documents.inbound.fileTooLarge", - }); - } - return apiError("Invalid multipart body", 400); - } - - const file = formData.get("file"); - if (!(file instanceof File)) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - return apiError("Field 'file' must be a file", 422); - } - - // Optional document-kind label (a label only; drives no interpretation). - const kindRaw = formData.get("kind"); - const kind: InboundDocumentKindValue = - typeof kindRaw === "string" && - (INBOUND_DOCUMENT_KINDS as readonly string[]).includes(kindRaw) - ? (kindRaw as InboundDocumentKindValue) - : "OTHER"; - - let buffer: Buffer; - try { - buffer = Buffer.from(await file.arrayBuffer()); - } catch { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - return apiError("Failed to read uploaded file", 400); - } - - const mime = detectOcrMimeType(buffer); - if (!mime) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, { - errorCode: "documents.inbound.fileType", - }); - } - - if (mime === "application/pdf" && !pick.pdfSupported) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - return apiError( - "PDF scanning needs a Claude vision provider; upload a photo instead.", - 422, - { errorCode: "documents.inbound.pdfNeedsAnthropic" }, - ); - } - - const dataBase64 = buffer.toString("base64"); - const images = - mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }]; - const documents = - mime === "application/pdf" - ? [{ mediaType: "application/pdf" as const, dataBase64 }] - : []; - - try { - const result = await runInboundExtraction({ - provider: pick.entry.instance, - providerType: pick.providerType, - images, - documents, - }); - await reconcileSpend( - userId, - reservation.reserved, - reservation.reserved, - dateKey, - ); - - const document = await persistExtraction({ - userId, - kind, - filename: - typeof file.name === "string" ? file.name.slice(0, 255) : null, - mimeType: mime, - byteSize: buffer.byteLength, - contentEncrypted: encryptDocumentToBytes(buffer), - providerType: result.providerType, - reportDate: result.reportDate, - facts: result.facts, - }); - - await auditLog("documents.inbound.upload", { - userId, - ipAddress: getClientIp(request), - details: { - documentId: document.id, - facts: document.facts.length, - mode: "vision", - }, - }); - - return apiSuccess( - serialiseDocument(document, { - factCount: document.facts.length, - pendingCount: document.facts.length, - }), - 201, - ); - } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - if (err instanceof InboundExtractError) { - return apiError( - "Couldn't read the document. Try a clearer copy.", - 422, - { - errorCode: "documents.inbound.extractFailed", - }, - ); - } - annotate({ - action: { name: "documents.inbound.extractFailed" }, - meta: { reason: "provider_error", mode: "vision" }, - }); - return apiError("Couldn't read the document. Try a clearer copy.", 502, { - errorCode: "documents.inbound.extractFailed", - }); - } - } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch( - () => {}, - ); - throw err; + return apiError("Invalid multipart body", 400); } -} -/** GET — list the caller's inbound documents (newest first, live only). */ -export const GET = apiHandler(async () => { + const file = formData.get("file"); + if (!(file instanceof File)) { + 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. + const parsed = documentCreateSchema.safeParse({ + title: formData.get("title") ?? undefined, + kind: formData.get("kind") ?? undefined, + documentDate: formData.get("documentDate") ?? undefined, + }); + if (!parsed.success) { + return apiError("Invalid document metadata", 422, { + errorCode: "documents.inbound.invalidMetadata", + }); + } + + let buffer: Buffer; + try { + buffer = Buffer.from(await file.arrayBuffer()); + } 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", + }); + } + + // 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. + const document = await prisma.inboundDocument.create({ + data: { + userId: user.id, + kind: parsed.data.kind ?? "OTHER", + 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) + : null, + }, + }); + + await auditLog("documents.inbound.store", { + userId: user.id, + ipAddress: getClientIp(request), + details: { documentId: document.id, mime }, + }); + + annotate({ + action: { name: "documents.inbound.store" }, + meta: { documentId: document.id, byteSize: document.byteSize }, + }); + + return apiSuccess( + serialiseDocument(document, { factCount: 0, pendingCount: 0 }), + 201, + ); +}); + +/** GET — list the caller's documents (search / filter / sort / paginate). */ +export const GET = apiHandler(async (request: Request) => { const { user } = await requireAuth(); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; - const documents = await prisma.inboundDocument.findMany({ - where: { userId: user.id, deletedAt: null }, - orderBy: { createdAt: "desc" }, + const url = new URL(request.url); + const parsed = documentListQuerySchema.safeParse( + Object.fromEntries(url.searchParams), + ); + 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 where: Prisma.InboundDocumentWhereInput = { + userId: user.id, + deletedAt: null, + }; + if (kind) where.kind = kind; + if (from || to) { + where.documentDate = { + ...(from ? { gte: isoDateToUtc(from) } : {}), + // inclusive end-of-day for the `to` bound + ...(to ? { lt: new Date(isoDateToUtc(to).getTime() + 86_400_000) } : {}), + }; + } + if (q) { + where.OR = [ + { title: { contains: q, mode: "insensitive" } }, + { filename: { contains: q, mode: "insensitive" } }, + ]; + } + + // Keyset pagination on the sort column + a stable `id` tiebreak; nullable + // sort columns sort nulls last so undated documents trail. + const primaryOrder: + Prisma.InboundDocumentOrderByWithRelationInput | undefined = + sort === "documentDate" + ? { documentDate: { sort: order, nulls: "last" } } + : sort === "title" + ? { title: { sort: order, nulls: "last" } } + : { createdAt: order }; + const orderBy: Prisma.InboundDocumentOrderByWithRelationInput[] = [ + primaryOrder, + { id: order }, + ]; + + const rows = await prisma.inboundDocument.findMany({ + where, + orderBy, include: { _count: { select: { facts: true } }, facts: { where: { status: "PENDING" }, select: { id: true } }, }, - take: 200, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), }); + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null; + annotate({ action: { name: "documents.inbound.list" }, - meta: { count: documents.length }, + meta: { count: page.length, sort, order, filtered: Boolean(q || kind) }, }); return apiSuccess({ - documents: documents.map((doc) => + documents: page.map((doc) => serialiseDocument(doc, { factCount: doc._count.facts, pendingCount: doc.facts.length, }), ), + nextCursor, }); }); diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 2514d64c7..77997b77f 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -36,10 +36,13 @@ export function encryptDocumentToBytes(bytes: Buffer): Uint8Array { } /** - * Decrypt a stored document back to its raw bytes. Throws on a bad / missing - * key id (fail-closed) — the caller must treat a throw as "cannot serve" and - * never fall back to returning the ciphertext. The original document is PHI; - * the only path that reads it is the owner-scoped download route. + * Decrypt a stored document's `Bytes` payload back to its raw bytes. The exact + * inverse of `encryptDocumentToBytes`: the column holds `encrypt()`-string + * UTF-8 bytes whose plaintext is the base64 of the original binary. Throws on a + * bad / missing key id (fail-closed) — the caller must treat a throw as "cannot + * serve" and never fall back to returning the ciphertext. The original document + * is PHI; the owner-scoped download route and the optional extraction action are + * the only paths that read it. */ export function decryptDocumentFromBytes(buf: Uint8Array): Buffer { const base64 = decrypt(Buffer.from(buf).toString("utf8")); @@ -85,6 +88,7 @@ export function serialiseDocument( return { id: doc.id, kind: doc.kind as InboundDocumentKindValue, + title: doc.title, filename: doc.filename, mimeType: doc.mimeType, byteSize: doc.byteSize, @@ -93,6 +97,9 @@ export function serialiseDocument( reportDate: doc.reportDate ? doc.reportDate.toISOString().slice(0, 10) : null, + documentDate: doc.documentDate + ? doc.documentDate.toISOString().slice(0, 10) + : null, errorReason: doc.errorReason, factCount: counts.factCount, pendingCount: counts.pendingCount, diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 5abee6a51..8401545d3 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -1,28 +1,35 @@ /** - * OpenAPI route table for inbound clinical documents (`/api/documents/inbound/*`). + * OpenAPI route table for the documents library (`/api/documents/inbound/*`). * * Part of the OpenAPI route table; aggregated in `./index.ts`. The confirm + - * edit request bodies reuse the runtime schemas from + * edit + list request bodies reuse the runtime schemas from * `@/lib/validations/inbound-documents` so the wire contract stays - * single-source. The upload is a `multipart/form-data` binary body (or a JSON - * text-mode body); the response shapes are declared here. + * single-source. * - * Safety contract surfaced in the descriptions: extraction reproduces what the - * document states (stated codes/status only) into a staging area — it never - * interprets or diagnoses. Nothing reaches the structured stores until the - * confirm route commits the user's approvals; a low-confidence fact fails - * closed. + * The v1.25.x library inverts the original OCR-inbox: upload is STORE-ONLY and + * provider-free (a file is always filable, encrypted at rest); AI extraction is + * a separate, opt-in action on an already-stored document. Extraction + * reproduces what the document states (stated codes/status only) into a staging + * area — it never interprets or diagnoses. Nothing reaches the structured + * stores until the confirm route commits the user's approvals; a low-confidence + * fact fails closed. */ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; import { + documentUpdateSchema, inboundConfirmSchema, inboundFactEditSchema, + INBOUND_DOCUMENT_KINDS, + INBOUND_DOCUMENT_STATUSES, } from "@/lib/validations/inbound-documents"; import { dataEnvelope, stdResponses } from "./shared"; +const kindEnum = z.enum(INBOUND_DOCUMENT_KINDS); +const statusEnum = z.enum(INBOUND_DOCUMENT_STATUSES); + inboundConfirmSchema.meta({ id: "InboundConfirmRequest", description: @@ -35,6 +42,12 @@ inboundFactEditSchema.meta({ "A correction to a staged fact before approval (fixes OCR / units / dates / codes). Discriminated by `factType` so the edit cannot change the fact's resource type. A successful edit clears `needsReview` — the values become user-asserted.", }); +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.", +}); + const factProvenance = z.object({ sourceText: z.string(), page: z.number().nullable(), @@ -62,19 +75,15 @@ const extractedFact = z const inboundDocument = z .object({ id: z.string(), - kind: z.enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"]), + kind: kindEnum, + title: z.string().nullable(), filename: z.string().nullable(), mimeType: z.string(), byteSize: z.number(), - status: z.enum([ - "EXTRACTING", - "EXTRACTED", - "FAILED", - "CONFIRMED", - "DISCARDED", - ]), + status: statusEnum, providerType: z.string().nullable(), reportDate: z.string().nullable(), + documentDate: z.string().nullable(), errorReason: z.string().nullable(), factCount: z.number(), pendingCount: z.number(), @@ -84,7 +93,7 @@ const inboundDocument = z .meta({ id: "InboundDocument", description: - "An uploaded clinical document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary.", + "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).", }); const inboundDocumentDetail = inboundDocument @@ -92,8 +101,15 @@ const inboundDocumentDetail = inboundDocument .meta({ id: "InboundDocumentDetail" }); const listResponse = z - .object({ documents: z.array(inboundDocument) }) - .meta({ id: "InboundDocumentList" }); + .object({ + documents: z.array(inboundDocument), + nextCursor: z.string().nullable(), + }) + .meta({ + id: "InboundDocumentList", + description: + "A page of documents plus an opaque `nextCursor` (the id to pass back as `cursor` for the next page; null when the list is exhausted).", + }); const confirmResponse = z .object({ @@ -114,16 +130,82 @@ const confirmResponse = z "The outcome per decision: `approved` (committed, with the new record ref), `rejected` (discarded), `needsReview` (refused — edit first, fail-closed), `failed` (a per-fact commit miss, e.g. a numeric observation with no unit).", }); +const kindValues = [...INBOUND_DOCUMENT_KINDS]; + export const inboundDocumentPaths: NonNullable = { "/api/documents/inbound": { get: { tags: ["Documents"], - summary: "List inbound clinical documents", + summary: "List stored documents", description: - "The caller's uploaded doctor reports / discharge letters (newest first, live only). Owner-scoped; gated on the opt-in `inboundDocuments` module.", + "The caller's stored documents with title/filename search, category filter, a `documentDate` range, sort, and keyset pagination. Owner-scoped; gated on the opt-in `inboundDocuments` module.", + parameters: [ + { + name: "q", + in: "query", + required: false, + schema: { type: "string", maxLength: 100 }, + description: "Case-insensitive search over title + filename.", + }, + { + name: "kind", + in: "query", + required: false, + schema: { type: "string", enum: kindValues }, + description: "Category filter.", + }, + { + name: "from", + in: "query", + required: false, + schema: { type: "string", format: "date" }, + description: "Inclusive lower bound on documentDate (YYYY-MM-DD).", + }, + { + name: "to", + in: "query", + required: false, + schema: { type: "string", format: "date" }, + description: "Inclusive upper bound on documentDate (YYYY-MM-DD).", + }, + { + name: "sort", + in: "query", + required: false, + schema: { + type: "string", + enum: ["documentDate", "createdAt", "title"], + default: "documentDate", + }, + }, + { + name: "order", + in: "query", + required: false, + schema: { type: "string", enum: ["asc", "desc"], default: "desc" }, + }, + { + name: "cursor", + in: "query", + required: false, + schema: { type: "string" }, + description: "Keyset cursor (the `nextCursor` from a prior page).", + }, + { + name: "limit", + in: "query", + required: false, + schema: { + type: "integer", + minimum: 1, + maximum: 100, + default: 50, + }, + }, + ], responses: { "200": { - description: "The document list.", + description: "A page of documents.", content: { "application/json": { schema: dataEnvelope(listResponse, "InboundDocumentListEnvelope"), @@ -135,9 +217,9 @@ export const inboundDocumentPaths: NonNullable = { }, post: { tags: ["Documents"], - summary: "Upload + extract a clinical document", + summary: "Store a document (no extraction)", description: - 'Ingests a doctor report / discharge letter through the dedicated OCR/vision provider, stores the raw document ENCRYPTED at rest, and stages the extracted STRUCTURED FACTS for review. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) plus an optional `kind`. TEXT (`application/json`, opt-in local OCR): `{ mode: "text", text, kind? }` — only the extracted text reaches the server. Both modes are AI-consent / rate / budget gated. Extraction reproduces what the document states (stated codes/status only) — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.', + "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`.", requestBody: { required: true, content: { @@ -146,30 +228,25 @@ export const inboundDocumentPaths: NonNullable = { file: z.string().meta({ format: "binary", description: - "The clinical-document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.", + "The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.", }), - kind: z - .enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"]) + title: z + .string() .optional() - .meta({ description: "Optional document-type label." }), + .meta({ description: "Optional user label." }), + kind: kindEnum + .optional() + .meta({ description: "Optional category." }), + documentDate: z.string().optional().meta({ + description: "Optional user filing date (YYYY-MM-DD).", + }), }), }, - "application/json": { - schema: z - .object({ - mode: z.literal("text"), - text: z.string(), - kind: z - .enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"]) - .optional(), - }) - .meta({ id: "InboundTextUploadRequest" }), - }, }, }, responses: { "201": { - description: "The created document + staging summary.", + description: "The stored document.", content: { "application/json": { schema: dataEnvelope(inboundDocument, "InboundDocumentEnvelope"), @@ -209,9 +286,41 @@ export const inboundDocumentPaths: NonNullable = { ...stdResponses, }, }, + patch: { + tags: ["Documents"], + summary: "Edit a document's metadata", + description: + "Rename / recategorise / set the user filing date on a stored document. Owner-scoped; no mass assignment. Returns the updated document + its staged facts.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { "application/json": { schema: documentUpdateSchema } }, + }, + responses: { + "200": { + description: "The updated document.", + content: { + "application/json": { + schema: dataEnvelope( + inboundDocumentDetail, + "InboundDocumentUpdateEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, delete: { tags: ["Documents"], - summary: "Discard an inbound document", + summary: "Discard a document", description: "Soft-deletes the document + its staging. Facts already approved into the structured stores are independent rows and are NOT affected.", parameters: [ @@ -272,6 +381,50 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, + "/api/documents/inbound/{id}/extract": { + post: { + tags: ["Documents"], + summary: "Extract facts from a stored document", + description: + 'Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only the enhancement fails. Two modes by content-type: VISION (empty body) decrypts and scans the stored original (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: "text", text }` structures browser-OCR\'d text. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.', + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: false, + content: { + "application/json": { + schema: z + .object({ + mode: z.literal("text"), + text: z.string(), + kind: kindEnum.optional(), + }) + .meta({ id: "InboundTextExtractRequest" }), + }, + }, + }, + responses: { + "200": { + description: "The document with its newly staged facts.", + content: { + "application/json": { + schema: dataEnvelope( + inboundDocumentDetail, + "InboundExtractEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/documents/inbound/{id}/facts/{factId}": { patch: { tags: ["Documents"], diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 5fc266099..fb54dd70b 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -49,10 +49,27 @@ export type InboundFactType = (typeof INBOUND_FACT_TYPES)[number]; export const INBOUND_DOCUMENT_KINDS = [ "DOCTOR_REPORT", "DISCHARGE_LETTER", + "LAB_RESULT", + "IMAGING", + "PRESCRIPTION", + "INSURANCE", + "VACCINATION", "OTHER", ] as const; export type InboundDocumentKindValue = (typeof INBOUND_DOCUMENT_KINDS)[number]; +/** The document lifecycle states. STORED is the library default. */ +export const INBOUND_DOCUMENT_STATUSES = [ + "STORED", + "EXTRACTING", + "EXTRACTED", + "FAILED", + "CONFIRMED", + "DISCARDED", +] as const; +export type InboundDocumentStatusValue = + (typeof INBOUND_DOCUMENT_STATUSES)[number]; + /** * The coding systems a fact's STATED code may belong to. The provider emits a * code ONLY when the document writes one — it never machine-guesses. Mirrors @@ -183,12 +200,17 @@ export interface ExtractedFactDto { export interface InboundDocumentDto { id: string; kind: InboundDocumentKindValue; + /** User-given title (plaintext), or null when never set. */ + title: string | null; filename: string | null; mimeType: string; byteSize: number; - status: "EXTRACTING" | "EXTRACTED" | "FAILED" | "CONFIRMED" | "DISCARDED"; + status: InboundDocumentStatusValue; providerType: string | null; + /** Model-transcribed report/collection date (YYYY-MM-DD), or null. */ reportDate: string | null; + /** User-set filing date (YYYY-MM-DD), or null. */ + documentDate: string | null; errorReason: string | null; factCount: number; pendingCount: number; @@ -309,3 +331,88 @@ export const inboundTextExtractSchema = z.object({ }); export type InboundTextExtractInput = z.infer; + +// ─── Library: store / edit / list ────────────────────────────────────────── + +/** A bare YYYY-MM-DD date string (no time component). */ +const isoDateString = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected a YYYY-MM-DD date"); + +export const DOCUMENT_TITLE_MAX = 200; + +/** + * 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. + */ +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(), +}); + +export type DocumentCreateInput = z.infer; + +/** + * Metadata edit (rename / recategorise / set the filing date). At least one + * field must be present. `title` accepts null to clear it; `documentDate` + * accepts null to clear it. No `userId` field — narrowed from the session and + * fed to the Prisma `where` alongside the row id. + */ +export const documentUpdateSchema = z + .object({ + title: z + .string() + .trim() + .max(DOCUMENT_TITLE_MAX) + .nullable() + .optional() + .transform((v) => (v === "" ? null : v)), + kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(), + documentDate: isoDateString.nullable().optional(), + }) + .refine( + (d) => + d.title !== undefined || + d.kind !== undefined || + d.documentDate !== undefined, + { message: "Provide at least one field to update" }, + ); + +export type DocumentUpdateInput = z.infer; + +/** Library list sort columns + page size. */ +export const DOCUMENT_LIST_SORTS = [ + "documentDate", + "createdAt", + "title", +] as const; +export type DocumentListSort = (typeof DOCUMENT_LIST_SORTS)[number]; + +export const DOCUMENT_LIST_MAX_LIMIT = 100; +export const DOCUMENT_LIST_DEFAULT_LIMIT = 50; + +/** + * The library list query: title/filename search, category filter, a + * `documentDate` range, sort + keyset pagination. Parsed off `searchParams`; + * `safeParse` returns 422 on a bad value. + */ +export const documentListQuerySchema = z.object({ + q: z.string().trim().max(100).optional(), + kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(), + from: isoDateString.optional(), + to: isoDateString.optional(), + sort: z.enum(DOCUMENT_LIST_SORTS).default("documentDate"), + order: z.enum(["asc", "desc"]).default("desc"), + cursor: z.string().trim().min(1).max(40).optional(), + limit: z.coerce + .number() + .int() + .min(1) + .max(DOCUMENT_LIST_MAX_LIMIT) + .default(DOCUMENT_LIST_DEFAULT_LIMIT), +}); + +export type DocumentListQuery = z.infer;