Merge v1.25.1: documents library backend — store-without-AI, optional extract, edit, list

# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/documents/store.ts
#	src/lib/openapi/routes/documents.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 23:08:54 +02:00
commit cdd66b6620
12 files changed
+1820 -472

No files matched your search

+262 -22
View File
@@ -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:
@@ -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");
+28 -8
View File
@@ -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")
}
@@ -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<string, unknown> = {}) {
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();
});
});
@@ -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();
});
});
@@ -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<Response> {
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<Response> {
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",
});
}
}
+78 -2
View File
@@ -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();
@@ -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<Response>;
const get = GET as unknown as (r: Request) => Promise<Response>;
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<string, string> = {}) {
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<string, unknown> = {}) {
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);
});
});
+167 -393
View File
@@ -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<ArrayBuffer>;
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<Response> {
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<Response> {
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,
});
});
+11 -4
View File
@@ -36,10 +36,13 @@ export function encryptDocumentToBytes(bytes: Buffer): Uint8Array<ArrayBuffer> {
}
/**
* 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,
+195 -42
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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<ZodOpenApiObject["paths"]> = {
},
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
...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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
+108 -1
View File
@@ -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<typeof inboundTextExtractSchema>;
// ─── 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<typeof documentCreateSchema>;
/**
* 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<typeof documentUpdateSchema>;
/** 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<typeof documentListQuerySchema>;