/** * Document vault: store-first upload + browsable list. * * POST is STORE-ONLY and provider-free. A self-hoster uploads any accepted * document; it is stored ENCRYPTED at rest (binary codec) with * `status: STORED` and NO extraction run. There is no provider resolution, no * AI consent guard, no budget, and NO egress on this path — a file can always * be filed, even on an account with no document-scan provider configured. * * The upload path enforces the vault policy layer * (`src/lib/documents/upload-policy.ts`): magic-byte classification (the wire * Content-Type is never trusted), the admin-tunable per-file cap (bounded * read aborts at the cap), the per-user quota (checked in the same * transaction as the insert, tombstone-inclusive), sha256 duplicate detection * (a same-user re-upload returns the existing live row, never a second copy), * an honoured `Idempotency-Key`, and optional `episodeIds[]` pre-linking. * * v1.39.2 (#1038) — POST also admits a narrow `documents:write` token, the * door another document system (a Paperless workflow, the import script) * pushes through. Such a caller gets its own rate bucket, and a receipt * (`{ id, duplicate }`) instead of the stored row: a credential that can only * add files learns that a file exists, never how its owner filed it. Optional * `sourceSystem` / `sourceId` fields key an import so a re-send is a * duplicate, including after the person deleted the document; `aiRead=defer` * holds back automatic AI reading for the upload. * * GET lists the caller's documents with title/filename search, kind / * episode / year / date-range filters, sort, and keyset pagination — and * NEVER selects the encrypted blob column (`omit: { contentEncrypted: true }`). * * The document is UNTRUSTED (prompt-injection): the server never acts on an * instruction inside it. Storing it does nothing with its contents. */ import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; import { NextResponse } from "next/server"; import { apiHandler, isScopedCredential, requireAuth, requireRecordAuth, type AuthContext, } from "@/lib/api-handler"; import { apiError, apiSuccess, apiValidationError, getClientIp, sanitiseZodIssues, } from "@/lib/api-response"; import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { hashQueryTokens } from "@/lib/documents/content-index"; import { loadConditionLinks, loadDocumentEncounterLinks, narrowOwnedEncounterIds, narrowOwnedEpisodeIds, } from "@/lib/documents/links"; import { encryptDocumentContent, serialiseDocument, type SerialisableDocument, } from "@/lib/documents/store"; import { enqueueDocumentIndex } from "@/lib/jobs/document-index"; import { enqueueDocumentSummary } from "@/lib/jobs/document-summary"; import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail"; import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes"; import { acquireDocumentUploadSlot, detectDocumentType, resolveDocumentLimits, resolveDocumentUploadLimitPerHour, } from "@/lib/documents/upload-policy"; import { withIdempotency } from "@/lib/idempotency"; import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload"; import { linkTargets } from "@/lib/links"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { isP2002 } from "@/lib/prisma-errors"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { documentCreateSchema, documentListQuerySchema, toContentIndexSource, type DocumentSourceSystemValue, } from "@/lib/validations/inbound-documents"; import type { Prisma } from "@/generated/prisma/client"; export const dynamic = "force-dynamic"; /** * 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; /** * Multipart envelope allowance on top of the per-file cap: boundaries plus * the small metadata fields (title / kind / documentDate / episodeIds). The * exact per-file cap is re-enforced on the extracted file bytes below. */ const MULTIPART_OVERHEAD_BYTES = 64 * 1024; const UPLOAD_BODY_TIMEOUT_MS = 30_000; /** Parse a YYYY-MM-DD form field into a UTC midnight Date. */ function isoDateToUtc(value: string): Date { return new Date(`${value}T00:00:00.000Z`); } /** §3.2 — 413 fileTooLarge with the configured limit in `meta`. */ function fileTooLarge(maxFileBytes: number): NextResponse { return apiError("File is too large.", 413, { errorCode: "documents.inbound.fileTooLarge", reason: "fileTooLarge", maxFileBytes, }); } /** Internal signal: the quota gate inside the insert transaction tripped. */ class QuotaExceededError extends Error { constructor(public readonly usedBytes: number) { super("Document quota exceeded"); this.name = "QuotaExceededError"; } } /** What a single upload asked for, carried into the annotations. */ interface UploadContext { userId: string; /** A narrow `documents:write` token, not a session or a `["*"]` token. */ scoped: boolean; sourceSystem: DocumentSourceSystemValue | null; aiDeferred: boolean; } /** * The receipt a scoped caller gets in place of the stored row: the id and * whether the upload was a duplicate. A write-only credential holder who * knows a file's bytes learns that it is stored, not its title, filename, * links or fact counts. */ function receiptResponse( id: string | null, status: 200 | 201, flags: { duplicate: boolean; deleted?: boolean }, ): NextResponse { const data = { id, duplicate: flags.duplicate, ...(flags.deleted ? { deleted: true } : {}), }; return NextResponse.json( { data, error: null, ...(flags.duplicate ? { meta: { duplicate: true, ...(flags.deleted ? { deleted: true } : {}), }, } : {}), }, { status }, ); } /** * v1.39.2 (#1038) — a re-send of an imported document the person deleted. * 200 and no new row, for every caller: the deletion was a decision, and an * importer re-running over the same source must not undo it. `id` is the * tombstoned row while it exists and null once the purge took it; there is no * row left to serialise either way, so the answer is the receipt. */ function deletedResponse(ctx: UploadContext, id: string | null): NextResponse { annotate({ action: { name: "documents.vault.upload" }, meta: { documentId: id, duplicate: true, deleted: true, scoped: ctx.scoped, sourceSystem: ctx.sourceSystem, aiDeferred: ctx.aiDeferred, }, }); return receiptResponse(id, 200, { duplicate: true, deleted: true }); } /** * §3.2 — a duplicate upload is NOT an error: return the existing live row * with `meta.duplicate: true` at the envelope level (the UI toasts "already * stored" and highlights the row). A scoped caller gets the receipt instead. */ async function duplicateResponse( ctx: UploadContext, existing: SerialisableDocument, ): Promise { annotate({ action: { name: "documents.vault.upload" }, meta: { documentId: existing.id, duplicate: true, scoped: ctx.scoped, sourceSystem: ctx.sourceSystem, aiDeferred: ctx.aiDeferred, }, }); if (ctx.scoped) { return receiptResponse(existing.id, 200, { duplicate: true }); } const userId = ctx.userId; const [links, visitLinks, groups] = await Promise.all([ loadConditionLinks(userId, [existing.id]), loadDocumentEncounterLinks(userId, [existing.id]), prisma.extractedFact.groupBy({ by: ["status"], where: { userId, documentId: existing.id }, _count: { _all: true }, }), ]); let factCount = 0; let pendingCount = 0; for (const g of groups) { if (g.status !== "REJECTED") factCount += g._count._all; if (g.status === "PENDING") pendingCount += g._count._all; } return NextResponse.json( { data: serialiseDocument( existing, { factCount, pendingCount }, links.get(existing.id) ?? [], false, null, false, visitLinks.get(existing.id) ?? [], ), error: null, meta: { duplicate: true }, }, { status: 200 }, ); } /** Process one admitted upload. The caller owns and releases its memory slot. */ async function processUpload( request: Request, auth: AuthContext, ): Promise { const { user } = auth; const scoped = isScopedCredential(auth); // Opt-in module gate — even a valid Bearer token is refused when the surface // is off (it ships dark; the user turns it on deliberately). const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; // A scoped token draws on its own bucket, keyed on the token (its id rides // `session.id` on the Bearer path), so an import running overnight cannot // use up the allowance the person's own uploads from the web or the phone // draw on. The cookie / wildcard bucket is unchanged. const rl = scoped ? await checkRateLimit( `documents-upload:token:${auth.session.id}`, resolveDocumentUploadLimitPerHour(), UPLOAD_WINDOW_MS, ) : await checkRateLimit( `documents-upload:${user.id}`, UPLOAD_LIMIT_PER_HOUR, UPLOAD_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 limits = await resolveDocumentLimits(user.id); const bodyCap = limits.maxFileBytes + MULTIPART_OVERHEAD_BYTES; // Instant rejection on a declared oversize (a CD/ISO-sized upload never // allocates); the bounded read below covers chunked/undeclared bodies. const contentLength = Number(request.headers.get("content-length") ?? 0); if (contentLength > bodyCap) { return fileTooLarge(limits.maxFileBytes); } let formData: FormData; try { const bytes = await readBoundedBody(request.body, bodyCap, { signal: request.signal, deadline: Date.now() + UPLOAD_BODY_TIMEOUT_MS, }); formData = await new Request(request.url, { method: "POST", headers: { "content-type": request.headers.get("content-type") ?? "" }, body: bytes, }).formData(); } catch (err) { if (err instanceof BodyTooLargeError) { return fileTooLarge(limits.maxFileBytes); } if (err instanceof DOMException && err.name === "AbortError") { return apiError("Upload body read timed out.", 408, { errorCode: "documents.inbound.uploadTimeout", reason: "uploadTimeout", }); } return apiError("Invalid multipart body", 400); } const file = formData.get("file"); if (!(file instanceof File)) { return apiError("Field 'file' must be a file", 422); } // Optional metadata (title / kind / documentDate / episodeIds / // encounterIds). The file is read separately; these are the form fields // beside it. Both id lists may be repeated. const rawEpisodeIds = formData .getAll("episodeIds") .filter((v): v is string => typeof v === "string"); const rawEncounterIds = formData .getAll("encounterIds") .filter((v): v is string => typeof v === "string"); const parsed = documentCreateSchema.safeParse({ title: formData.get("title") ?? undefined, kind: formData.get("kind") ?? undefined, documentDate: formData.get("documentDate") ?? undefined, episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined, encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined, sourceSystem: formData.get("sourceSystem") ?? undefined, sourceId: formData.get("sourceId") ?? undefined, aiRead: formData.get("aiRead") ?? undefined, }); if (!parsed.success) { return apiValidationError( "Invalid document metadata", sanitiseZodIssues(parsed.error.issues), 422, { errorCode: "documents.inbound.invalidMetadata", }, ); } const sourceSystem = parsed.data.sourceSystem ?? null; const sourceId = sourceSystem ? (parsed.data.sourceId ?? null) : null; const ctx: UploadContext = { userId: user.id, scoped, sourceSystem, aiDeferred: parsed.data.aiRead === "defer", }; // A source key answers before the bytes are looked at: an import re-sending // what it sent before is a duplicate, and one re-sending a document the // person deleted is refused a second copy — live and tombstoned rows both // count (the unique index has no `deleted_at` predicate), and past the purge // the ledger remembers. if (sourceSystem && sourceId) { const keyed = await findBySourceKey(user.id, sourceSystem, sourceId); if (keyed) return keyed.deletedAt ? deletedResponse(ctx, keyed.id) : duplicateResponse(ctx, keyed); const purged = await prisma.documentImportKey.findUnique({ where: { userId_sourceSystem_sourceId: { userId: user.id, sourceSystem, sourceId, }, }, select: { id: true }, }); if (purged) return deletedResponse(ctx, null); } let buffer: Buffer; try { buffer = Buffer.from(await file.arrayBuffer()); } catch { return apiError("Failed to read uploaded file", 400); } if (buffer.byteLength > limits.maxFileBytes) { return fileTooLarge(limits.maxFileBytes); } if (buffer.byteLength === 0) { return apiError("Uploaded file is empty", 422, { errorCode: "documents.inbound.invalidMetadata", }); } // §3.1 — magic-byte classification; the wire Content-Type is never trusted. const detected = detectDocumentType( buffer, typeof file.name === "string" ? file.name : null, ); if (!detected) { return apiError("This file type is not supported.", 415, { errorCode: "documents.inbound.fileType", reason: "unsupportedType", }); } // Pre-linking: every episode id must be a LIVE episode of the caller. const episodeIds = await narrowOwnedEpisodeIds( user.id, parsed.data.episodeIds ?? [], ); if (episodeIds === null) { return apiError("Episode not found", 404, { errorCode: "documents.inbound.episodeNotFound", }); } // Same for the visit ids the review step offered. A refusal rather than a // silent drop: the person saw a visit named and would otherwise watch the // upload succeed without the link they asked for. const encounterIds = await narrowOwnedEncounterIds( user.id, parsed.data.encounterIds ?? [], ); if (encounterIds === null) { return apiError("Visit not found", 404, { errorCode: "documents.inbound.encounterNotFound", }); } // sha256 of the PLAINTEXT for same-user duplicate detection. const contentSha256 = createHash("sha256").update(buffer).digest("hex"); // Fast-path dedupe check; the partial unique index closes the race below. const existing = await prisma.inboundDocument.findFirst({ where: { userId: user.id, contentSha256, deletedAt: null }, omit: { contentEncrypted: true }, }); if (existing) { return duplicateResponse(ctx, existing); } // Quota gate + insert + pre-links in ONE transaction. Usage counts every // non-purged row — tombstones still hold TOAST bytes, so "deleted" bytes // are never invisible weight (undo-delete never changes usage). let document: SerialisableDocument; try { document = await prisma.$transaction(async (tx) => { // Serialise the quota gate per user: without this, N concurrent // uploads all read the same SUM before any of them commits and the // quota can be overshot by up to N × cap in one burst. The advisory // lock is transaction-scoped (released on commit/rollback) and keyed // on the user id, so uploads by different users never queue on each // other. // (`pg_advisory_xact_lock` returns void, which the client cannot // deserialize as a column — selecting FROM it yields a plain int row.) await tx.$queryRaw` SELECT 1 AS locked FROM pg_advisory_xact_lock(hashtextextended('documents-quota:' || ${user.id}, 0)) `; const rows = await tx.$queryRaw>` SELECT COALESCE(SUM(byte_size), 0)::bigint AS used FROM inbound_documents WHERE user_id = ${user.id} `; const usedBytes = Number(rows[0]?.used ?? 0); if (usedBytes + buffer.byteLength > limits.quotaBytes) { throw new QuotaExceededError(usedBytes); } const { content, codec } = encryptDocumentContent(buffer); // No mass assignment — every column is set field-by-field; `userId` // comes from the session, never the body. `documentDate` defaults to // the upload day so display == sort == filter (user-editable later). const created = await tx.inboundDocument.create({ data: { userId: user.id, kind: parsed.data.kind ?? "OTHER", // Stored plaintext on purpose (mirrors `filename`) so the list can // ILIKE-search + ORDER BY it. It MAY hold PHI the user types; that // is the accepted tradeoff for server-side search/sort — the // document body stays encrypted. title: parsed.data.title ?? null, filename: typeof file.name === "string" ? file.name.slice(0, 255) : null, mimeType: detected.mimeType, byteSize: buffer.byteLength, contentEncrypted: content, contentCodec: codec, contentSha256, status: "STORED", documentDate: parsed.data.documentDate ? isoDateToUtc(parsed.data.documentDate) : new Date(), sourceSystem, sourceId, }, omit: { contentEncrypted: true }, }); if (episodeIds.length > 0) { await linkTargets(tx, { userId: user.id, sourceKind: "document", sourceId: created.id, targetKind: "conditionEpisode", targetIds: episodeIds, }); } if (encounterIds.length > 0) { await linkTargets(tx, { userId: user.id, sourceKind: "document", sourceId: created.id, targetKind: "encounter", targetIds: encounterIds, }); } return created; }); } catch (err) { if (err instanceof QuotaExceededError) { return apiError("Storage quota exceeded.", 413, { errorCode: "documents.inbound.quotaExceeded", reason: "quotaExceeded", quotaBytes: limits.quotaBytes, usedBytes: err.usedBytes, }); } if (isP2002(err)) { // A racing upload won one of the two partial unique indexes — the same // source key, or the same bytes. Surface the winner exactly as the fast // paths above would have. if (sourceSystem && sourceId) { const keyed = await findBySourceKey(user.id, sourceSystem, sourceId); if (keyed) { return keyed.deletedAt ? deletedResponse(ctx, keyed.id) : duplicateResponse(ctx, keyed); } } const winner = await prisma.inboundDocument.findFirst({ where: { userId: user.id, contentSha256, deletedAt: null }, omit: { contentEncrypted: true }, }); if (winner) return duplicateResponse(ctx, winner); } throw err; } await auditLog("documents.inbound.store", { userId: user.id, ipAddress: getClientIp(request), details: { documentId: document.id, mime: detected.mimeType, ...(scoped ? { scoped: true } : {}), ...(sourceSystem ? { sourceSystem } : {}), }, }); annotate({ action: { name: "documents.vault.upload" }, meta: { documentId: document.id, byteSize: document.byteSize, servingClass: detected.servingClass, linked: episodeIds.length, linkedVisits: encounterIds.length, scoped, sourceSystem, aiDeferred: ctx.aiDeferred, }, }); // Auto-index the freshly stored document for content search: enqueue a // fire-and-forget background job (provider-first, local text-layer fallback). // The upload response never blocks on or fails because of indexing — the // enqueue is not awaited and swallows its own errors (a missing boss or a // transient send failure is a silent no-op). Only fresh inserts enqueue — a // duplicate upload returns early above and never reaches here. // // `aiRead=defer` narrows it to the local text layer: an import of a whole // archive must not turn into one provider call per file. The person reads // them later, deliberately, from the document itself. if (ctx.aiDeferred) { void enqueueDocumentIndex(user.id, document.id, { localOnly: true }); } else { void enqueueDocumentIndex(user.id, document.id); } // Render a preview thumbnail in the background too (pure local compute — no // egress). Same fire-and-forget contract: the upload never blocks on or fails // because of it, and a dropped enqueue is recoverable via the boot backfill. // Only fresh inserts reach here (a duplicate returns early above). void enqueueDocumentThumbnail(user.id, document.id); // Summarise the freshly stored document in the background — but ONLY when the // `documentsAutoAiRead` opt-in is ON (the job re-checks it and the egress // consent, and no-ops otherwise) and the `documentAi` capability is open for // this record. The upload itself never depends on AI: the document is stored // above whatever the capability says, and the index job still runs its // provider-free text-layer path. The persisted summary shows on the detail // view. Same fire-and-forget contract: the upload never blocks on or fails // because of it. Only fresh inserts reach here (a duplicate returns early). // // A deferred upload skips it outright and keeps `summaryState: NONE`, so // the detail sheet offers "Generate summary" rather than a pending state. const documentAi = ctx.aiDeferred ? { available: false as const, reason: "deferred" } : await getAiCapability("documentAi"); if (documentAi.available) { void enqueueDocumentSummary(user.id, document.id); } else { annotate({ action: { name: "documents.summary.enqueueSkipped" }, meta: { documentId: document.id, reason: documentAi.reason }, }); } if (scoped) return receiptResponse(document.id, 201, { duplicate: false }); const [links, visitLinks] = await Promise.all([ loadConditionLinks(user.id, [document.id]), loadDocumentEncounterLinks(user.id, [document.id]), ]); return apiSuccess( serialiseDocument( document, { factCount: 0, pendingCount: 0 }, links.get(document.id) ?? [], false, null, false, visitLinks.get(document.id) ?? [], ), 201, ); } /** * Find a document by its import source key, live or tombstoned. The blob * column is never selected. */ function findBySourceKey( userId: string, sourceSystem: DocumentSourceSystemValue, sourceId: string, ): Promise { return prisma.inboundDocument.findFirst({ where: { userId, sourceSystem, sourceId }, omit: { contentEncrypted: true }, }); } /** Authenticate and reserve memory capacity before any request-body read. */ async function postUpload(request: Request): Promise { // The one route that names `documents:write`. Every other vault leg — // list, detail, original, thumbnail, bulk, AI — declares no scope and so // refuses the token (`bearer-scope-enforcement-guard.test.ts`). const auth = await requireAuth(DOCUMENTS_WRITE_SCOPE); const { user } = auth; const releaseUploadSlot = acquireDocumentUploadSlot(user.id); if (!releaseUploadSlot) { const response = apiError( "Too many uploads are already in progress. Try again shortly.", 429, { errorCode: "documents.inbound.uploadBusy", reason: "uploadBusy", }, ); response.headers.set("Retry-After", "1"); return response; } try { return await processUpload(request, auth); } finally { releaseUploadSlot(); } } export const POST = apiHandler(withIdempotency<[Request]>(postUpload)); /** * GET — list the record's documents (search / filter / sort / paginate). * * v1.36.x — delegable. `/documents` carries `sharedRecord: true` in the nav * model, so the shell offers the destination under a switch and every read * behind it refused: the vault rendered a query-error card, and the illness * page's episode-documents tile rendered another one beside it. A clinical * letter is health data belonging to the record, and reading it is the whole * point of handing somebody the record. * * The POST above is NOT delegable and declares its own scope. Uploading is not * a verb the grant admits, and the module-level split matters here: the * resolver escalates any non-safe method to `"write"`, so the two arms have to * declare separately rather than share one line. */ export const GET = apiHandler(async (request: Request) => { const { user } = await requireRecordAuth("read", "documents"); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; const url = new URL(request.url); // `kind` is a multi-value facet: repeated params and/or comma-separated. const kinds = url.searchParams .getAll("kind") .flatMap((v) => v.split(",")) .map((v) => v.trim()) .filter((v) => v.length > 0); const single = Object.fromEntries(url.searchParams); const parsed = documentListQuerySchema.safeParse({ ...single, kind: kinds.length > 0 ? kinds : undefined, }); if (!parsed.success) { return apiValidationError( "Invalid list query", sanitiseZodIssues(parsed.error.issues), 422, { errorCode: "documents.inbound.invalidQuery", }, ); } const { q, kind, episodeId, encounterId, year, from, to, sort, order, cursor, limit, } = parsed.data; const where: Prisma.InboundDocumentWhereInput = { userId: user.id, deletedAt: null, }; if (kind && kind.length > 0) where.kind = { in: kind }; if (episodeId) where.conditionLinks = { some: { episodeId } }; // The visit filter reads the SAME table the visit's own sheet writes, from // the document side. Filtering rather than post-filtering: a page of fifty // documents narrowed in Node would page wrongly. if (encounterId) where.encounterLinks = { some: { encounterId } }; if (year !== undefined) { where.documentDate = { gte: new Date(Date.UTC(year, 0, 1)), lt: new Date(Date.UTC(year + 1, 0, 1)), }; } else if (from || to) { where.documentDate = { ...(from ? { gte: isoDateToUtc(from) } : {}), // inclusive end-of-day for the `to` bound ...(to ? { lt: new Date(isoDateToUtc(to).getTime() + 86_400_000) } : {}), }; } if (q) { // Substring match on the short plaintext fields … const or: Prisma.InboundDocumentWhereInput[] = [ { title: { contains: q, mode: "insensitive" } }, { filename: { contains: q, mode: "insensitive" } }, ]; // … unioned with a WHOLE-WORD content match over the blind token index. // The query is tokenised + HMAC'd the same way the index was built, then // matched with a GIN-accelerated array-overlap (`hasSome` → `&&`). The // list still never selects the encrypted text — only the opaque hashes are // touched, in the related table. Degrades silently to title/filename when // the caller has no indexed documents (no rows overlap). const hashes = hashQueryTokens(q); if (hashes.length > 0) { or.push({ contentIndex: { is: { searchTokens: { hasSome: hashes } } } }); } where.OR = or; } // 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 }, ]; // Hardening: the list NEVER fetches the encrypted blob column — a page of // 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request. const rows = await prisma.inboundDocument.findMany({ where, omit: { contentEncrypted: true }, orderBy, take: limit + 1, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), }); const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null; // Fact tallies for the whole page in ONE grouped count — no per-document // fan-out and no materialising fact-id rows. `factCount` counts every // non-REJECTED fact (a rejected fact is discarded, not part of the // document's tally), `pendingCount` the PENDING subset awaiting review. const factCounts = new Map< string, { factCount: number; pendingCount: number } >(); if (page.length > 0) { const groups = await prisma.extractedFact.groupBy({ by: ["documentId", "status"], where: { userId: user.id, documentId: { in: page.map((d) => d.id) } }, _count: { _all: true }, }); for (const g of groups) { const entry = factCounts.get(g.documentId) ?? { factCount: 0, pendingCount: 0, }; const n = g._count._all; if (g.status !== "REJECTED") entry.factCount += n; if (g.status === "PENDING") entry.pendingCount += n; factCounts.set(g.documentId, entry); } } // Condition/visit links + the content-index and thumbnail probes for the // page, all in ONE Promise.all — four independent grouped queries (no // N+1, never a ciphertext/blob column), and the vault polls this list // every few seconds so two serialized round-trips were pure added // latency (A5-4). const pageIds = page.map((d) => d.id); const [linkMap, visitLinkMap, indexedRows, thumbRows] = await Promise.all([ loadConditionLinks(user.id, pageIds), loadDocumentEncounterLinks(user.id, pageIds), // Which of the page's documents have a content index (drives the // searchable status + the provenance the UI reads to tell an AI-read // document from a locally-indexed one). page.length > 0 ? prisma.documentContentIndex.findMany({ where: { userId: user.id, documentId: { in: pageIds } }, select: { documentId: true, source: true }, }) : Promise.resolve([]), // Which of the page's documents have a preview thumbnail (gates the // card's ). Grouped on the 1:1 side table. page.length > 0 ? prisma.documentThumbnail.findMany({ where: { userId: user.id, documentId: { in: pageIds } }, select: { documentId: true }, }) : Promise.resolve([]), ]); const indexSources = new Map(); for (const row of indexedRows) indexSources.set(row.documentId, row.source); const thumbnailIds = new Set(); for (const row of thumbRows) thumbnailIds.add(row.documentId); annotate({ action: { name: "documents.inbound.list" }, meta: { count: page.length, sort, order, filtered: Boolean( q || (kind && kind.length > 0) || episodeId || encounterId || year, ), }, }); return apiSuccess({ documents: page.map((doc) => serialiseDocument( doc, factCounts.get(doc.id) ?? { factCount: 0, pendingCount: 0 }, linkMap.get(doc.id) ?? [], indexSources.has(doc.id), toContentIndexSource(indexSources.get(doc.id)), thumbnailIds.has(doc.id), visitLinkMap.get(doc.id) ?? [], ), ), nextCursor, }); });