Automate Paperless webhook setup
This commit is contained in:
@@ -53,6 +53,7 @@ export interface SettingsResponse {
|
||||
aiSystemPrompt: string | null;
|
||||
aiTimeoutSeconds: number;
|
||||
aiMaxTokens: number;
|
||||
paperlessWorkflowId: number | null;
|
||||
};
|
||||
secrets: {
|
||||
paperlessTokenSet: boolean;
|
||||
@@ -121,3 +122,12 @@ export async function triggerNtfyTest(): Promise<void> {
|
||||
export async function triggerAiTest(): Promise<void> {
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -199,6 +199,12 @@
|
||||
"webhookSecretRemove": "Webhook-Secret löschen",
|
||||
"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.",
|
||||
"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",
|
||||
"icalFeedUrl": "Feed-URL",
|
||||
"paperlessApiUrl": "Paperless API URL",
|
||||
|
||||
@@ -199,6 +199,12 @@
|
||||
"webhookSecretRemove": "Remove webhook secret",
|
||||
"webhookSecretInfo": "A webhook secret is stored. Leave empty to keep it.",
|
||||
"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",
|
||||
"icalFeedUrl": "Feed URL",
|
||||
"paperlessApiUrl": "Paperless API URL",
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
fetchServerConfig,
|
||||
fetchSettings,
|
||||
resetIcalSecret,
|
||||
setupPaperlessWebhook,
|
||||
triggerAiTest,
|
||||
triggerMailTest,
|
||||
triggerNtfyTest,
|
||||
@@ -263,6 +264,15 @@ export default function SettingsPage() {
|
||||
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({
|
||||
mutationFn: (name: string) => apiCreateCategory(name),
|
||||
onSuccess: async (category) => {
|
||||
@@ -858,6 +868,26 @@ export default function SettingsPage() {
|
||||
{t("settings.webhookSecretInfo")}
|
||||
</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}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
48
src/index.ts
48
src/index.ts
@@ -39,6 +39,7 @@ import {
|
||||
regenerateIcalSecret,
|
||||
updateRuntimeSettings
|
||||
} from "./runtimeSettings.js";
|
||||
import { generateSecret } from "./settingsStore.js";
|
||||
import type { RuntimeSettings } from "./runtimeSettings.js";
|
||||
import type { UpcomingDeadline } from "./types.js";
|
||||
import { ContractPayload } from "./types.js";
|
||||
@@ -148,7 +149,8 @@ function formatSettingsResponse(runtime: RuntimeSettings) {
|
||||
aiModel: runtime.aiModel,
|
||||
aiSystemPrompt: runtime.aiSystemPrompt,
|
||||
aiTimeoutSeconds: runtime.aiTimeoutSeconds,
|
||||
aiMaxTokens: runtime.aiMaxTokens
|
||||
aiMaxTokens: runtime.aiMaxTokens,
|
||||
paperlessWorkflowId: runtime.paperlessWorkflowId
|
||||
},
|
||||
secrets: {
|
||||
paperlessTokenSet: Boolean(runtime.paperlessToken),
|
||||
@@ -245,6 +247,22 @@ function extractWebhookDocumentId(body: unknown): number | null {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -516,6 +534,34 @@ app.post("/settings/ical-secret/reset", (_req, res) => {
|
||||
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) => {
|
||||
try {
|
||||
const runtime = getRuntimeSettings();
|
||||
|
||||
@@ -11,6 +11,15 @@ interface PaperlessCollectionResponse<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 {
|
||||
get isConfigured() {
|
||||
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> {
|
||||
if (!documents.length) return;
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface RuntimeSettings {
|
||||
aiTimeoutSeconds: number;
|
||||
aiMaxTokens: number;
|
||||
paperlessWebhookSecret: string | null;
|
||||
paperlessWorkflowId: number | null;
|
||||
}
|
||||
|
||||
const numericKeys = new Set<SettingKey>([
|
||||
@@ -47,7 +48,8 @@ const numericKeys = new Set<SettingKey>([
|
||||
"alertDaysBefore",
|
||||
"mailPort",
|
||||
"aiTimeoutSeconds",
|
||||
"aiMaxTokens"
|
||||
"aiMaxTokens",
|
||||
"paperlessWorkflowId"
|
||||
]);
|
||||
const booleanKeys = new Set<SettingKey>(["mailUseTls", "aiEnabled"]);
|
||||
|
||||
@@ -154,7 +156,10 @@ export function getRuntimeSettings(): RuntimeSettings {
|
||||
aiSystemPrompt: coerceString(stored.aiSystemPrompt, config.aiSystemPrompt ?? null),
|
||||
aiTimeoutSeconds,
|
||||
aiMaxTokens,
|
||||
paperlessWebhookSecret: coerceString(stored.paperlessWebhookSecret, config.paperlessWebhookSecret ?? null)
|
||||
paperlessWebhookSecret: coerceString(stored.paperlessWebhookSecret, config.paperlessWebhookSecret ?? null),
|
||||
paperlessWorkflowId: stored.paperlessWorkflowId !== undefined
|
||||
? coerceNumber(stored.paperlessWorkflowId, 0)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ export type SettingKey =
|
||||
| "aiSystemPrompt"
|
||||
| "aiTimeoutSeconds"
|
||||
| "aiMaxTokens"
|
||||
| "paperlessWebhookSecret";
|
||||
| "paperlessWebhookSecret"
|
||||
| "paperlessWorkflowId";
|
||||
|
||||
export type StoredSettings = Partial<Record<SettingKey, unknown>>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user