Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b77a283ab2 | |||
| 1c37936d8c | |||
| 32d9e84985 |
@@ -66,19 +66,12 @@ export class PaperlessProcessorService {
|
|||||||
/**
|
/**
|
||||||
* Verarbeitet ein einzelnes Dokument anhand seiner ID – die ereignisgesteuerte
|
* Verarbeitet ein einzelnes Dokument anhand seiner ID – die ereignisgesteuerte
|
||||||
* Variante des früheren Cron-Jobs (vom Paperless-Webhook aufgerufen). Es wird
|
* 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(
|
async processDocumentById(
|
||||||
documentId: number,
|
documentId: number,
|
||||||
): Promise<{ processed: boolean; reason?: string }> {
|
): Promise<{ processed: boolean; reason?: string }> {
|
||||||
const doc = await this.paperlessService.getDocument(documentId);
|
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 customFields = await this.paperlessService.getCustomFields();
|
||||||
const validFieldIds = new Set<number>(customFields.map((f: any) => f.id));
|
const validFieldIds = new Set<number>(customFields.map((f: any) => f.id));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
|
Get,
|
||||||
Body,
|
Body,
|
||||||
Logger,
|
Logger,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
@@ -8,8 +9,11 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
import { ApiKeyGuard } from '../auth/api-key.guard';
|
||||||
import { PaperlessProcessorService } from '../paperless/paperless-processor.service';
|
import { PaperlessProcessorService } from '../paperless/paperless-processor.service';
|
||||||
|
import { Setting } from '../database/entities/setting.entity';
|
||||||
|
|
||||||
export interface PaperlessWebhookPayload {
|
export interface PaperlessWebhookPayload {
|
||||||
doc_url?: string;
|
doc_url?: string;
|
||||||
@@ -18,11 +22,18 @@ export interface PaperlessWebhookPayload {
|
|||||||
[key: string]: any;
|
[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')
|
@Controller('api/webhook')
|
||||||
export class WebhookController {
|
export class WebhookController {
|
||||||
private readonly logger = new Logger(WebhookController.name);
|
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)
|
@UseGuards(ApiKeyGuard)
|
||||||
@Post('paperless')
|
@Post('paperless')
|
||||||
@@ -33,6 +44,11 @@ export class WebhookController {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Webhook ohne ermittelbare Dokument-ID: ${JSON.stringify(payload)}`,
|
`Webhook ohne ermittelbare Dokument-ID: ${JSON.stringify(payload)}`,
|
||||||
);
|
);
|
||||||
|
await this.recordWebhookCall({
|
||||||
|
documentId: null,
|
||||||
|
action: payload.action,
|
||||||
|
status: 'bad-request',
|
||||||
|
});
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Keine Dokument-ID aus doc_url/document_id ermittelbar',
|
'Keine Dokument-ID aus doc_url/document_id ermittelbar',
|
||||||
);
|
);
|
||||||
@@ -45,10 +61,14 @@ export class WebhookController {
|
|||||||
try {
|
try {
|
||||||
const result =
|
const result =
|
||||||
await this.paperlessProcessor.processDocumentById(documentId);
|
await this.paperlessProcessor.processDocumentById(documentId);
|
||||||
return {
|
const status = result.processed ? 'processed' : 'skipped';
|
||||||
status: result.processed ? 'processed' : 'skipped',
|
await this.recordWebhookCall({
|
||||||
|
documentId,
|
||||||
|
action: payload.action,
|
||||||
|
status,
|
||||||
reason: result.reason,
|
reason: result.reason,
|
||||||
};
|
});
|
||||||
|
return { status, reason: result.reason };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Fehler tolerieren (wie der bisherige Cron) und 200 zurückgeben,
|
// Fehler tolerieren (wie der bisherige Cron) und 200 zurückgeben,
|
||||||
// damit Paperless keine Retry-Schleife startet.
|
// damit Paperless keine Retry-Schleife startet.
|
||||||
@@ -56,10 +76,35 @@ export class WebhookController {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Fehler bei Webhook-Verarbeitung von Dokument ${documentId}: ${message}`,
|
`Fehler bei Webhook-Verarbeitung von Dokument ${documentId}: ${message}`,
|
||||||
);
|
);
|
||||||
|
await this.recordWebhookCall({
|
||||||
|
documentId,
|
||||||
|
action: payload.action,
|
||||||
|
status: 'error',
|
||||||
|
message,
|
||||||
|
});
|
||||||
return { 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
|
* Ermittelt die Dokument-ID aus dem Webhook-Payload. Bevorzugt wird das
|
||||||
* optionale Feld `document_id` (für manuelles Testen), andernfalls wird die
|
* optionale Feld `document_id` (für manuelles Testen), andernfalls wird die
|
||||||
@@ -79,4 +124,47 @@ export class WebhookController {
|
|||||||
}
|
}
|
||||||
return null;
|
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 { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { WebhookController } from './webhook.controller';
|
import { WebhookController } from './webhook.controller';
|
||||||
import { PaperlessModule } from '../paperless/paperless.module';
|
import { PaperlessModule } from '../paperless/paperless.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { Setting } from '../database/entities/setting.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PaperlessModule, AuthModule],
|
imports: [TypeOrmModule.forFeature([Setting]), PaperlessModule, AuthModule],
|
||||||
controllers: [WebhookController],
|
controllers: [WebhookController],
|
||||||
})
|
})
|
||||||
export class WebhookModule {}
|
export class WebhookModule {}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -2,14 +2,14 @@ import { useEffect, useState, useCallback } from 'react';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
Tabs, Typography, Table, Button, Modal, Form, Input, Select,
|
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';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
UserOutlined, FileTextOutlined, ThunderboltOutlined,
|
UserOutlined, FileTextOutlined, ThunderboltOutlined,
|
||||||
PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined,
|
PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined,
|
||||||
HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined,
|
HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined,
|
||||||
QrcodeOutlined, UnorderedListOutlined, PrinterOutlined, GlobalOutlined,
|
QrcodeOutlined, UnorderedListOutlined, PrinterOutlined, GlobalOutlined,
|
||||||
TagsOutlined,
|
TagsOutlined, ApiOutlined, ReloadOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { FormInstance } from 'antd';
|
import type { FormInstance } from 'antd';
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '../api/settings';
|
} from '../api/settings';
|
||||||
import { clientsApi, type Client } from '../api/inbox';
|
import { clientsApi, type Client } from '../api/inbox';
|
||||||
import { apiKeysApi, type ApiKey } from '../api/api-keys';
|
import { apiKeysApi, type ApiKey } from '../api/api-keys';
|
||||||
|
import { webhookApi, type WebhookStatus } from '../api/webhook';
|
||||||
import {
|
import {
|
||||||
barcodeTemplatesApi,
|
barcodeTemplatesApi,
|
||||||
type BarcodeTemplate,
|
type BarcodeTemplate,
|
||||||
@@ -2748,6 +2749,91 @@ function SteuertagsTab() {
|
|||||||
// Settings Page
|
// 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() {
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -2805,6 +2891,11 @@ export default function SettingsPage() {
|
|||||||
label: <span><KeyOutlined /> API-Keys</span>,
|
label: <span><KeyOutlined /> API-Keys</span>,
|
||||||
children: <ApiKeysTab />,
|
children: <ApiKeysTab />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'webhook',
|
||||||
|
label: <span><ApiOutlined /> Webhook</span>,
|
||||||
|
children: <WebhookStatusTab />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'agrarmonitor',
|
key: 'agrarmonitor',
|
||||||
label: <span><GlobalOutlined /> Agrarmonitor</span>,
|
label: <span><GlobalOutlined /> Agrarmonitor</span>,
|
||||||
|
|||||||
Reference in New Issue
Block a user