mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
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}
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
FileUp,
|
||||
Key,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Upload,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -55,6 +57,7 @@ export function ApiSection() {
|
||||
<div className="space-y-6">
|
||||
<ApiEndpointsCard />
|
||||
<MeasurementTokensCard />
|
||||
<DocumentTokensCard />
|
||||
<ApiTokensCard />
|
||||
</div>
|
||||
);
|
||||
@@ -70,6 +73,64 @@ export function ApiSection() {
|
||||
* below is where credentials are reviewed and killed.
|
||||
*/
|
||||
function MeasurementTokensCard() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<IngestTokenCard
|
||||
endpoint="/api/tokens/measurements"
|
||||
icon={Upload}
|
||||
slot="settings-measurements-token-created"
|
||||
copy={{
|
||||
title: t("settings.measurementsToken.title"),
|
||||
description: t("settings.measurementsToken.description"),
|
||||
detail: t("settings.measurementsToken.detail"),
|
||||
scopeNote: t("settings.measurementsToken.scopeNote"),
|
||||
createFailed: t("settings.measurementsToken.createFailed"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a token another document system uploads through (#1038): a Paperless
|
||||
* workflow or the import script. Same card, same no-list reasoning as the
|
||||
* measurement one above.
|
||||
*/
|
||||
function DocumentTokensCard() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<IngestTokenCard
|
||||
endpoint="/api/tokens/documents"
|
||||
icon={FileUp}
|
||||
slot="settings-documents-token-created"
|
||||
copy={{
|
||||
title: t("settings.documentsToken.title"),
|
||||
description: t("settings.documentsToken.description"),
|
||||
detail: t("settings.documentsToken.detail"),
|
||||
scopeNote: t("settings.documentsToken.scopeNote"),
|
||||
createFailed: t("settings.documentsToken.createFailed"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** One single-scope mint: a name field, the token shown once, a copy button. */
|
||||
function IngestTokenCard({
|
||||
endpoint,
|
||||
icon,
|
||||
slot,
|
||||
copy,
|
||||
}: {
|
||||
endpoint: "/api/tokens/measurements" | "/api/tokens/documents";
|
||||
icon: LucideIcon;
|
||||
slot: string;
|
||||
copy: {
|
||||
title: string;
|
||||
description: string;
|
||||
detail: string;
|
||||
scopeNote: string;
|
||||
createFailed: string;
|
||||
};
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
@@ -84,7 +145,7 @@ function MeasurementTokensCard() {
|
||||
setTokenMsg(null);
|
||||
setNewToken(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/tokens/measurements", {
|
||||
const res = await apiFetchRaw(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// No scope field: the endpoint mints one shape, so there is nothing
|
||||
@@ -101,7 +162,7 @@ function MeasurementTokensCard() {
|
||||
// The server's own message when it sent one — it names the actual
|
||||
// cause (switch off, rate limit, validation) — and a message about
|
||||
// this specific action when it did not.
|
||||
setTokenMsg(json.error || t("settings.measurementsToken.createFailed"));
|
||||
setTokenMsg(json.error || copy.createFailed);
|
||||
}
|
||||
} catch {
|
||||
setTokenMsg(t("common.networkError"));
|
||||
@@ -125,17 +186,15 @@ function MeasurementTokensCard() {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Upload}
|
||||
title={t("settings.measurementsToken.title")}
|
||||
description={t("settings.measurementsToken.description")}
|
||||
icon={icon}
|
||||
title={copy.title}
|
||||
description={copy.description}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm leading-relaxed">
|
||||
{t("settings.measurementsToken.detail")}
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed">{copy.detail}</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("settings.measurementsToken.scopeNote")}
|
||||
{copy.scopeNote}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-2">
|
||||
@@ -162,7 +221,7 @@ function MeasurementTokensCard() {
|
||||
{newToken && (
|
||||
<div
|
||||
className="bg-success/10 rounded-lg p-3 text-sm"
|
||||
data-slot="settings-measurements-token-created"
|
||||
data-slot={slot}
|
||||
>
|
||||
<p className="text-success mb-1 font-medium">
|
||||
{t("settings.tokenCreated")}
|
||||
@@ -223,6 +282,12 @@ function ApiEndpointsCard() {
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/documents/inbound",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `multipart: file, title, documentDate, sourceSystem, sourceId`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user