Freigabe → main: Mobile UI, Webhook, IMAP, Zahlung-Workflow und weitere Features #5

Merged
bjoernpoettker merged 16 commits from Freigabe into main 2026-07-14 07:44:32 +00:00
8 changed files with 182 additions and 61 deletions
Showing only changes of commit 9718d6888a - Show all commits
@@ -26,6 +26,7 @@ import {
CorrespondentEmailMapping, CorrespondentEmailMapping,
UserSettings, UserSettings,
LabelPrintJob, LabelPrintJob,
WebhookQueueItem,
} from './entities'; } from './entities';
// CLI-Kontext: .env laden (Laufzeit im Container liefert die Variablen via Docker, // CLI-Kontext: .env laden (Laufzeit im Container liefert die Variablen via Docker,
@@ -57,6 +58,7 @@ export const entities = [
CorrespondentEmailMapping, CorrespondentEmailMapping,
UserSettings, UserSettings,
LabelPrintJob, LabelPrintJob,
WebhookQueueItem,
]; ];
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
@@ -21,3 +21,4 @@ export { InboxPostprocessingAction } from './inbox-postprocessing-action.entity'
export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity'; export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity';
export { UserSettings } from './user-settings.entity'; export { UserSettings } from './user-settings.entity';
export { LabelPrintJob } from './label-print-job.entity'; export { LabelPrintJob } from './label-print-job.entity';
export { WebhookQueueItem } from './webhook-queue-item.entity';
@@ -0,0 +1,26 @@
import {
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
/**
* Persistente Warteschlange der vom Paperless-Webhook gemeldeten Dokument-IDs.
* `documentId` ist Primärschlüssel und erzwingt damit, dass jede ID nur einmal
* in der Warteschlange steht (Dedup auf DB-Ebene). Die Tabelle übersteht
* Neustarts; ausstehende IDs werden nach dem Boot weiterverarbeitet.
*/
@Entity('webhook_queue')
export class WebhookQueueItem {
@PrimaryColumn({ type: 'int' })
documentId!: number;
@Column({ type: 'varchar', length: 100, nullable: true })
action!: string | null;
@Index()
@CreateDateColumn()
createdAt!: Date;
}
@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Legt die persistente Webhook-Warteschlange an (`webhook_queue`).
* `documentId` ist Primärschlüssel → jede Dokument-ID kommt nur einmal vor.
*/
export class CreateWebhookQueue1782700000000 implements MigrationInterface {
name = 'CreateWebhookQueue1782700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS \`webhook_queue\` (
\`documentId\` int NOT NULL,
\`action\` varchar(100) NULL,
\`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (\`documentId\`),
INDEX \`IDX_webhook_queue_createdAt\` (\`createdAt\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE \`webhook_queue\``);
}
}
@@ -8,13 +8,51 @@ jest.mock('../paperless/paperless-processor.service', () => ({
import { WebhookQueueService } from './webhook-queue.service'; import { WebhookQueueService } from './webhook-queue.service';
/**
* Baut ein Mock-Repository, das die `webhook_queue`-Tabelle durch ein einfaches
* FIFO-Array simuliert: `INSERT IGNORE` (Dedup), FIFO-`find` und `delete`.
*/
function createQueueRepoMock() {
const store: number[] = [];
return {
store,
createQueryBuilder: jest.fn(() => ({
insert: () => ({
into: () => ({
values: (v: { documentId: number }) => ({
orIgnore: () => ({
execute: () => {
const added = !store.includes(v.documentId);
if (added) store.push(v.documentId);
return Promise.resolve({
raw: { affectedRows: added ? 1 : 0 },
});
},
}),
}),
}),
}),
})),
find: jest.fn(() =>
Promise.resolve(
store.length
? [{ documentId: store[0], action: null, createdAt: new Date() }]
: [],
),
),
delete: jest.fn((criteria: { documentId: number }) => {
const idx = store.indexOf(criteria.documentId);
if (idx >= 0) store.splice(idx, 1);
return Promise.resolve({ affected: 1 });
}),
count: jest.fn(() => Promise.resolve(store.length)),
};
}
describe('WebhookQueueService', () => { describe('WebhookQueueService', () => {
let processor: { processDocumentById: jest.Mock }; let processor: { processDocumentById: jest.Mock };
let settingRepo: { let settingRepo: { findOneBy: jest.Mock; create: jest.Mock; save: jest.Mock };
findOneBy: jest.Mock; let queueRepo: ReturnType<typeof createQueueRepoMock>;
create: jest.Mock;
save: jest.Mock;
};
let service: WebhookQueueService; let service: WebhookQueueService;
beforeEach(() => { beforeEach(() => {
@@ -26,71 +64,83 @@ describe('WebhookQueueService', () => {
create: jest.fn((x: unknown) => x), create: jest.fn((x: unknown) => x),
save: jest.fn().mockResolvedValue(undefined), save: jest.fn().mockResolvedValue(undefined),
}; };
service = new WebhookQueueService(processor as never, settingRepo as never); queueRepo = createQueueRepoMock();
service = new WebhookQueueService(
processor as never,
settingRepo as never,
queueRepo as never,
);
}); });
it('reiht jede ID nur einmal ein (Dedup)', () => { it('reiht jede ID nur einmal ein (Dedup)', async () => {
service.enqueue(5); await service.enqueue(5);
service.enqueue(5); await service.enqueue(5);
expect(service.size).toBe(1); expect(queueRepo.store).toEqual([5]);
expect(await service.count()).toBe(1);
}); });
it('verarbeitet alle eingereihten IDs und leert die Warteschlange', async () => { it('verarbeitet alle eingereihten IDs und leert die Warteschlange', async () => {
service.enqueue(1); await service.enqueue(1);
service.enqueue(2); await service.enqueue(2);
await service.processQueue(); await service.processQueue();
expect(processor.processDocumentById).toHaveBeenCalledWith(1); expect(processor.processDocumentById).toHaveBeenCalledWith(1);
expect(processor.processDocumentById).toHaveBeenCalledWith(2); expect(processor.processDocumentById).toHaveBeenCalledWith(2);
expect(processor.processDocumentById).toHaveBeenCalledTimes(2); expect(processor.processDocumentById).toHaveBeenCalledTimes(2);
expect(service.size).toBe(0); expect(queueRepo.store).toEqual([]);
}); });
it('entfernt die ID vor Verarbeitungsbeginn aus der Liste', async () => { it('entfernt die ID vor Verarbeitungsbeginn aus der Tabelle', async () => {
let sizeWhileProcessing = -1; let containedWhileProcessing = true;
processor.processDocumentById.mockImplementation(() => { processor.processDocumentById.mockImplementation((id: number) => {
sizeWhileProcessing = service.size; containedWhileProcessing = queueRepo.store.includes(id);
return Promise.resolve({ processed: true }); return Promise.resolve({ processed: true });
}); });
service.enqueue(42); await service.enqueue(42);
await service.processQueue(); await service.processQueue();
// Beim Verarbeitungsstart war die einzige ID bereits entfernt. // Beim Verarbeitungsstart war die ID bereits aus der Tabelle entfernt.
expect(sizeWhileProcessing).toBe(0); expect(containedWhileProcessing).toBe(false);
}); });
it('arbeitet eine während der Verarbeitung erneut eingereihte ID erneut ab', async () => { it('arbeitet eine während der Verarbeitung erneut eingereihte ID erneut ab', async () => {
let firstRun = true; let firstRun = true;
processor.processDocumentById.mockImplementation((id: number) => { processor.processDocumentById.mockImplementation(async (id: number) => {
// Beim ersten Lauf feuert der Webhook erneut, während verarbeitet wird. // Beim ersten Lauf feuert der Webhook erneut, während verarbeitet wird.
if (firstRun && id === 7) { if (firstRun && id === 7) {
firstRun = false; firstRun = false;
service.enqueue(7); await service.enqueue(7);
} }
return Promise.resolve({ processed: true }); return { processed: true };
}); });
service.enqueue(7); await service.enqueue(7);
await service.processQueue(); await service.processQueue();
expect(processor.processDocumentById).toHaveBeenCalledTimes(2); expect(processor.processDocumentById).toHaveBeenCalledTimes(2);
expect(service.size).toBe(0); expect(queueRepo.store).toEqual([]);
}); });
it('startet keinen zweiten Durchlauf parallel (isProcessing-Guard)', async () => { it('startet keinen zweiten Durchlauf parallel (isProcessing-Guard)', async () => {
let resolveFirst: (() => void) | undefined; let resolveFirst: (() => void) | undefined;
let signalStarted!: () => void;
const started = new Promise<void>((res) => {
signalStarted = res;
});
processor.processDocumentById.mockImplementation( processor.processDocumentById.mockImplementation(
() => () =>
new Promise<{ processed: boolean }>((res) => { new Promise<{ processed: boolean }>((res) => {
resolveFirst = () => res({ processed: true }); resolveFirst = () => res({ processed: true });
signalStarted(); // erste Verarbeitung läuft und blockiert hier
}), }),
); );
service.enqueue(1); await service.enqueue(1);
const firstRun = service.processQueue(); // blockiert in processDocumentById const firstRun = service.processQueue(); // blockiert in processDocumentById
await service.processQueue(); // muss sofort zurückkehren await started; // warten, bis der erste Lauf tatsächlich verarbeitet
await service.processQueue(); // muss sofort zurückkehren (isProcessing)
expect(processor.processDocumentById).toHaveBeenCalledTimes(1); expect(processor.processDocumentById).toHaveBeenCalledTimes(1);
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { PaperlessProcessorService } from '../paperless/paperless-processor.service'; import { PaperlessProcessorService } from '../paperless/paperless-processor.service';
import { Setting } from '../database/entities/setting.entity'; import { Setting } from '../database/entities/setting.entity';
import { WebhookQueueItem } from '../database/entities/webhook-queue-item.entity';
// Tag (Schlüssel) des Settings-Eintrags, der den letzten Webhook-Aufruf festhält. // Tag (Schlüssel) des Settings-Eintrags, der den letzten Webhook-Aufruf festhält.
const LAST_WEBHOOK_CALL_TAG = 'last_webhook_call'; const LAST_WEBHOOK_CALL_TAG = 'last_webhook_call';
@@ -21,53 +22,64 @@ interface WebhookStatusInfo {
/** /**
* Entkoppelt den Webhook-Empfang von der Verarbeitung: Eingehende Dokument-IDs * Entkoppelt den Webhook-Empfang von der Verarbeitung: Eingehende Dokument-IDs
* werden in eine deduplizierte Warteschlange gelegt und von einem separaten * werden in eine **persistente**, deduplizierte Warteschlange (`webhook_queue`)
* Intervall-Prozess **sequenziell** (ohne Überschneidung) abgearbeitet. So führt * gelegt und von einem separaten Intervall-Prozess **sequenziell** (ohne
* mehrfaches schnelles Speichern desselben Dokuments nicht zu parallelen Läufen. * Überschneidung) abgearbeitet. So führt mehrfaches schnelles Speichern
* desselben Dokuments nicht zu parallelen Läufen.
* *
* Verhalten: * Verhalten:
* - Jede ID kommt nur **einmal** in der Liste vor (Dedup). * - Jede ID kommt nur **einmal** in der Warteschlange vor (Dedup via Primär-
* - Beim Verarbeitungsstart wird die ID **sofort** aus der Liste entfernt; ein * schlüssel `documentId` / `INSERT IGNORE`).
* - Beim Verarbeitungsstart wird die ID **sofort** aus der Tabelle entfernt; ein
* erneutes Feuern während der Verarbeitung reiht sie wieder ein (ein weiterer * erneutes Feuern während der Verarbeitung reiht sie wieder ein (ein weiterer
* Lauf folgt danach). * Lauf folgt danach).
* - Es läuft immer nur eine Verarbeitung gleichzeitig. * - Es läuft immer nur eine Verarbeitung gleichzeitig (`isProcessing`-Guard).
* *
* Hinweis: Die Warteschlange liegt im Speicher; bei einem Neustart gehen noch * Da die Warteschlange in der Datenbank liegt, überstehen ausstehende IDs einen
* nicht verarbeitete IDs verloren (Paperless müsste den Webhook erneut feuern). * Neustart und werden nach dem Boot weiterverarbeitet.
*/ */
@Injectable() @Injectable()
export class WebhookQueueService { export class WebhookQueueService {
private readonly logger = new Logger(WebhookQueueService.name); private readonly logger = new Logger(WebhookQueueService.name);
private readonly pending = new Set<number>();
private isProcessing = false; private isProcessing = false;
constructor( constructor(
private readonly paperlessProcessor: PaperlessProcessorService, private readonly paperlessProcessor: PaperlessProcessorService,
@InjectRepository(Setting) @InjectRepository(Setting)
private readonly settingRepo: Repository<Setting>, private readonly settingRepo: Repository<Setting>,
@InjectRepository(WebhookQueueItem)
private readonly queueRepo: Repository<WebhookQueueItem>,
) {} ) {}
/** Anzahl der aktuell wartenden Dokument-IDs. */ /** Anzahl der aktuell wartenden Dokument-IDs. */
get size(): number { count(): Promise<number> {
return this.pending.size; return this.queueRepo.count();
} }
/** /**
* Reiht eine Dokument-ID zur Verarbeitung ein. Ist die ID bereits in der * Reiht eine Dokument-ID zur Verarbeitung ein. Ist die ID bereits in der
* Warteschlange, wird sie nicht erneut hinzugefügt (Dedup). * Warteschlange, wird sie nicht erneut hinzugefügt (Dedup über den Primär-
* schlüssel; `INSERT IGNORE` ist atomar und race-sicher).
*/ */
enqueue(documentId: number, action?: string): void { async enqueue(documentId: number, action?: string): Promise<void> {
if (this.pending.has(documentId)) { const result = await this.queueRepo
.createQueryBuilder()
.insert()
.into(WebhookQueueItem)
.values({ documentId, action: action ?? null })
.orIgnore()
.execute();
const added =
((result.raw as { affectedRows?: number })?.affectedRows ?? 0) > 0;
if (added) {
this.logger.log(`Dokument ${documentId} eingereiht.`);
} else {
this.logger.log( this.logger.log(
`Dokument ${documentId} ist bereits in der Warteschlange nicht erneut hinzugefügt.`, `Dokument ${documentId} ist bereits in der Warteschlange nicht erneut hinzugefügt.`,
); );
} else {
this.pending.add(documentId);
this.logger.log(
`Dokument ${documentId} eingereiht (Warteschlange: ${this.pending.size}).`,
);
} }
void this.recordStatus({ documentId, action, status: 'queued' }); await this.recordStatus({ documentId, action, status: 'queued' });
} }
/** /**
@@ -77,20 +89,20 @@ export class WebhookQueueService {
*/ */
@Interval(QUEUE_INTERVAL_MS) @Interval(QUEUE_INTERVAL_MS)
async processQueue(): Promise<void> { async processQueue(): Promise<void> {
if (this.isProcessing || this.pending.size === 0) return; if (this.isProcessing) return;
this.isProcessing = true; this.isProcessing = true;
try { try {
// Solange Einträge vorhanden sind, einzeln und sequenziell abarbeiten. // Solange Einträge vorhanden sind, einzeln und sequenziell abarbeiten.
while (this.pending.size > 0) { for (;;) {
// Nächste ID entnehmen und SOFORT (vor Verarbeitungsstart) entfernen. // Ältesten Eintrag (FIFO) holen ...
let documentId: number | undefined; const [next] = await this.queueRepo.find({
for (const id of this.pending) { order: { createdAt: 'ASC' },
documentId = id; take: 1,
break; });
} if (!next) break;
if (documentId === undefined) break; // ... und SOFORT (vor Verarbeitungsstart) aus der Tabelle entfernen.
this.pending.delete(documentId); await this.queueRepo.delete({ documentId: next.documentId });
await this.handleDocument(documentId); await this.handleDocument(next.documentId);
} }
} finally { } finally {
this.isProcessing = false; this.isProcessing = false;
@@ -127,7 +139,7 @@ export class WebhookQueueService {
lastCall = { raw: setting.Wert }; lastCall = { raw: setting.Wert };
} }
} }
return { lastCall, queueSize: this.pending.size }; return { lastCall, queueSize: await this.queueRepo.count() };
} }
/** /**
@@ -49,7 +49,7 @@ export class WebhookController {
); );
// Sofort einreihen und antworten; die eigentliche Verarbeitung übernimmt // Sofort einreihen und antworten; die eigentliche Verarbeitung übernimmt
// der separate Queue-Prozess (sequenziell, dedupliziert). // der separate Queue-Prozess (sequenziell, dedupliziert).
this.webhookQueue.enqueue(documentId, payload.action); await this.webhookQueue.enqueue(documentId, payload.action);
return { status: 'queued', documentId }; return { status: 'queued', documentId };
} }
@@ -5,9 +5,14 @@ import { WebhookQueueService } from './webhook-queue.service';
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'; import { Setting } from '../database/entities/setting.entity';
import { WebhookQueueItem } from '../database/entities/webhook-queue-item.entity';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Setting]), PaperlessModule, AuthModule], imports: [
TypeOrmModule.forFeature([Setting, WebhookQueueItem]),
PaperlessModule,
AuthModule,
],
controllers: [WebhookController], controllers: [WebhookController],
providers: [WebhookQueueService], providers: [WebhookQueueService],
}) })