Compare commits

..

3 Commits

Author SHA1 Message Date
bjoernpoettker b77a283ab2 feat: Webhook verarbeitet jedes Dokument ohne Tag-16-Vorprüfung
Build and Push Multi-Platform Images / build-and-push (push) Successful in 38s
processDocumentById verarbeitet nun jedes vom Paperless-Webhook gemeldete
Dokument, unabhängig vom Tag "paperlessmanager" (ID 16). Die Tag-Vorprüfung
(skipped/tag-missing) entfällt. Die Konstante PAPERLESSMANAGER_TAG_ID bleibt
für den manuellen Batch-Lauf processDocuments erhalten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:58:34 +02:00
bjoernpoettker 1c37936d8c feat(frontend): Webhook-Status-Tab in den Einstellungen
Neuer Tab "Webhook" in der SettingsPage zeigt den letzten Paperless-
Webhook-Aufruf (Zeitpunkt, Dokument-ID, Aktion, Ergebnis) über
GET /api/webhook/status an. Neue API-Datei src/api/webhook.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:01:27 +02:00
bjoernpoettker 32d9e84985 feat: letzten Paperless-Webhook-Aufruf in Settings festhalten
Pro Webhook-Aufruf wird ein Status in der settings-Tabelle gepflegt
(Tag "last_webhook_call", JSON mit Zeitpunkt, Dokument, action, Ergebnis).
Neuer Endpunkt GET /api/webhook/status liefert den letzten Aufruf zum
Auslesen (JWT oder API-Key). Persistent über Neustarts, anders als die
Container-Logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:57:36 +02:00
5 changed files with 208 additions and 15 deletions
@@ -66,19 +66,12 @@ export class PaperlessProcessorService {
/**
* Verarbeitet ein einzelnes Dokument anhand seiner ID die ereignisgesteuerte
* Variante des früheren Cron-Jobs (vom Paperless-Webhook aufgerufen). Es wird
* nur verarbeitet, wenn das Dokument den Tag "paperlessmanager" trägt.
* jedes gemeldete Dokument verarbeitet, unabhängig vom Tag "paperlessmanager".
*/
async processDocumentById(
documentId: number,
): Promise<{ processed: boolean; reason?: string }> {
const doc = await this.paperlessService.getDocument(documentId);
const tags: number[] = doc.tags || [];
if (!tags.includes(PAPERLESSMANAGER_TAG_ID)) {
this.logger.log(
`Dokument ${documentId} ohne Tag "paperlessmanager" (ID ${PAPERLESSMANAGER_TAG_ID}) übersprungen.`,
);
return { processed: false, reason: 'tag-missing' };
}
const customFields = await this.paperlessService.getCustomFields();
const validFieldIds = new Set<number>(customFields.map((f: any) => f.id));
@@ -1,6 +1,7 @@
import {
Controller,
Post,
Get,
Body,
Logger,
HttpCode,
@@ -8,8 +9,11 @@ import {
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApiKeyGuard } from '../auth/api-key.guard';
import { PaperlessProcessorService } from '../paperless/paperless-processor.service';
import { Setting } from '../database/entities/setting.entity';
export interface PaperlessWebhookPayload {
doc_url?: string;
@@ -18,11 +22,18 @@ export interface PaperlessWebhookPayload {
[key: string]: any;
}
// Tag (Schlüssel) des Settings-Eintrags, der den letzten Webhook-Aufruf festhält.
const LAST_WEBHOOK_CALL_TAG = 'last_webhook_call';
@Controller('api/webhook')
export class WebhookController {
private readonly logger = new Logger(WebhookController.name);
constructor(private readonly paperlessProcessor: PaperlessProcessorService) {}
constructor(
private readonly paperlessProcessor: PaperlessProcessorService,
@InjectRepository(Setting)
private readonly settingRepo: Repository<Setting>,
) {}
@UseGuards(ApiKeyGuard)
@Post('paperless')
@@ -33,6 +44,11 @@ export class WebhookController {
this.logger.warn(
`Webhook ohne ermittelbare Dokument-ID: ${JSON.stringify(payload)}`,
);
await this.recordWebhookCall({
documentId: null,
action: payload.action,
status: 'bad-request',
});
throw new BadRequestException(
'Keine Dokument-ID aus doc_url/document_id ermittelbar',
);
@@ -45,10 +61,14 @@ export class WebhookController {
try {
const result =
await this.paperlessProcessor.processDocumentById(documentId);
return {
status: result.processed ? 'processed' : 'skipped',
const status = result.processed ? 'processed' : 'skipped';
await this.recordWebhookCall({
documentId,
action: payload.action,
status,
reason: result.reason,
};
});
return { status, reason: result.reason };
} catch (err) {
// Fehler tolerieren (wie der bisherige Cron) und 200 zurückgeben,
// damit Paperless keine Retry-Schleife startet.
@@ -56,10 +76,35 @@ export class WebhookController {
this.logger.error(
`Fehler bei Webhook-Verarbeitung von Dokument ${documentId}: ${message}`,
);
await this.recordWebhookCall({
documentId,
action: payload.action,
status: 'error',
message,
});
return { status: 'error', message };
}
}
/**
* Liefert Informationen zum letzten Webhook-Aufruf (Zeitpunkt, Dokument,
* Ergebnis). Über die globalen Guards per JWT oder API-Key zugänglich.
*/
@Get('status')
async getWebhookStatus(): Promise<{ lastCall: unknown }> {
const setting = await this.settingRepo.findOneBy({
Tag: LAST_WEBHOOK_CALL_TAG,
});
if (!setting?.Wert) {
return { lastCall: null };
}
try {
return { lastCall: JSON.parse(setting.Wert) };
} catch {
return { lastCall: { raw: setting.Wert } };
}
}
/**
* Ermittelt die Dokument-ID aus dem Webhook-Payload. Bevorzugt wird das
* optionale Feld `document_id` (für manuelles Testen), andernfalls wird die
@@ -79,4 +124,47 @@ export class WebhookController {
}
return null;
}
/**
* Hält den letzten Webhook-Aufruf in der Settings-Tabelle fest
* (Tag "last_webhook_call"). Fehler beim Speichern werden nur geloggt und
* beeinflussen die Webhook-Antwort nicht.
*/
private async recordWebhookCall(info: {
documentId: number | null;
action?: string;
status: string;
reason?: string;
message?: string;
}): Promise<void> {
try {
const value = JSON.stringify({
at: new Date().toISOString(),
documentId: info.documentId,
action: info.action ?? null,
status: info.status,
...(info.reason ? { reason: info.reason } : {}),
...(info.message ? { message: info.message.slice(0, 100) } : {}),
});
let setting = await this.settingRepo.findOneBy({
Tag: LAST_WEBHOOK_CALL_TAG,
});
if (!setting) {
setting = this.settingRepo.create({
Typ: 0,
Tag: LAST_WEBHOOK_CALL_TAG,
Wert: value,
});
} else {
setting.Wert = value;
}
await this.settingRepo.save(setting);
} catch (err) {
this.logger.error(
'Konnte letzten Webhook-Aufruf nicht in den Settings speichern',
err instanceof Error ? err.stack : String(err),
);
}
}
}
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WebhookController } from './webhook.controller';
import { PaperlessModule } from '../paperless/paperless.module';
import { AuthModule } from '../auth/auth.module';
import { Setting } from '../database/entities/setting.entity';
@Module({
imports: [PaperlessModule, AuthModule],
imports: [TypeOrmModule.forFeature([Setting]), PaperlessModule, AuthModule],
controllers: [WebhookController],
})
export class WebhookModule {}
+19
View File
@@ -0,0 +1,19 @@
import api from './client';
export interface WebhookLastCall {
at: string; // ISO-Zeitstempel
documentId: number | null;
action: string | null;
status: 'processed' | 'skipped' | 'error' | 'bad-request' | string;
reason?: string;
message?: string;
}
export interface WebhookStatus {
lastCall: WebhookLastCall | null;
}
export const webhookApi = {
getStatus: () =>
api.get<WebhookStatus>('/api/webhook/status').then((r) => r.data),
};
+93 -2
View File
@@ -2,14 +2,14 @@ import { useEffect, useState, useCallback } from 'react';
import dayjs from 'dayjs';
import {
Tabs, Typography, Table, Button, Modal, Form, Input, Select,
Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge, Row, Col, Radio, Alert,
Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge, Row, Col, Radio, Alert, Descriptions,
} from 'antd';
import {
UserOutlined, FileTextOutlined, ThunderboltOutlined,
PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined,
HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined,
QrcodeOutlined, UnorderedListOutlined, PrinterOutlined, GlobalOutlined,
TagsOutlined,
TagsOutlined, ApiOutlined, ReloadOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd';
@@ -26,6 +26,7 @@ import {
} from '../api/settings';
import { clientsApi, type Client } from '../api/inbox';
import { apiKeysApi, type ApiKey } from '../api/api-keys';
import { webhookApi, type WebhookStatus } from '../api/webhook';
import {
barcodeTemplatesApi,
type BarcodeTemplate,
@@ -2748,6 +2749,91 @@ function SteuertagsTab() {
// Settings Page
// ═══════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════
// Webhook-Status Tab
// ═══════════════════════════════════════════════════════════════════
function WebhookStatusTab() {
const [status, setStatus] = useState<WebhookStatus | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await webhookApi.getStatus();
setStatus(data);
} catch (err) {
console.error('Error loading webhook status:', err);
message.error('Webhook-Status konnte nicht geladen werden');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const renderStatusBadge = (s: string) => {
switch (s) {
case 'processed': return <Badge status="success" text="Verarbeitet" />;
case 'skipped': return <Badge status="warning" text="Übersprungen" />;
case 'error': return <Badge status="error" text="Fehler" />;
case 'bad-request': return <Badge status="error" text="Ungültige Anfrage" />;
default: return <Badge status="default" text={s} />;
}
};
const lastCall = status?.lastCall ?? null;
return (
<>
<div style={{ marginBottom: 16 }}>
<Title level={4}>Webhook-Status</Title>
<Typography.Paragraph type="secondary">
Paperless-NGX ruft nach dem Bearbeiten eines Dokuments den Webhook{' '}
<Typography.Text code>/api/webhook/paperless</Typography.Text> auf
(per API-Key authentifiziert). Hier siehst du den zuletzt
verarbeiteten Aufruf. Eine vollständige Historie steht in den
Backend-Logs.
</Typography.Paragraph>
<Button icon={<ReloadOutlined />} loading={loading} onClick={load}>
Aktualisieren
</Button>
</div>
<Card size="small" title="Letzter Webhook-Aufruf" loading={loading}>
{lastCall ? (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="Zeitpunkt">
{dayjs(lastCall.at).format('DD.MM.YYYY HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label="Dokument-ID">
{lastCall.documentId ?? '—'}
</Descriptions.Item>
<Descriptions.Item label="Aktion">
{lastCall.action ? <Tag>{lastCall.action}</Tag> : '—'}
</Descriptions.Item>
<Descriptions.Item label="Ergebnis">
{renderStatusBadge(lastCall.status)}
{lastCall.reason ? (
<Typography.Text type="secondary"> ({lastCall.reason})</Typography.Text>
) : null}
</Descriptions.Item>
{lastCall.message ? (
<Descriptions.Item label="Meldung">
<Typography.Text type="danger">{lastCall.message}</Typography.Text>
</Descriptions.Item>
) : null}
</Descriptions>
) : (
<Typography.Text type="secondary">
Noch kein Webhook-Aufruf erfolgt.
</Typography.Text>
)}
</Card>
</>
);
}
export default function SettingsPage() {
return (
<div>
@@ -2805,6 +2891,11 @@ export default function SettingsPage() {
label: <span><KeyOutlined /> API-Keys</span>,
children: <ApiKeysTab />,
},
{
key: 'webhook',
label: <span><ApiOutlined /> Webhook</span>,
children: <WebhookStatusTab />,
},
{
key: 'agrarmonitor',
label: <span><GlobalOutlined /> Agrarmonitor</span>,