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

@@ -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,
});
});