Files
HealthLog/src/app/api/documents/inbound/source/route.ts
T
Marc-André Bombeck 10a1c3ca2e fix(documents): keep every import key deleted, answer re-sends cheaply
A keyed upload that matched a stored document by its bytes never recorded
its key, so deleting that document let the next import run store it again.
Such keys are now kept as aliases of the document, the source-key check
reads them, and the purge carries them into the ledger with the row's own.

A source key may ride the query string and is then answered before the
body is read or the hourly allowance charged; an upload answered as a
duplicate hands its slot back. GET /api/documents/inbound/source answers
whether a key is held, under the same documents:write scope, so an
importer can skip a download.

aiRead=defer is stored on the row. The summary catch-up, the summary job
and lab auto-staging skip such documents until an explicit Read with AI
or Generate summary clears the marker; backup and restore carry it.

A documents:write caller no longer sees the quota figures on a full vault.

Refs #1038
2026-09-25 22:35:11 +02:00

101 lines
3.3 KiB
TypeScript

/**
* `GET /api/documents/inbound/source?sourceSystem=…&sourceId=…` — does the
* vault already hold this import source key? (#1038)
*
* Lets an importer skip a document before downloading it from the source
* system at all: a nightly re-sync of an archive HealthLog already holds then
* costs one small request per document instead of a download and an upload.
*
* It answers only what the upload itself would answer for the same key —
* `known`, the document `id`, and whether the owner `deleted` it — so a
* `documents:write` token learns nothing here it could not learn by uploading.
* That token is the reason the route exists, and it is the second of the two
* routes the scope reaches (`bearer-scope-enforcement-guard.test.ts`).
*/
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
import { findSourceKey } from "@/lib/documents/source-key";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { documentSourceKeySchema } from "@/lib/validations/inbound-documents";
export const dynamic = "force-dynamic";
/**
* Lookups per caller per hour. Generous on purpose: an importer asks once per
* document in the source, so a re-sync of a few thousand documents has to fit
* in an hour. A lookup reads three indexed rows and stores nothing.
*/
const LOOKUP_LIMIT_PER_HOUR = 5000;
const LOOKUP_WINDOW_MS = 60 * 60 * 1000;
export const GET = apiHandler(async (request: Request) => {
const auth = await requireAuth(DOCUMENTS_WRITE_SCOPE);
const { user } = auth;
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
isScopedCredential(auth)
? `documents-source:token:${auth.session.id}`
: `documents-source:${user.id}`,
LOOKUP_LIMIT_PER_HOUR,
LOOKUP_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many lookups. 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 url = new URL(request.url);
const parsed = documentSourceKeySchema.safeParse({
sourceSystem: url.searchParams.get("sourceSystem") ?? undefined,
sourceId: url.searchParams.get("sourceId") ?? undefined,
});
if (!parsed.success) {
return apiValidationError(
"Invalid source key",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "documents.inbound.invalidMetadata" },
);
}
const match = await findSourceKey(
user.id,
parsed.data.sourceSystem,
parsed.data.sourceId,
);
annotate({
action: { name: "documents.vault.sourceLookup" },
meta: {
sourceSystem: parsed.data.sourceSystem,
known: match !== null,
deleted: match?.state === "deleted",
},
});
return apiSuccess({
known: match !== null,
id:
match === null
? null
: match.state === "live"
? match.document.id
: match.id,
deleted: match?.state === "deleted",
});
});