Automate Paperless webhook setup

This commit is contained in:
2026-05-07 23:57:42 +02:00
parent f913bc0ba6
commit 58e737d5cd
8 changed files with 184 additions and 4 deletions

View File

@@ -53,6 +53,7 @@ export interface SettingsResponse {
aiSystemPrompt: string | null; aiSystemPrompt: string | null;
aiTimeoutSeconds: number; aiTimeoutSeconds: number;
aiMaxTokens: number; aiMaxTokens: number;
paperlessWorkflowId: number | null;
}; };
secrets: { secrets: {
paperlessTokenSet: boolean; paperlessTokenSet: boolean;
@@ -121,3 +122,12 @@ export async function triggerNtfyTest(): Promise<void> {
export async function triggerAiTest(): Promise<void> { export async function triggerAiTest(): Promise<void> {
await request("/settings/test/ai", { method: "POST" }); await request("/settings/test/ai", { method: "POST" });
} }
export async function setupPaperlessWebhook(): Promise<{
status: string;
workflowId: number | null;
webhookUrl: string;
settings: SettingsResponse;
}> {
return request("/settings/paperless-webhook/setup", { method: "POST" });
}

View File

@@ -199,6 +199,12 @@
"webhookSecretRemove": "Webhook-Secret löschen", "webhookSecretRemove": "Webhook-Secret löschen",
"webhookSecretInfo": "Ein Webhook-Secret ist hinterlegt. Lasse das Feld leer, um es unverändert zu lassen.", "webhookSecretInfo": "Ein Webhook-Secret ist hinterlegt. Lasse das Feld leer, um es unverändert zu lassen.",
"webhookSecretHelp": "Paperless sendet diesen Wert im Header x-contract-companion-secret.", "webhookSecretHelp": "Paperless sendet diesen Wert im Header x-contract-companion-secret.",
"webhookSetup": "Paperless automatisch konfigurieren",
"webhookSetupRunning": "Konfiguriere Paperless…",
"webhookSetupSuccess": "Paperless-Workflow konfiguriert (ID {{id}})",
"webhookSetupError": "Paperless-Workflow konnte nicht konfiguriert werden",
"webhookSetupHelp": "Legt ein Secret an und erstellt oder aktualisiert in Paperless den Workflow \"Contract Companion AI Review\".",
"webhookWorkflowInfo": "Paperless-Workflow ID {{id}} ist verknüpft.",
"ical": "iCal-Abo", "ical": "iCal-Abo",
"icalFeedUrl": "Feed-URL", "icalFeedUrl": "Feed-URL",
"paperlessApiUrl": "Paperless API URL", "paperlessApiUrl": "Paperless API URL",

View File

@@ -199,6 +199,12 @@
"webhookSecretRemove": "Remove webhook secret", "webhookSecretRemove": "Remove webhook secret",
"webhookSecretInfo": "A webhook secret is stored. Leave empty to keep it.", "webhookSecretInfo": "A webhook secret is stored. Leave empty to keep it.",
"webhookSecretHelp": "Paperless sends this value in the x-contract-companion-secret header.", "webhookSecretHelp": "Paperless sends this value in the x-contract-companion-secret header.",
"webhookSetup": "Configure Paperless automatically",
"webhookSetupRunning": "Configuring Paperless…",
"webhookSetupSuccess": "Paperless workflow configured (ID {{id}})",
"webhookSetupError": "Unable to configure Paperless workflow",
"webhookSetupHelp": "Creates a secret and creates or updates the Paperless workflow \"Contract Companion AI Review\".",
"webhookWorkflowInfo": "Paperless workflow ID {{id}} is linked.",
"ical": "iCal subscription", "ical": "iCal subscription",
"icalFeedUrl": "Feed URL", "icalFeedUrl": "Feed URL",
"paperlessApiUrl": "Paperless API URL", "paperlessApiUrl": "Paperless API URL",

View File

@@ -35,6 +35,7 @@ import {
fetchServerConfig, fetchServerConfig,
fetchSettings, fetchSettings,
resetIcalSecret, resetIcalSecret,
setupPaperlessWebhook,
triggerAiTest, triggerAiTest,
triggerMailTest, triggerMailTest,
triggerNtfyTest, triggerNtfyTest,
@@ -263,6 +264,15 @@ export default function SettingsPage() {
onError: (error: Error) => showMessage(error.message ?? t("settings.aiTestError"), "error") onError: (error: Error) => showMessage(error.message ?? t("settings.aiTestError"), "error")
}); });
const paperlessWebhookSetupMutation = useMutation({
mutationFn: () => setupPaperlessWebhook(),
onSuccess: async (result) => {
showMessage(t("settings.webhookSetupSuccess", { id: result.workflowId ?? "-" }), "success");
await Promise.all([refetchSettings(), refetchServerConfig()]);
},
onError: (error: Error) => showMessage(error.message ?? t("settings.webhookSetupError"), "error")
});
const createCategoryMutation = useMutation({ const createCategoryMutation = useMutation({
mutationFn: (name: string) => apiCreateCategory(name), mutationFn: (name: string) => apiCreateCategory(name),
onSuccess: async (category) => { onSuccess: async (category) => {
@@ -858,6 +868,26 @@ export default function SettingsPage() {
{t("settings.webhookSecretInfo")} {t("settings.webhookSecretInfo")}
</Typography> </Typography>
)} )}
{settingsData?.values.paperlessWorkflowId && (
<Typography variant="caption" color="text.secondary">
{t("settings.webhookWorkflowInfo", { id: settingsData.values.paperlessWorkflowId })}
</Typography>
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button
variant="contained"
onClick={() => paperlessWebhookSetupMutation.mutate()}
disabled={paperlessWebhookSetupMutation.isPending || !serverConfig?.paperlessConfigured}
>
{paperlessWebhookSetupMutation.isPending
? t("settings.webhookSetupRunning")
: t("settings.webhookSetup")}
</Button>
{paperlessWebhookSetupMutation.isPending && <CircularProgress size={24} />}
</Stack>
<Typography variant="caption" color="text.secondary">
{t("settings.webhookSetupHelp")}
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}> <Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button <Button
variant="outlined" variant="outlined"

View File

@@ -39,6 +39,7 @@ import {
regenerateIcalSecret, regenerateIcalSecret,
updateRuntimeSettings updateRuntimeSettings
} from "./runtimeSettings.js"; } from "./runtimeSettings.js";
import { generateSecret } from "./settingsStore.js";
import type { RuntimeSettings } from "./runtimeSettings.js"; import type { RuntimeSettings } from "./runtimeSettings.js";
import type { UpcomingDeadline } from "./types.js"; import type { UpcomingDeadline } from "./types.js";
import { ContractPayload } from "./types.js"; import { ContractPayload } from "./types.js";
@@ -148,7 +149,8 @@ function formatSettingsResponse(runtime: RuntimeSettings) {
aiModel: runtime.aiModel, aiModel: runtime.aiModel,
aiSystemPrompt: runtime.aiSystemPrompt, aiSystemPrompt: runtime.aiSystemPrompt,
aiTimeoutSeconds: runtime.aiTimeoutSeconds, aiTimeoutSeconds: runtime.aiTimeoutSeconds,
aiMaxTokens: runtime.aiMaxTokens aiMaxTokens: runtime.aiMaxTokens,
paperlessWorkflowId: runtime.paperlessWorkflowId
}, },
secrets: { secrets: {
paperlessTokenSet: Boolean(runtime.paperlessToken), paperlessTokenSet: Boolean(runtime.paperlessToken),
@@ -245,6 +247,22 @@ function extractWebhookDocumentId(body: unknown): number | null {
return value; return value;
} }
} }
const urlCandidate =
typeof payload.doc_url === "string"
? payload.doc_url
: typeof payload.document_url === "string"
? payload.document_url
: null;
if (urlCandidate) {
const match = urlCandidate.match(/\/documents\/(\d+)/);
if (match) {
const value = Number(match[1]);
if (Number.isInteger(value) && value > 0) {
return value;
}
}
}
return null; return null;
} }
@@ -516,6 +534,34 @@ app.post("/settings/ical-secret/reset", (_req, res) => {
res.json({ icalSecret: runtime.icalSecret }); res.json({ icalSecret: runtime.icalSecret });
}); });
app.post("/settings/paperless-webhook/setup", async (req, res, next) => {
try {
const runtime = getRuntimeSettings();
if (!runtime.paperlessBaseUrl || !runtime.paperlessToken) {
return res.status(400).json({ error: "Paperless integration is not configured" });
}
const secret = runtime.paperlessWebhookSecret ?? generateSecret(32);
const appUrl = resolveAppBaseUrl(req, runtime);
const webhookUrl = `${appUrl}/api/integrations/paperless/webhook`;
const workflow = await paperlessClient.upsertContractCompanionWorkflow(webhookUrl, secret);
const workflowId = typeof workflow.id === "number" ? workflow.id : null;
const updated = updateRuntimeSettings({
paperlessWebhookSecret: secret,
paperlessWorkflowId: workflowId
});
res.json({
status: "ok",
workflowId,
webhookUrl,
settings: formatSettingsResponse(updated)
});
} catch (error) {
next(error);
}
});
app.post("/settings/test/mail", async (_req, res, next) => { app.post("/settings/test/mail", async (_req, res, next) => {
try { try {
const runtime = getRuntimeSettings(); const runtime = getRuntimeSettings();

View File

@@ -11,6 +11,15 @@ interface PaperlessCollectionResponse<T> {
results?: T[]; results?: T[];
} }
type PaperlessWorkflow = Record<string, unknown> & {
id?: number;
name?: string;
order?: number;
enabled?: boolean;
};
const CONTRACT_COMPANION_WORKFLOW_NAME = "Contract Companion AI Review";
export class PaperlessClient { export class PaperlessClient {
get isConfigured() { get isConfigured() {
const { paperlessBaseUrl, paperlessToken } = getRuntimeSettings(); const { paperlessBaseUrl, paperlessToken } = getRuntimeSettings();
@@ -225,6 +234,73 @@ export class PaperlessClient {
}); });
} }
async upsertContractCompanionWorkflow(webhookUrl: string, secret: string): Promise<PaperlessWorkflow> {
if (!this.isConfigured) {
throw new Error("Paperless integration is not configured");
}
const workflowsUrl = new URL(this.buildUrl("/api/workflows/"));
workflowsUrl.searchParams.set("page_size", "100");
const payload = await this.fetchJson<PaperlessCollectionResponse<PaperlessWorkflow>>(workflowsUrl);
const existing = (payload.results ?? []).find(
(workflow) => workflow.name === CONTRACT_COMPANION_WORKFLOW_NAME
);
const workflowPayload = {
name: CONTRACT_COMPANION_WORKFLOW_NAME,
order: typeof existing?.order === "number" ? existing.order : 1,
enabled: true,
triggers: [
{
type: 2,
sources: [],
matching_algorithm: 0,
match: "",
is_insensitive: true,
filter_has_tags: [],
filter_has_all_tags: [],
filter_has_not_tags: [],
filter_has_not_correspondents: [],
filter_has_not_document_types: [],
filter_has_not_storage_paths: [],
schedule_offset_days: 0,
schedule_is_recurring: false,
schedule_recurring_interval_days: 1,
schedule_date_field: "added"
}
],
actions: [
{
type: 4,
webhook: {
url: webhookUrl,
use_params: true,
as_json: true,
params: {
doc_url: "{{doc_url}}",
title: "{{doc_title}}"
},
body: "",
headers: {
"x-contract-companion-secret": secret
},
include_document: false
}
}
]
};
const url = existing?.id
? new URL(this.buildUrl(`/api/workflows/${existing.id}/`))
: workflowsUrl;
const method = existing?.id ? "PUT" : "POST";
return this.fetchJson<PaperlessWorkflow>(url, {
method,
headers: this.getHeaders(true),
body: JSON.stringify(workflowPayload)
});
}
async enrichDocuments(documents: PaperlessDocument[]): Promise<void> { async enrichDocuments(documents: PaperlessDocument[]): Promise<void> {
if (!documents.length) return; if (!documents.length) return;

View File

@@ -40,6 +40,7 @@ export interface RuntimeSettings {
aiTimeoutSeconds: number; aiTimeoutSeconds: number;
aiMaxTokens: number; aiMaxTokens: number;
paperlessWebhookSecret: string | null; paperlessWebhookSecret: string | null;
paperlessWorkflowId: number | null;
} }
const numericKeys = new Set<SettingKey>([ const numericKeys = new Set<SettingKey>([
@@ -47,7 +48,8 @@ const numericKeys = new Set<SettingKey>([
"alertDaysBefore", "alertDaysBefore",
"mailPort", "mailPort",
"aiTimeoutSeconds", "aiTimeoutSeconds",
"aiMaxTokens" "aiMaxTokens",
"paperlessWorkflowId"
]); ]);
const booleanKeys = new Set<SettingKey>(["mailUseTls", "aiEnabled"]); const booleanKeys = new Set<SettingKey>(["mailUseTls", "aiEnabled"]);
@@ -154,7 +156,10 @@ export function getRuntimeSettings(): RuntimeSettings {
aiSystemPrompt: coerceString(stored.aiSystemPrompt, config.aiSystemPrompt ?? null), aiSystemPrompt: coerceString(stored.aiSystemPrompt, config.aiSystemPrompt ?? null),
aiTimeoutSeconds, aiTimeoutSeconds,
aiMaxTokens, aiMaxTokens,
paperlessWebhookSecret: coerceString(stored.paperlessWebhookSecret, config.paperlessWebhookSecret ?? null) paperlessWebhookSecret: coerceString(stored.paperlessWebhookSecret, config.paperlessWebhookSecret ?? null),
paperlessWorkflowId: stored.paperlessWorkflowId !== undefined
? coerceNumber(stored.paperlessWorkflowId, 0)
: null
}; };
} }

View File

@@ -37,7 +37,8 @@ export type SettingKey =
| "aiSystemPrompt" | "aiSystemPrompt"
| "aiTimeoutSeconds" | "aiTimeoutSeconds"
| "aiMaxTokens" | "aiMaxTokens"
| "paperlessWebhookSecret"; | "paperlessWebhookSecret"
| "paperlessWorkflowId";
export type StoredSettings = Partial<Record<SettingKey, unknown>>; export type StoredSettings = Partial<Record<SettingKey, unknown>>;