Automate Paperless webhook setup
This commit is contained in:
@@ -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" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
48
src/index.ts
48
src/index.ts
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user