Merge branch 'feat/document-picker' into release/v1.39.3

This commit is contained in:
Marc-André Bombeck committed 2026-09-26 17:00:45 +02:00
commit a8cf4c1dad
71 files changed
+8934 -375

No files matched your search

@@ -67,6 +67,11 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
capabilities: ["documentAi"],
why: "The upload is data and always accepted; only the AI work queued after it follows the capability.",
},
"src/app/api/documents/sources/[system]/import/route.ts": {
kind: "mixed",
capabilities: ["documentAi"],
why: "A picked document is stored whatever AI says; only the summary queued after a fresh insert follows the capability.",
},
"src/app/api/auth/me/documents-auto-ai-read/route.ts": {
kind: "mixed",
capabilities: ["documentAi"],
@@ -51,10 +51,10 @@ vi.mock("@/lib/db", () => {
findFirst: vi.fn(),
},
documentImportKey: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
documentSourceAlias: {
findUnique: vi.fn(),
findFirst: vi.fn(),
createMany: vi.fn(),
count: vi.fn(),
},
@@ -239,10 +239,10 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.documentImportKey.findUnique).mockResolvedValue(
vi.mocked(prisma.documentImportKey.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.documentSourceAlias.findUnique).mockResolvedValue(
vi.mocked(prisma.documentSourceAlias.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.documentSourceAlias.createMany).mockResolvedValue({
@@ -783,7 +783,7 @@ describe("POST /api/documents/inbound — document import", () => {
});
it("remembers a purged import through the ledger", async () => {
vi.mocked(prisma.documentImportKey.findUnique).mockResolvedValue({
vi.mocked(prisma.documentImportKey.findFirst).mockResolvedValue({
id: "key-1",
} as never);
const res = await post(
@@ -909,6 +909,8 @@ describe("POST /api/documents/inbound — document import", () => {
userId: "user-1",
documentId: "doc-manual",
sourceSystem: "PAPRA",
// Sent without an instance: the alias matches any Papra.
sourceInstance: null,
sourceId: "doc_9",
},
],
@@ -936,7 +938,7 @@ describe("POST /api/documents/inbound — document import", () => {
});
it("resolves a key held as an alias of a deleted document to deleted", async () => {
vi.mocked(prisma.documentSourceAlias.findUnique).mockResolvedValue({
vi.mocked(prisma.documentSourceAlias.findFirst).mockResolvedValue({
document: docRow({ id: "doc-gone", deletedAt: new Date() }),
} as never);
const res = await post(
+80 -280
View File
@@ -32,7 +32,6 @@
* 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";
@@ -51,42 +50,38 @@ import {
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 {
ingestDocument,
isoDateToUtc,
personalUploadBucket,
UPLOAD_LIMIT_PER_HOUR,
UPLOAD_WINDOW_MS,
} from "@/lib/documents/ingest";
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 {
checkSourceLookupRateLimit,
findSourceKey,
rememberSourceAlias,
type SourceKeyMatch,
} from "@/lib/documents/source-key";
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,
@@ -99,13 +94,6 @@ 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
@@ -114,11 +102,6 @@ const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
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, {
@@ -128,14 +111,6 @@ function fileTooLarge(maxFileBytes: number): NextResponse {
});
}
/** 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;
@@ -299,14 +274,17 @@ async function processUpload(
const url = new URL(request.url);
const querySystem = url.searchParams.get("sourceSystem");
const queryId = url.searchParams.get("sourceId");
const queryInstance = url.searchParams.get("sourceInstance");
let queryKey: {
sourceSystem: DocumentSourceSystemValue;
sourceId: string;
sourceInstance?: string;
} | null = null;
if (querySystem !== null || queryId !== null) {
const parsedKey = documentSourceKeySchema.safeParse({
sourceSystem: querySystem ?? undefined,
sourceId: queryId ?? undefined,
sourceInstance: queryInstance ?? undefined,
});
if (!parsedKey.success) {
return apiValidationError(
@@ -337,6 +315,7 @@ async function processUpload(
user.id,
queryKey.sourceSystem,
queryKey.sourceId,
queryKey.sourceInstance ?? null,
);
if (match) {
return answerSourceKey(
@@ -357,7 +336,7 @@ async function processUpload(
// draw on. The cookie / wildcard bucket is unchanged.
const bucketKey = scoped
? `documents-upload:token:${auth.session.id}`
: `documents-upload:${user.id}`;
: personalUploadBucket(user.id);
const rl = await checkRateLimit(
bucketKey,
scoped ? resolveDocumentUploadLimitPerHour() : UPLOAD_LIMIT_PER_HOUR,
@@ -433,6 +412,7 @@ async function processUpload(
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
sourceSystem: formData.get("sourceSystem") ?? undefined,
sourceId: formData.get("sourceId") ?? undefined,
sourceInstance: formData.get("sourceInstance") ?? undefined,
aiRead: formData.get("aiRead") ?? undefined,
});
if (!parsed.success) {
@@ -450,10 +430,14 @@ async function processUpload(
// both they have to agree.
const formSystem = parsed.data.sourceSystem ?? null;
const formId = formSystem ? (parsed.data.sourceId ?? null) : null;
const formInstance = formId ? (parsed.data.sourceInstance ?? null) : null;
if (
queryKey &&
formSystem !== null &&
(formSystem !== queryKey.sourceSystem || formId !== queryKey.sourceId)
(formSystem !== queryKey.sourceSystem ||
formId !== queryKey.sourceId ||
(formInstance !== null &&
formInstance !== (queryKey.sourceInstance ?? null)))
) {
return apiError(
"The source key in the address and in the form differ.",
@@ -463,6 +447,9 @@ async function processUpload(
}
const sourceSystem = queryKey?.sourceSystem ?? formSystem;
const sourceId = queryKey?.sourceId ?? formId;
const sourceInstance = sourceId
? (queryKey?.sourceInstance ?? formInstance ?? null)
: null;
const ctx: UploadContext = {
userId: user.id,
scoped,
@@ -476,7 +463,12 @@ async function processUpload(
// count (the unique index has no `deleted_at` predicate), and past the purge
// the ledger remembers.
if (sourceSystem && sourceId && !queryKey) {
const match = await findSourceKey(user.id, sourceSystem, sourceId);
const match = await findSourceKey(
user.id,
sourceSystem,
sourceId,
sourceInstance,
);
if (match) return answerSourceKey(ctx, match);
}
@@ -486,157 +478,59 @@ async function processUpload(
} 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 },
// Everything from here to the stored row is the shared ingest path
// (`src/lib/documents/ingest.ts`), the one the document picker's import
// stores through as well.
const result = await ingestDocument({
userId: user.id,
scoped,
ipAddress: getClientIp(request),
bytes: buffer,
filename: typeof file.name === "string" ? file.name : null,
title: parsed.data.title ?? null,
kind: parsed.data.kind ?? null,
documentDate: parsed.data.documentDate ?? null,
episodeIds: parsed.data.episodeIds ?? [],
encounterIds: parsed.data.encounterIds ?? [],
sourceSystem,
sourceId,
sourceInstance,
aiDeferred: ctx.aiDeferred,
sourceKeyChecked: true,
limits,
// Only the AI work queued after a fresh insert follows the capability;
// the upload itself is data and always accepted.
documentAi: () => getAiCapability("documentAi"),
});
if (existing) {
// An import sending bytes that are already stored under another key (or
// none) is answered with that document — and the key is remembered for
// it, so once the person deletes the document this key stays deleted too.
if (sourceSystem && sourceId) {
const remembered = await rememberSourceAlias(
user.id,
existing.id,
sourceSystem,
sourceId,
);
if (remembered === "limit") return aliasLimitResponse();
}
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,
aiReadDeferred: ctx.aiDeferred,
},
omit: { contentEncrypted: true },
switch (result.kind) {
case "sourceKey":
return answerSourceKey(ctx, result.match);
case "tooLarge":
return fileTooLarge(result.maxFileBytes);
case "empty":
return apiError("Uploaded file is empty", 422, {
errorCode: "documents.inbound.invalidMetadata",
});
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) {
case "unsupportedType":
return apiError("This file type is not supported.", 415, {
errorCode: "documents.inbound.fileType",
reason: "unsupportedType",
});
case "episodeNotFound":
return apiError("Episode not found", 404, {
errorCode: "documents.inbound.episodeNotFound",
});
case "encounterNotFound":
return apiError("Visit not found", 404, {
errorCode: "documents.inbound.encounterNotFound",
});
case "aliasLimit":
return aliasLimitResponse();
case "duplicate":
return duplicateResponse(ctx, result.document);
case "quotaExceeded":
// The figures are the owner's; a write-only token learns only that the
// vault is full.
return apiError("Storage quota exceeded.", 413, {
@@ -644,107 +538,13 @@ async function processUpload(
reason: "quotaExceeded",
...(scoped
? {}
: { quotaBytes: limits.quotaBytes, usedBytes: err.usedBytes }),
: { quotaBytes: result.quotaBytes, usedBytes: result.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 match = await findSourceKey(user.id, sourceSystem, sourceId);
if (match) return answerSourceKey(ctx, match);
}
const winner = await prisma.inboundDocument.findFirst({
where: { userId: user.id, contentSha256, deletedAt: null },
omit: { contentEncrypted: true },
});
if (winner) {
if (sourceSystem && sourceId) {
const remembered = await rememberSourceAlias(
user.id,
winner.id,
sourceSystem,
sourceId,
);
if (remembered === "limit") return aliasLimitResponse();
}
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 },
});
case "stored":
break;
}
const document = result.document;
if (scoped) return receiptResponse(document.id, 201, { duplicate: false });
const [links, visitLinks] = await Promise.all([
@@ -57,6 +57,7 @@ export const GET = apiHandler(async (request: Request) => {
const parsed = documentSourceKeySchema.safeParse({
sourceSystem: url.searchParams.get("sourceSystem") ?? undefined,
sourceId: url.searchParams.get("sourceId") ?? undefined,
sourceInstance: url.searchParams.get("sourceInstance") ?? undefined,
});
if (!parsed.success) {
return apiValidationError(
@@ -71,6 +72,7 @@ export const GET = apiHandler(async (request: Request) => {
user.id,
parsed.data.sourceSystem,
parsed.data.sourceId,
parsed.data.sourceInstance ?? null,
);
annotate({
action: { name: "documents.vault.sourceLookup" },
@@ -0,0 +1,385 @@
/**
* Document picker (#1038): import one picked document from a connected
* archive into the vault.
*
* One document per request, so the sheet can show each one's progress and
* result, and no single request holds more than one file in memory. The path:
*
* 1. the link target, when the picker was opened from a record, must be one
* of the person's own live records (404 before anything leaves the
* server);
* 2. the source key answers first, without a download: a document already
* imported (or already holding this key as an alias) is a `duplicate`,
* and one the person deleted here is `deleted` — nothing is fetched and
* nothing is stored, exactly as the token upload answers the same key;
* 3. only then is the source asked (on the search allowance, like every
* request to it) and an upload slot taken. One import per key and person
* runs at a time under an advisory lock, so a double click downloads the
* document once. The details and the original file are fetched (bounded
* by the vault's per-file cap); only a file that arrived is charged to
* the vault's own upload allowance (60 an hour, shared with uploads from
* the web and the phone), and it goes through `ingestDocument`, the same
* function the upload route stores through: type check, content dedup,
* quota, provenance (`sourceSystem` / `sourceId` / `sourceInstance`), the
* background jobs;
* 4. the document is linked to the record through `src/lib/links/`.
*
* AI reading follows the person's own setting, unlike the token upload's
* default of holding it back: the person picked this document by hand, as
* deliberately as an upload. The hourly allowance and the daily AI budget
* bound it either way.
*
* Cookie-only and owner-only; see `admitDocumentSourceCaller`.
*/
import { Buffer } from "node:buffer";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { prisma } from "@/lib/db";
import {
ingestDocument,
personalUploadBucket,
UPLOAD_LIMIT_PER_HOUR,
UPLOAD_WINDOW_MS,
} from "@/lib/documents/ingest";
import {
checkSourceLookupRateLimit,
findSourceKey,
type SourceKeyMatch,
} from "@/lib/documents/source-key";
import type { SourceClient } from "@/lib/documents/sources/clients";
import { clientFor, loadConnection } from "@/lib/documents/sources/connections";
import {
admitDocumentSourceCaller,
checkSearchRateLimit,
responseForSourceFailure,
sourceErrorResponse,
systemParam,
} from "@/lib/documents/sources/route-support";
import {
isSourceDocumentId,
type DocumentImportOutcome,
type DocumentPickerLinkKind,
type DocumentPickerSystem,
type DocumentSourceImportDto,
} from "@/lib/documents/sources/types";
import {
acquireDocumentUploadSlot,
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
import { linkTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { documentSourceImportSchema } from "@/lib/validations/document-sources";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ system: string }> };
/** How long an import waits for a database connection to hold its lock on. */
const IMPORT_LOCK_WAIT_MS = 10_000;
/** Longest a single import may hold its key: the download plus the store. */
const IMPORT_LOCK_HOLD_MS = 150_000;
interface LinkTarget {
kind: DocumentPickerLinkKind;
id: string;
}
/** Is the record the picker was opened from one of the person's live ones? */
async function ownsLinkTarget(
userId: string,
link: LinkTarget,
): Promise<boolean> {
const where = { id: link.id, userId, deletedAt: null };
const select = { id: true } as const;
switch (link.kind) {
case "conditionEpisode":
return (
(await prisma.illnessEpisode.findFirst({ where, select })) !== null
);
case "encounter":
return (await prisma.encounter.findFirst({ where, select })) !== null;
case "vaccination":
return (
(await prisma.vaccinationRecord.findFirst({ where, select })) !== null
);
}
}
/** File the document against the record, from the record's side. */
async function linkDocument(
userId: string,
link: LinkTarget | undefined,
documentId: string,
): Promise<boolean> {
if (!link) return false;
const result = await prisma.$transaction((tx) =>
linkTargets(tx, {
userId,
sourceKind: link.kind,
sourceId: link.id,
targetKind: "document",
targetIds: [documentId],
}),
);
return !result.unknownSource && result.unknownTargetIds.length === 0;
}
function respond(
system: string,
outcome: DocumentImportOutcome,
documentId: string | null,
linked: boolean,
): NextResponse {
annotate({
action: { name: "documents.sources.import" },
meta: { system, outcome, linked, documentId },
});
const data: DocumentSourceImportDto = { outcome, documentId, linked };
return NextResponse.json(
{ data, error: null },
{ status: outcome === "imported" ? 201 : 200 },
);
}
/** A key the vault already holds: link a live one, never revive a deleted one. */
async function answerHeldKey(
system: string,
userId: string,
link: LinkTarget | undefined,
match: SourceKeyMatch,
): Promise<NextResponse> {
if (match.state === "deleted") {
return respond(system, "deleted", match.id, false);
}
const linked = await linkDocument(userId, link, match.document.id);
return respond(system, "duplicate", match.document.id, linked);
}
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const auth = await requireAuth();
const refused = await admitDocumentSourceCaller(auth);
if (refused) return refused;
const system = await systemParam(params);
if (system instanceof Response) return system;
const userId = auth.user.id;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = documentSourceImportSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid import request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { sourceId, kind, link } = parsed.data;
// Only an id this system could have issued goes into a request path.
if (!isSourceDocumentId(system, sourceId)) {
return apiError("Not a document id of this archive.", 422);
}
const row = await loadConnection(userId, system);
if (!row) return sourceErrorResponse("notConnected");
let client;
try {
client = clientFor(row);
} catch (err) {
return responseForSourceFailure(err);
}
const instance = client.instance;
if (link && !(await ownsLinkTarget(userId, link))) {
return sourceErrorResponse("linkTargetNotFound");
}
// The source key answers before anything is downloaded or charged, on the
// same metered lookup allowance the upload's key check draws on.
const lookupRl = await checkSourceLookupRateLimit(
false,
auth.session.id,
userId,
);
if (!lookupRl.allowed) {
return sourceErrorResponse(
"rateLimited",
undefined,
rateLimitHeaders(lookupRl),
);
}
const held = await findSourceKey(userId, system, sourceId, instance);
if (held) return answerHeldKey(system, userId, link, held);
// A request to the source counts against the search allowance like any
// other; the upload allowance is charged only once a file arrived.
const fetchRl = await checkSearchRateLimit(userId);
if (!fetchRl.allowed) {
return sourceErrorResponse(
"rateLimited",
undefined,
rateLimitHeaders(fetchRl),
);
}
const release = acquireDocumentUploadSlot(userId);
if (!release) {
return apiError(
"Too many uploads are already in progress. Try again shortly.",
429,
{
errorCode: "documents.inbound.uploadBusy",
reason: "uploadBusy",
headers: { "Retry-After": "1" },
},
);
}
try {
// One import of one key at a time per person: a second tab or a double
// click waits here, then finds the key the first one stored, so the
// document is downloaded once. The lock is transaction-scoped and the
// transaction holds nothing else; the store runs in its own.
return await prisma.$transaction(
async (tx) => {
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended(${`documents-import:${userId}:${system}:${instance}:${sourceId}`}, 0))
`;
const raced = await findSourceKey(userId, system, sourceId, instance);
if (raced) return answerHeldKey(system, userId, link, raced);
return importOne({
request,
client,
userId,
system,
instance,
sourceId,
kind: kind ?? null,
link,
});
},
{ maxWait: IMPORT_LOCK_WAIT_MS, timeout: IMPORT_LOCK_HOLD_MS },
);
} finally {
release();
}
},
);
/** Fetch one document and store it; the caller holds the key's lock. */
async function importOne(args: {
request: Request;
client: SourceClient;
userId: string;
system: DocumentPickerSystem;
instance: string;
sourceId: string;
kind: InboundDocumentKindValue | null;
link: LinkTarget | undefined;
}): Promise<Response> {
const { request, client, userId, system, instance, sourceId, kind, link } =
args;
const limits = await resolveDocumentLimits(userId);
let meta;
let file;
try {
meta = await client.document(sourceId);
file = await client.download(sourceId, limits.maxFileBytes);
} catch (err) {
return responseForSourceFailure(err);
}
const rl = await checkRateLimit(
personalUploadBucket(userId),
UPLOAD_LIMIT_PER_HOUR,
UPLOAD_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many uploads. Try again later.", 429, {
errorCode: "documents.inbound.rateLimited",
headers: rateLimitHeaders(rl),
});
}
const result = await ingestDocument({
userId,
scoped: false,
ipAddress: getClientIp(request),
bytes: Buffer.from(file.bytes),
filename: meta.filename ?? file.filename,
title: meta.title,
kind,
documentDate: meta.date,
episodeIds: [],
encounterIds: [],
sourceSystem: system,
sourceId,
sourceInstance: instance,
aiDeferred: false,
sourceKeyChecked: true,
limits,
// The import is data; only the summary queued after a fresh insert
// follows the capability (and the person's auto-read setting).
documentAi: () => getAiCapability("documentAi"),
});
switch (result.kind) {
case "sourceKey":
return answerHeldKey(system, userId, link, result.match);
case "duplicate": {
// The same bytes were already in the vault (an upload, or another
// archive); the key is now remembered for that document.
const linked = await linkDocument(userId, link, result.document.id);
return respond(system, "duplicate", result.document.id, linked);
}
case "stored": {
const linked = await linkDocument(userId, link, result.document.id);
return respond(system, "imported", result.document.id, linked);
}
case "tooLarge":
return apiError("File is too large.", 413, {
errorCode: "documents.inbound.fileTooLarge",
reason: "fileTooLarge",
maxFileBytes: result.maxFileBytes,
});
case "empty":
case "unsupportedType":
return apiError("This file type is not supported.", 415, {
errorCode: "documents.inbound.fileType",
reason: "unsupportedType",
});
case "quotaExceeded":
return apiError("Storage quota exceeded.", 413, {
errorCode: "documents.inbound.quotaExceeded",
reason: "quotaExceeded",
quotaBytes: result.quotaBytes,
usedBytes: result.usedBytes,
});
case "aliasLimit":
return apiError(
"This file is already stored under too many source ids.",
409,
{ errorCode: "documents.inbound.sourceAliasLimit" },
);
case "episodeNotFound":
case "encounterNotFound":
// No pre-links are passed on this path; unreachable, but a result
// the switch does not name must not fall through silently.
return sourceErrorResponse("linkTargetNotFound");
}
}
@@ -0,0 +1,204 @@
/**
* Document picker (#1038): save or remove the person's connection to one
* document archive (`paperless` or `papra`).
*
* PUT validates the base address against the operator's list
* (`DOCUMENT_SOURCE_ORIGINS`), tests the connection live before storing
* anything (a wrong token or an unreachable host is reported here, not on the
* first search), then stores the token encrypted. A PUT without a token keeps
* the stored one, so the base path or the Papra organization can change
* without typing the key again, but only on the same origin: a new origin, and
* the first save, must carry it (422 `documents.sources.tokenRequired`). The response is the
* connection without its token, like every read of it.
*
* DELETE removes the row, and with it the token.
*
* Cookie-only and owner-only; see `admitDocumentSourceCaller`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { decrypt, encrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { sourceClient } from "@/lib/documents/sources/clients";
import {
loadConnection,
toConnectionDto,
} from "@/lib/documents/sources/connections";
import { DocumentSourceError } from "@/lib/documents/sources/http";
import { evaluateSourceBaseUrl } from "@/lib/documents/sources/origins";
import {
admitDocumentSourceCaller,
checkConnectRateLimit,
responseForSourceFailure,
sourceErrorResponse,
systemParam,
} from "@/lib/documents/sources/route-support";
import { annotate } from "@/lib/logging/context";
import { rateLimitHeaders } from "@/lib/rate-limit";
import { documentSourceSaveSchema } from "@/lib/validations/document-sources";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ system: string }> };
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const auth = await requireAuth();
const refused = await admitDocumentSourceCaller(auth);
if (refused) return refused;
const system = await systemParam(params);
if (system instanceof Response) return system;
const userId = auth.user.id;
annotate({ action: { name: "documents.sources.save" }, meta: { system } });
const rl = await checkConnectRateLimit(userId);
if (!rl.allowed) {
return sourceErrorResponse(
"rateLimited",
undefined,
rateLimitHeaders(rl),
);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
});
if (jsonError) return jsonError;
const parsed = documentSourceSaveSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid connection details",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const verdict = evaluateSourceBaseUrl(parsed.data.baseUrl);
if (!verdict.ok) {
return verdict.reason === "notAllowed"
? sourceErrorResponse("originNotAllowed")
: apiError("Enter the full address, starting with https://.", 422, {
errorCode: "documents.sources.invalidAddress",
});
}
const organizationId =
system === "PAPRA" ? (parsed.data.organizationId ?? null) : null;
if (system === "PAPRA" && !organizationId) {
return apiError("Papra needs the organization id.", 422, {
errorCode: "documents.sources.organizationRequired",
});
}
const existing = await loadConnection(userId, system);
// The stored token is only ever sent back to the origin it was saved
// for. A new address on another origin needs the token typed again, or
// saving an address would hand the old archive's token to whatever the
// new one is.
const reusable =
existing !== null &&
evaluateSourceBaseUrl(existing.baseUrl, new Set([verdict.origin])).ok;
const token =
parsed.data.token ??
(existing && reusable ? decrypt(existing.tokenEncrypted) : null);
if (!token) {
return apiError("Enter the API token.", 422, {
errorCode: "documents.sources.tokenRequired",
});
}
// Live check before anything is stored: the row only ever holds a
// connection that answered once.
try {
await sourceClient({
system,
origin: verdict.origin,
baseUrl: verdict.baseUrl,
organizationId,
token,
}).test();
} catch (err) {
if (err instanceof DocumentSourceError) {
annotate({ meta: { refused: err.code } });
}
return responseForSourceFailure(err);
}
const now = new Date();
const tokenEncrypted = encrypt(token);
// Field by field; `userId` from the session, never the body.
const saved = await prisma.documentSourceConnection.upsert({
where: { userId_system: { userId, system } },
create: {
userId,
system,
baseUrl: verdict.baseUrl,
organizationId,
tokenEncrypted,
lastVerifiedAt: now,
},
update: {
baseUrl: verdict.baseUrl,
organizationId,
tokenEncrypted,
lastVerifiedAt: now,
},
select: {
system: true,
baseUrl: true,
organizationId: true,
lastVerifiedAt: true,
},
});
await auditLog("documents.sources.connect", {
userId,
ipAddress: getClientIp(request),
details: { system, origin: verdict.origin, replaced: existing !== null },
});
return apiSuccess(toConnectionDto(saved));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const auth = await requireAuth();
// Neither the list nor the module is needed to forget a connection: an
// operator who removed the origin, or a person who switched Documents
// off, must still be able to delete the stored token.
const refused = await admitDocumentSourceCaller(auth, {
needsList: false,
needsModule: false,
});
if (refused) return refused;
const system = await systemParam(params);
if (system instanceof Response) return system;
annotate({
action: { name: "documents.sources.disconnect" },
meta: { system },
});
const { count } = await prisma.documentSourceConnection.deleteMany({
where: { userId: auth.user.id, system },
});
if (count > 0) {
await auditLog("documents.sources.disconnect", {
userId: auth.user.id,
ipAddress: getClientIp(request),
details: { system },
});
}
return apiSuccess({ disconnected: count > 0 });
},
);
@@ -0,0 +1,159 @@
/**
* Document picker (#1038): search one connected document archive.
*
* `q` searches the document's name (the title in Paperless-ngx, the name in
* Papra), `tag` narrows to one of the source's tags by its id, `from` / `to`
* to a date range (inclusive), `page` pages through 25 at a time. The answer
* is a minimal row per document (source id, title, date, tag names, size when
* the source says it) plus what HealthLog already holds under that source key:
* `new`, `imported` (with the vault document's id) or `deleted` (the person
* deleted it here; importing it again is refused). Nothing is stored.
*
* When the source ignores a filter and the local re-check empties a page, the
* route reads on (up to five source pages) and answers with the last page it
* read, so the client's "load more" continues after it.
*
* Sixty searches a minute, shared with the tag read. Cookie-only and
* owner-only; see `admitDocumentSourceCaller`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { findSourceKeyStates } from "@/lib/documents/source-key";
import type { SourceListItem } from "@/lib/documents/sources/clients";
import { openConnection } from "@/lib/documents/sources/connections";
import {
admitDocumentSourceCaller,
checkSearchRateLimit,
responseForSourceFailure,
sourceErrorResponse,
systemParam,
} from "@/lib/documents/sources/route-support";
import {
DOCUMENT_PICKER_MAX_PAGE,
type DocumentSourceResultDto,
type DocumentSourceSearchDto,
} from "@/lib/documents/sources/types";
import { annotate } from "@/lib/logging/context";
import { rateLimitHeaders } from "@/lib/rate-limit";
import { documentSourceSearchSchema } from "@/lib/validations/document-sources";
export const dynamic = "force-dynamic";
/** Source pages one search request may read to find a non-empty one. */
const SEARCH_PAGES_PER_REQUEST = 5;
type RouteParams = { params: Promise<{ system: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const auth = await requireAuth();
const refused = await admitDocumentSourceCaller(auth);
if (refused) return refused;
const system = await systemParam(params);
if (system instanceof Response) return system;
const userId = auth.user.id;
const url = new URL(request.url);
const parsed = documentSourceSearchSchema.safeParse({
q: url.searchParams.get("q") ?? undefined,
tag: url.searchParams.get("tag") ?? undefined,
from: url.searchParams.get("from") ?? undefined,
to: url.searchParams.get("to") ?? undefined,
page: url.searchParams.get("page") ?? undefined,
});
if (!parsed.success) {
return apiValidationError(
"Invalid search",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { q, tag, from, to, page } = parsed.data;
const rl = await checkSearchRateLimit(userId);
if (!rl.allowed) {
return sourceErrorResponse(
"rateLimited",
undefined,
rateLimitHeaders(rl),
);
}
// A page the local tag/date re-check emptied is not an answer: the next
// source page may hold matches. Read on, a bounded number of pages, and
// report the last one read so "load more" continues after it.
let found: { items: SourceListItem[]; hasMore: boolean } = {
items: [],
hasMore: false,
};
let lastPage = page;
let instance: string;
try {
const client = await openConnection(userId, system);
instance = client.instance;
for (
let next = page;
next < page + SEARCH_PAGES_PER_REQUEST &&
next <= DOCUMENT_PICKER_MAX_PAGE;
next += 1
) {
found = await client.search({
q,
tagId: tag ?? null,
from: from ?? null,
to: to ?? null,
page: next,
});
lastPage = next;
if (found.items.length > 0 || !found.hasMore) break;
}
} catch (err) {
return responseForSourceFailure(err);
}
const states = await findSourceKeyStates(
userId,
system,
found.items.map((item) => item.sourceId),
instance,
);
const results: DocumentSourceResultDto[] = found.items.map((item) => {
const held = states.get(item.sourceId);
return {
sourceId: item.sourceId,
title: item.title,
date: item.date,
tags: item.tags,
sizeBytes: item.sizeBytes,
state: held?.state ?? "new",
documentId: held?.state === "imported" ? held.documentId : null,
};
});
// Counts only: the query and the titles are the person's own words and
// stay out of the wide event.
annotate({
action: { name: "documents.sources.search" },
meta: {
system,
page,
pagesRead: lastPage - page + 1,
count: results.length,
filtered: Boolean(q || tag || from || to),
},
});
const body: DocumentSourceSearchDto = {
results,
page: lastPage,
hasMore: found.hasMore && lastPage < DOCUMENT_PICKER_MAX_PAGE,
};
return apiSuccess(body);
},
);
@@ -0,0 +1,52 @@
/**
* Document picker (#1038): the tags of one connected document archive, for the
* picker's tag filter. The first 250 by name, as `{ id, name }` in the
* source's own ids. Shares the search allowance (60 a minute).
*
* Cookie-only and owner-only; see `admitDocumentSourceCaller`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { openConnection } from "@/lib/documents/sources/connections";
import {
admitDocumentSourceCaller,
checkSearchRateLimit,
responseForSourceFailure,
sourceErrorResponse,
systemParam,
} from "@/lib/documents/sources/route-support";
import { annotate } from "@/lib/logging/context";
import { rateLimitHeaders } from "@/lib/rate-limit";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ system: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const auth = await requireAuth();
const refused = await admitDocumentSourceCaller(auth);
if (refused) return refused;
const system = await systemParam(params);
if (system instanceof Response) return system;
annotate({ action: { name: "documents.sources.tags" }, meta: { system } });
const rl = await checkSearchRateLimit(auth.user.id);
if (!rl.allowed) {
return sourceErrorResponse(
"rateLimited",
undefined,
rateLimitHeaders(rl),
);
}
try {
const client = await openConnection(auth.user.id, system);
return apiSuccess({ tags: await client.tags() });
} catch (err) {
return responseForSourceFailure(err);
}
},
);
@@ -0,0 +1,71 @@
/**
* Document picker (#1038): test the saved connection to one document archive.
*
* One request to the source with the stored token, answered `{ ok, latencyMs }`
* or with the reason it failed (`meta.errorCode`, and `meta.upstreamStatus`
* when the source answered at all; its body is never passed on). A success
* stamps `lastVerifiedAt`. Shares the save's allowance of ten a minute.
*
* Cookie-only and owner-only; see `admitDocumentSourceCaller`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import {
markVerified,
openConnection,
} from "@/lib/documents/sources/connections";
import { DocumentSourceError } from "@/lib/documents/sources/http";
import {
admitDocumentSourceCaller,
checkConnectRateLimit,
responseForSourceFailure,
sourceErrorResponse,
systemParam,
} from "@/lib/documents/sources/route-support";
import { annotate } from "@/lib/logging/context";
import { rateLimitHeaders } from "@/lib/rate-limit";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ system: string }> };
export const POST = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const auth = await requireAuth();
const refused = await admitDocumentSourceCaller(auth);
if (refused) return refused;
const system = await systemParam(params);
if (system instanceof Response) return system;
const userId = auth.user.id;
annotate({ action: { name: "documents.sources.test" }, meta: { system } });
const rl = await checkConnectRateLimit(userId);
if (!rl.allowed) {
return sourceErrorResponse(
"rateLimited",
undefined,
rateLimitHeaders(rl),
);
}
const start = performance.now();
try {
const client = await openConnection(userId, system);
await client.test();
} catch (err) {
if (err instanceof DocumentSourceError) {
annotate({ meta: { refused: err.code } });
}
return responseForSourceFailure(err);
}
const latencyMs = Math.round(performance.now() - start);
const verifiedAt = await markVerified(userId, system);
return apiSuccess({
ok: true,
latencyMs,
lastVerifiedAt: verifiedAt.toISOString(),
});
},
);
+61
View File
@@ -0,0 +1,61 @@
/**
* Document picker (#1038): which document archives this person can pick from.
*
* `available` says whether the operator listed any origin in
* `DOCUMENT_SOURCE_ORIGINS`; `allowedOrigins` lists them so the settings card
* can say which addresses will be accepted; `connections` are the person's own
* saved connections, never with their token (`hasToken` is the literal true).
* No request leaves the server here.
*
* Cookie-only and owner-only like every route under this path
* (`admitDocumentSourceCaller`), but it needs neither the documents module nor
* the list: with the list unset it answers 200 `available: false` (plus any
* rows still stored, so they can be deleted) rather than 404, because this is
* the one read the client makes to decide what to show.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import {
listConnections,
toConnectionDto,
} from "@/lib/documents/sources/connections";
import {
documentSourcesAvailable,
listedDocumentSourceOrigins,
} from "@/lib/documents/sources/origins";
import type { DocumentSourcesStatusDto } from "@/lib/documents/sources/types";
import { admitDocumentSourceCaller } from "@/lib/documents/sources/route-support";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const auth = await requireAuth();
const refused = await admitDocumentSourceCaller(auth, {
needsList: false,
needsModule: false,
});
if (refused) return refused;
annotate({ action: { name: "documents.sources.status" } });
// Rows are listed even with the picker off: a connection whose origin the
// operator removed (or whose list is gone) must stay visible so it can be
// deleted. `originAllowed` is false for each of them then.
const rows = await listConnections(auth.user.id);
if (!documentSourcesAvailable()) {
const off: DocumentSourcesStatusDto = {
available: false,
allowedOrigins: [],
connections: rows.map(toConnectionDto),
};
return apiSuccess(off);
}
const status: DocumentSourcesStatusDto = {
available: true,
allowedOrigins: listedDocumentSourceOrigins(),
connections: rows.map(toConnectionDto),
};
return apiSuccess(status);
});