mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.
Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.
The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.
Refs #1038, #938
910 lines
32 KiB
TypeScript
910 lines
32 KiB
TypeScript
/**
|
||
* 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<NextResponse> {
|
||
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<Response> {
|
||
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<Array<{ used: bigint }>>`
|
||
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<SerialisableDocument | null> {
|
||
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<Response> {
|
||
// 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 <img>). 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<string, string>();
|
||
for (const row of indexedRows) indexSources.set(row.documentId, row.source);
|
||
const thumbnailIds = new Set<string>();
|
||
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,
|
||
});
|
||
});
|