feat: Paperless-Webhook ersetzt Tag-16-Cron-Job durch Echtzeit-Verarbeitung
Build and Push Multi-Platform Images / build-and-push (push) Successful in 42s

Statt stündlichem Cron ruft Paperless-NGX nun per Webhook das Backend auf,
sobald ein Dokument bearbeitet wurde. Der Endpunkt verarbeitet ein einzelnes
Dokument (per doc_url/document_id), prüft weiterhin den Tag "paperlessmanager"
(ID 16) und ist per API-Key (X-API-Key) abgesichert, sodass nur Paperless ihn
aufrufen kann.

- paperless-processor.service.ts: @Cron entfernt; neue Methode
  processDocumentById; Tag-16 als Konstante; gemeinsamer Helper
  processAndEvaluate (Batch + Webhook)
- paperless.module.ts: PaperlessProcessorService exportiert
- webhook.controller.ts: Route auf api/webhook (erreichbar via /api-Proxy);
  @Public() -> @UseGuards(ApiKeyGuard); ID-Extraktion aus {{doc_url}}
- webhook.module.ts: PaperlessModule + AuthModule importiert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 15:20:19 +02:00
parent 4d05a94681
commit 56596d4482
4 changed files with 110 additions and 24 deletions
@@ -1,5 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@@ -8,6 +7,8 @@ import { DocumentType } from '../database/entities/document-type.entity';
import { PaperlessService } from './paperless.service';
import { PostprocessingService } from '../postprocessing/postprocessing.service';
const PAPERLESSMANAGER_TAG_ID = 16; // Tag "paperlessmanager"
@Injectable()
export class PaperlessProcessorService {
private readonly logger = new Logger(PaperlessProcessorService.name);
@@ -22,11 +23,13 @@ export class PaperlessProcessorService {
private readonly docFieldRepo: Repository<DocumentField>,
) {}
@Cron(process.env.PAPERLESS_PROCESSOR_CRON || '0 * * * * *')
// Manueller Batch-Lauf ("alle paperlessmanager-Dokumente neu durchlaufen").
// Die laufende Verarbeitung erfolgt ereignisgesteuert über den Webhook
// (processDocumentById), daher ist hier kein @Cron-Trigger mehr gesetzt.
async processDocuments() {
try {
const response = await this.paperlessService.getDocuments({
tags__id__all: 16,
tags__id__all: PAPERLESSMANAGER_TAG_ID,
page_size: 9999,
});
const documents: any[] = Array.isArray(response)
@@ -38,17 +41,12 @@ export class PaperlessProcessorService {
const validFieldIds = new Set(customFields.map((f: any) => f.id));
this.logger.log(
`Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: 16).`,
`Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: ${PAPERLESSMANAGER_TAG_ID}).`,
);
for (const doc of documents) {
try {
const updatedDoc = await this.processSingleDocument(
doc,
validFieldIds,
);
// Postprocessing nach dem Speichern evaluieren
await this.postprocessingService.evaluate(updatedDoc || doc);
await this.processAndEvaluate(doc, validFieldIds);
} catch (innerErr: any) {
this.logger.error(
`Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`,
@@ -65,6 +63,45 @@ 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.
*/
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));
await this.processAndEvaluate(doc, validFieldIds);
this.logger.log(`Dokument ${documentId} per Webhook verarbeitet.`);
return { processed: true };
}
/**
* Reichert ein Dokument an (Tags, Pflichtfelder, Titel) und evaluiert
* anschließend das Postprocessing. Gemeinsame Logik von Batch-Lauf und Webhook.
*/
private async processAndEvaluate(
doc: any,
validFieldIds: Set<number>,
): Promise<any> {
const updatedDoc = await this.processSingleDocument(doc, validFieldIds);
// Postprocessing nach dem Speichern evaluieren
await this.postprocessingService.evaluate(updatedDoc || doc);
return updatedDoc;
}
private async processSingleDocument(
doc: any,
validFieldIds: Set<number>,
@@ -32,6 +32,6 @@ import { AuthModule } from '../auth/auth.module';
PaperlessProcessorService,
PaperlessTaskProcessorService,
],
exports: [PaperlessService],
exports: [PaperlessService, PaperlessProcessorService],
})
export class PaperlessModule {}
@@ -5,32 +5,78 @@ import {
Logger,
HttpCode,
HttpStatus,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { Public } from '../auth/public.decorator';
import { ApiKeyGuard } from '../auth/api-key.guard';
import { PaperlessProcessorService } from '../paperless/paperless-processor.service';
export interface PaperlessWebhookPayload {
document_id: number;
action: string;
doc_url?: string;
document_id?: number; // optional, für manuelles Testen
action?: string;
[key: string]: any;
}
@Controller('webhook')
@Controller('api/webhook')
export class WebhookController {
private readonly logger = new Logger(WebhookController.name);
@Public()
constructor(private readonly paperlessProcessor: PaperlessProcessorService) {}
@UseGuards(ApiKeyGuard)
@Post('paperless')
@HttpCode(HttpStatus.OK)
async handlePaperlessWebhook(
@Body() payload: PaperlessWebhookPayload,
): Promise<{ status: string }> {
async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload) {
const documentId = this.extractDocumentId(payload);
if (!documentId) {
this.logger.warn(
`Webhook ohne ermittelbare Dokument-ID: ${JSON.stringify(payload)}`,
);
throw new BadRequestException(
'Keine Dokument-ID aus doc_url/document_id ermittelbar',
);
}
this.logger.log(
`Webhook empfangen: action=${payload.action}, document=${payload.document_id}`,
`Webhook: action=${payload.action}, document=${documentId}`,
);
// TODO: Business-Logik für verschiedene Webhook-Events
// - document_updated → Felder prüfen, Postprocessing auslösen
// - document_consumed → GoBD-Archivierung prüfen
try {
const result =
await this.paperlessProcessor.processDocumentById(documentId);
return {
status: result.processed ? 'processed' : 'skipped',
reason: result.reason,
};
} catch (err) {
// Fehler tolerieren (wie der bisherige Cron) und 200 zurückgeben,
// damit Paperless keine Retry-Schleife startet.
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
`Fehler bei Webhook-Verarbeitung von Dokument ${documentId}: ${message}`,
);
return { status: 'error', message };
}
}
return { status: 'received' };
/**
* Ermittelt die Dokument-ID aus dem Webhook-Payload. Bevorzugt wird das
* optionale Feld `document_id` (für manuelles Testen), andernfalls wird die
* ID aus dem Paperless-Platzhalter `{{doc_url}}` extrahiert
* (z.B. ".../documents/123/").
*/
private extractDocumentId(payload: PaperlessWebhookPayload): number | null {
if (
payload?.document_id != null &&
!Number.isNaN(Number(payload.document_id))
) {
return Number(payload.document_id);
}
if (typeof payload?.doc_url === 'string') {
const m = payload.doc_url.match(/\/documents\/(\d+)/);
if (m) return Number(m[1]);
}
return null;
}
}
@@ -1,7 +1,10 @@
import { Module } from '@nestjs/common';
import { WebhookController } from './webhook.controller';
import { PaperlessModule } from '../paperless/paperless.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PaperlessModule, AuthModule],
controllers: [WebhookController],
})
export class WebhookModule {}