feat(documents): add a document upload token for imports from other systems

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
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 21:48:04 +02:00
1 parent af65f01fec
commit 55a32ff7d1
45 files changed
+2261 -96

No files matched your search

@@ -37,6 +37,8 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
lastIndexAttemptAt: null,
lastIndexOutcome: null,
hasThumbnail: false,
sourceSystem: null,
sourceId: null,
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
...overrides,
@@ -89,6 +89,8 @@ function doc(
lastIndexAttemptAt: null,
lastIndexOutcome: null,
hasThumbnail: false,
sourceSystem: null,
sourceId: null,
createdAt: "2026-08-01T10:00:00.000Z",
updatedAt: "2026-08-01T10:00:00.000Z",
facts: [fact()],
@@ -57,6 +57,8 @@ function doc(id: string, title: string): InboundDocumentDto {
lastIndexAttemptAt: null,
lastIndexOutcome: null,
hasThumbnail: false,
sourceSystem: null,
sourceId: null,
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
};
@@ -45,6 +45,8 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
lastIndexAttemptAt: null,
lastIndexOutcome: null,
hasThumbnail: false,
sourceSystem: null,
sourceId: null,
createdAt: "2026-03-11T08:00:00.000Z",
updatedAt: "2026-03-11T08:00:00.000Z",
...overrides,
@@ -73,6 +73,7 @@ import {
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
type DocumentSourceSystemValue,
type DocumentSuggestionDto,
type DocumentSummaryMode,
type InboundDocumentDetailDto,
@@ -698,6 +699,14 @@ export function DocumentDetailSheet({
: null;
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
// The product names are proper nouns and stay untranslated; only "another
// system" is prose.
const sourceSystemName = (system: DocumentSourceSystemValue): string =>
system === "PAPERLESS"
? "Paperless-ngx"
: system === "PAPRA"
? "Papra"
: t("documents.detail.sourceOther");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
// v1.37.0 — the ONE eager fetch of the original, and only for a document
// that is going to render it.
@@ -1167,6 +1176,25 @@ export function DocumentDetailSheet({
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
{/* Where an imported document came from (#1038). Meta, so
muted like the line above; the id is what the person looks
up in the other system. */}
{doc.sourceSystem ? (
<p
className="text-muted-foreground text-xs break-all"
data-slot="document-detail-provenance"
>
{doc.sourceId
? t("documents.detail.importedFromWithId", {
system: sourceSystemName(doc.sourceSystem),
id: doc.sourceId,
})
: t("documents.detail.importedFrom", {
system: sourceSystemName(doc.sourceSystem),
})}
</p>
) : null}
</div>
</div>
) : null}