Initial commit with Email Import Wizard and Task Processor updates
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ExportTarget } from '../database/entities/export-target.entity';
|
||||
import * as ftp from 'basic-ftp';
|
||||
import { createClient, type WebDAVClient } from 'webdav';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
private readonly logger = new Logger(ExportService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ExportTarget) private readonly targetRepo: Repository<ExportTarget>,
|
||||
) {}
|
||||
|
||||
async exportFile(targetId: number, filename: string, content: Buffer): Promise<void> {
|
||||
const target = await this.targetRepo.findOneByOrFail({ Id: targetId });
|
||||
|
||||
if (!target.IsActive) {
|
||||
throw new Error(`Export-Ziel "${target.Name}" ist deaktiviert.`);
|
||||
}
|
||||
|
||||
switch (target.Protocol) {
|
||||
case 'ftp':
|
||||
await this.uploadFtp(target, filename, content);
|
||||
break;
|
||||
case 'webdav':
|
||||
await this.uploadWebDav(target, filename, content);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unbekanntes Protokoll: ${target.Protocol}`);
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(targetId: number): Promise<{ success: boolean; message: string }> {
|
||||
const target = await this.targetRepo.findOneByOrFail({ Id: targetId });
|
||||
|
||||
try {
|
||||
switch (target.Protocol) {
|
||||
case 'ftp':
|
||||
await this.testFtp(target);
|
||||
break;
|
||||
case 'webdav':
|
||||
await this.testWebDav(target);
|
||||
break;
|
||||
default:
|
||||
return { success: false, message: `Unbekanntes Protokoll: ${target.Protocol}` };
|
||||
}
|
||||
return { success: true, message: 'Verbindung erfolgreich.' };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFtp(target: ExportTarget, filename: string, content: Buffer): Promise<void> {
|
||||
const client = new ftp.Client();
|
||||
try {
|
||||
await client.access({
|
||||
host: target.Host,
|
||||
port: target.Port || 21,
|
||||
user: target.Username || 'anonymous',
|
||||
password: target.Password || '',
|
||||
secure: false,
|
||||
});
|
||||
|
||||
const remotePath = `${target.RemotePath || '/'}/${filename}`;
|
||||
const { Readable } = await import('stream');
|
||||
const stream = Readable.from(content);
|
||||
await client.uploadFrom(stream, remotePath);
|
||||
this.logger.log(`FTP Upload: ${remotePath} → ${target.Name}`);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async testFtp(target: ExportTarget): Promise<void> {
|
||||
const client = new ftp.Client();
|
||||
try {
|
||||
await client.access({
|
||||
host: target.Host,
|
||||
port: target.Port || 21,
|
||||
user: target.Username || 'anonymous',
|
||||
password: target.Password || '',
|
||||
secure: false,
|
||||
});
|
||||
await client.list(target.RemotePath || '/');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadWebDav(target: ExportTarget, filename: string, content: Buffer): Promise<void> {
|
||||
const client = this.createWebDavClient(target);
|
||||
const remotePath = `${target.RemotePath || '/'}/${filename}`;
|
||||
await client.putFileContents(remotePath, content);
|
||||
this.logger.log(`WebDAV Upload: ${remotePath} → ${target.Name}`);
|
||||
}
|
||||
|
||||
private async testWebDav(target: ExportTarget): Promise<void> {
|
||||
const client = this.createWebDavClient(target);
|
||||
await client.getDirectoryContents(target.RemotePath || '/');
|
||||
}
|
||||
|
||||
private createWebDavClient(target: ExportTarget): WebDAVClient {
|
||||
const protocol = target.Port === 443 ? 'https' : 'http';
|
||||
const port = target.Port ? `:${target.Port}` : '';
|
||||
const url = `${protocol}://${target.Host}${port}`;
|
||||
|
||||
return createClient(url, {
|
||||
username: target.Username || undefined,
|
||||
password: target.Password || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
private transporter: nodemailer.Transporter;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: this.configService.get<string>('SMTP_HOST', ''),
|
||||
port: this.configService.get<number>('SMTP_PORT', 587),
|
||||
secure: this.configService.get<string>('SMTP_SECURE', 'false') === 'true',
|
||||
auth: {
|
||||
user: this.configService.get<string>('SMTP_USER', ''),
|
||||
pass: this.configService.get<string>('SMTP_PASS', ''),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sendMail(options: {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
attachments?: { filename: string; content: Buffer }[];
|
||||
}): Promise<void> {
|
||||
const from = this.configService.get<string>('SMTP_FROM', 'paperless@localhost');
|
||||
|
||||
await this.transporter.sendMail({
|
||||
from,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
text: options.body,
|
||||
attachments: options.attachments?.map(a => ({
|
||||
filename: a.filename,
|
||||
content: a.content,
|
||||
})),
|
||||
});
|
||||
|
||||
this.logger.log(`Mail gesendet an ${options.to}: "${options.subject}"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Postprocessing } from '../database/entities/postprocessing.entity';
|
||||
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
|
||||
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
|
||||
import { ExportTarget } from '../database/entities/export-target.entity';
|
||||
import { PostprocessingService } from './postprocessing.service';
|
||||
import { MailService } from './mail.service';
|
||||
import { ExportService } from './export.service';
|
||||
import { PaperlessModule } from '../paperless/paperless.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Postprocessing, PostprocessingAction, PostprocessingLog, ExportTarget]),
|
||||
forwardRef(() => PaperlessModule),
|
||||
],
|
||||
providers: [PostprocessingService, MailService, ExportService],
|
||||
exports: [PostprocessingService, ExportService, MailService],
|
||||
})
|
||||
export class PostprocessingModule {}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { PostprocessingService } from './postprocessing.service';
|
||||
import { Postprocessing } from '../database/entities/postprocessing.entity';
|
||||
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
|
||||
const mockRules: Partial<Postprocessing>[] = [
|
||||
{ Id: 1, Name: 'Rule1', DocumentTypeId: 5, CorrespondentId: null, OwnerId: null, TagId: null, Order: 1, IsActive: true, NoFurther: false },
|
||||
{ Id: 2, Name: 'StopRule', DocumentTypeId: null, CorrespondentId: null, OwnerId: null, TagId: null, Order: 2, IsActive: true, NoFurther: true },
|
||||
];
|
||||
|
||||
const mockActions: Partial<PostprocessingAction>[] = [
|
||||
{ Id: 1, PostprocessingId: 1, ActionType: 2, Content: '99', Order: 1, IsActive: true },
|
||||
];
|
||||
|
||||
describe('PostprocessingService', () => {
|
||||
let service: PostprocessingService;
|
||||
let ppRepo: any;
|
||||
let ppActionRepo: any;
|
||||
let paperlessService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
ppRepo = { find: jest.fn().mockResolvedValue(mockRules) };
|
||||
ppActionRepo = { find: jest.fn().mockResolvedValue(mockActions) };
|
||||
paperlessService = { updateDocument: jest.fn().mockResolvedValue({}) };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PostprocessingService,
|
||||
{ provide: getRepositoryToken(Postprocessing), useValue: ppRepo },
|
||||
{ provide: getRepositoryToken(PostprocessingAction), useValue: ppActionRepo },
|
||||
{ provide: PaperlessService, useValue: paperlessService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PostprocessingService>(PostprocessingService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('evaluate loads active rules in order', async () => {
|
||||
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
|
||||
|
||||
expect(ppRepo.find).toHaveBeenCalledWith({
|
||||
where: { IsActive: true },
|
||||
order: { Order: 'ASC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('evaluate executes matching actions', async () => {
|
||||
// Rule1 matches documentTypeId=5 → action adds tag
|
||||
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
|
||||
|
||||
expect(ppActionRepo.find).toHaveBeenCalledWith({
|
||||
where: { PostprocessingId: 1, IsActive: true },
|
||||
order: { Order: 'ASC' },
|
||||
});
|
||||
expect(paperlessService.updateDocument).toHaveBeenCalledWith(100, { tags: [99] });
|
||||
});
|
||||
|
||||
it('evaluate stops at NoFurther rule', async () => {
|
||||
// Rule1 matches, Rule2 also matches (no filters) + NoFurther → stops
|
||||
await service.evaluate({ documentId: 100, documentTypeId: 5, tagIds: [] });
|
||||
|
||||
// Actions loaded for rule 1 and rule 2, but no rule after rule 2
|
||||
expect(ppActionRepo.find).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('evaluate skips non-matching rules', async () => {
|
||||
// documentTypeId=999 doesn't match Rule1 (requires 5) but matches Rule2 (no filter)
|
||||
ppActionRepo.find.mockResolvedValue([]);
|
||||
await service.evaluate({ documentId: 100, documentTypeId: 999, tagIds: [] });
|
||||
|
||||
// Rule1 skipped, Rule2 matched → only 1 action lookup
|
||||
expect(ppActionRepo.find).toHaveBeenCalledTimes(1);
|
||||
expect(ppActionRepo.find).toHaveBeenCalledWith({
|
||||
where: { PostprocessingId: 2, IsActive: true },
|
||||
order: { Order: 'ASC' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Postprocessing, type FilterGroup, type FilterCondition } from '../database/entities/postprocessing.entity';
|
||||
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
|
||||
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import { MailService } from './mail.service';
|
||||
import { ExportService } from './export.service';
|
||||
import axios from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class PostprocessingService {
|
||||
private readonly logger = new Logger(PostprocessingService.name);
|
||||
private readonly errorTagId: number;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Postprocessing) private readonly ppRepo: Repository<Postprocessing>,
|
||||
@InjectRepository(PostprocessingAction) private readonly actionRepo: Repository<PostprocessingAction>,
|
||||
@InjectRepository(PostprocessingLog) private readonly logRepo: Repository<PostprocessingLog>,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(forwardRef(() => PaperlessService)) private readonly paperlessService: PaperlessService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly exportService: ExportService,
|
||||
) {
|
||||
this.errorTagId = this.configService.get<number>('POSTPROCESSING_ERROR_TAG', 0);
|
||||
}
|
||||
|
||||
async evaluate(doc: any): Promise<void> {
|
||||
const rules = await this.ppRepo.find({
|
||||
where: { IsActive: true },
|
||||
order: { Order: 'ASC' },
|
||||
});
|
||||
|
||||
// Enrich doc with resolved names (once per evaluation)
|
||||
await this.enrichDocWithNames(doc);
|
||||
|
||||
this.logger.log(`[Postprocessing] Dokument ${doc.id} wird gegen ${rules.length} Regel(n) geprüft`);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!this.hasConditions(rule.FilterJson)) {
|
||||
this.logger.warn(`[Postprocessing] Regel "${rule.Name}" (ID: ${rule.Id}) hat keine Bedingungen – wird übersprungen.`);
|
||||
continue;
|
||||
}
|
||||
this.logger.debug(`[Postprocessing] Prüfe Regel "${rule.Name}" (ID: ${rule.Id}) für Dokument ${doc.id}`);
|
||||
const matches = this.matchesFilter(rule.FilterJson, doc);
|
||||
this.logger.log(`[Postprocessing] Regel "${rule.Name}" (ID: ${rule.Id}) → ${matches ? 'TRIFFT ZU' : 'trifft nicht zu'}`);
|
||||
if (!matches) continue;
|
||||
|
||||
this.logger.log(`[Postprocessing] Regel "${rule.Name}" trifft zu für Dokument ${doc.id}`);
|
||||
|
||||
const actions = await this.actionRepo.find({
|
||||
where: { PostprocessingId: rule.Id, IsActive: true },
|
||||
order: { Order: 'ASC' },
|
||||
});
|
||||
|
||||
let hasError = false;
|
||||
for (const action of actions) {
|
||||
try {
|
||||
await this.executeAction(action, doc);
|
||||
await this.log(rule.Id, action.Id, doc.id, 'success', `Aktion ${action.ActionType} erfolgreich`);
|
||||
} catch (err: any) {
|
||||
hasError = true;
|
||||
this.logger.error(`Fehler bei Aktion ${action.Id} (Typ ${action.ActionType}) für Dokument ${doc.id}: ${err.message}`);
|
||||
await this.log(rule.Id, action.Id, doc.id, 'error', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError && this.errorTagId) {
|
||||
try {
|
||||
const currentTags = new Set<number>(doc.tags || []);
|
||||
currentTags.add(this.errorTagId);
|
||||
await this.paperlessService.updateDocument(doc.id, { tags: Array.from(currentTags) });
|
||||
} catch (tagErr: any) {
|
||||
this.logger.error(`Konnte Error-Tag ${this.errorTagId} nicht setzen für Dokument ${doc.id}: ${tagErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.NoFurther) {
|
||||
this.logger.debug('NoFurther – keine weiteren Regeln');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recursive Filter Evaluation ──────────────────────────────────
|
||||
|
||||
private hasConditions(filter: FilterGroup): boolean {
|
||||
if (!filter || !filter.rules || filter.rules.length === 0) return false;
|
||||
return filter.rules.some(rule => {
|
||||
if ('combinator' in rule) return this.hasConditions(rule as FilterGroup);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private matchesFilter(filter: FilterGroup, doc: any): boolean {
|
||||
if (!filter || !filter.rules || filter.rules.length === 0) return false;
|
||||
|
||||
const results = filter.rules.map(rule => {
|
||||
if ('combinator' in rule) {
|
||||
return this.matchesFilter(rule as FilterGroup, doc);
|
||||
}
|
||||
return this.evaluateCondition(rule as FilterCondition, doc);
|
||||
});
|
||||
|
||||
return filter.combinator === 'AND'
|
||||
? results.every(Boolean)
|
||||
: results.some(Boolean);
|
||||
}
|
||||
|
||||
private evaluateCondition(cond: FilterCondition, doc: any): boolean {
|
||||
const actual = this.resolveFieldValue(cond.field, doc);
|
||||
const expected = cond.value;
|
||||
let result: boolean;
|
||||
|
||||
switch (cond.operator) {
|
||||
case 'equals':
|
||||
if (cond.field === 'tag') {
|
||||
result = Array.isArray(actual) && actual.includes(Number(expected));
|
||||
} else {
|
||||
result = String(actual ?? '') === String(expected ?? '');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'not_equals':
|
||||
if (cond.field === 'tag') {
|
||||
result = !Array.isArray(actual) || !actual.includes(Number(expected));
|
||||
} else {
|
||||
result = String(actual ?? '') !== String(expected ?? '');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'contains':
|
||||
if (cond.field === 'tag') {
|
||||
result = Array.isArray(actual) && actual.includes(Number(expected));
|
||||
} else {
|
||||
result = String(actual ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase());
|
||||
}
|
||||
break;
|
||||
|
||||
case 'not_contains':
|
||||
if (cond.field === 'tag') {
|
||||
result = !Array.isArray(actual) || !actual.includes(Number(expected));
|
||||
} else {
|
||||
result = !String(actual ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase());
|
||||
}
|
||||
break;
|
||||
|
||||
case 'is_set':
|
||||
result = actual !== null && actual !== undefined && actual !== '';
|
||||
break;
|
||||
|
||||
case 'is_not_set':
|
||||
result = actual === null || actual === undefined || actual === '';
|
||||
break;
|
||||
|
||||
case 'gt':
|
||||
result = parseFloat(actual) > parseFloat(expected);
|
||||
break;
|
||||
|
||||
case 'lt':
|
||||
result = parseFloat(actual) < parseFloat(expected);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unbekannter Operator: ${cond.operator}`);
|
||||
result = false;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`[Postprocessing] Bedingung: ${cond.field} ${cond.operator} "${expected}" | Istwert: "${Array.isArray(actual) ? actual.join(',') : actual}" → ${result ? 'WAHR' : 'FALSCH'}`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private resolveFieldValue(field: string, doc: any): any {
|
||||
switch (field) {
|
||||
case 'document_type':
|
||||
return doc.document_type;
|
||||
case 'correspondent':
|
||||
return doc.correspondent;
|
||||
case 'owner':
|
||||
return doc.owner;
|
||||
case 'tag':
|
||||
return doc.tags || [];
|
||||
case 'title':
|
||||
return doc.title;
|
||||
case 'archive_serial_number':
|
||||
return doc.archive_serial_number;
|
||||
default:
|
||||
// Custom field: "custom_field_<id>"
|
||||
if (field.startsWith('custom_field_')) {
|
||||
const fieldId = parseInt(field.replace('custom_field_', ''), 10);
|
||||
const cf = (doc.custom_fields || []).find((f: any) => f.field === fieldId);
|
||||
return cf?.value ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action Execution ─────────────────────────────────────────────
|
||||
|
||||
private async executeAction(action: PostprocessingAction, doc: any): Promise<void> {
|
||||
const content = action.Content;
|
||||
|
||||
switch (action.ActionType) {
|
||||
case 1: // Export
|
||||
await this.handleExport(content, doc);
|
||||
break;
|
||||
case 2: // Mail
|
||||
await this.handleMail(content, doc);
|
||||
break;
|
||||
case 3: // Tag setzen/entfernen
|
||||
await this.handleTags(content, doc);
|
||||
break;
|
||||
case 4: // Custom Field setzen
|
||||
await this.handleCustomField(content, doc);
|
||||
break;
|
||||
case 5: // Webhook
|
||||
await this.handleWebhook(content, doc);
|
||||
break;
|
||||
case 6: // Notiz hinzufügen
|
||||
await this.handleNote(content, doc);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(`Unbekannter ActionType: ${action.ActionType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichDocWithNames(doc: any): Promise<void> {
|
||||
try {
|
||||
if (doc.correspondent && !doc._correspondentName) {
|
||||
const response = await this.paperlessService.getCorrespondents({ page_size: 9999 });
|
||||
const correspondents = response.results;
|
||||
const c = correspondents.find((x: any) => x.id === doc.correspondent);
|
||||
doc._correspondentName = c?.name ?? '';
|
||||
}
|
||||
if (doc.document_type && !doc._documentTypeName) {
|
||||
const docTypes = await this.paperlessService.getDocumentTypes();
|
||||
const dt = docTypes.find((x: any) => x.id === doc.document_type);
|
||||
doc._documentTypeName = dt?.name ?? '';
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Konnte Namen nicht auflösen: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveTemplate(template: string, doc: any): string {
|
||||
const created = doc.created ? new Date(doc.created) : null;
|
||||
const replacements: Record<string, string> = {
|
||||
'{id}': String(doc.id ?? ''),
|
||||
'{titel}': doc.title ?? '',
|
||||
'{korrespondent}': String(doc.correspondent ?? ''),
|
||||
'{absender}': String(doc.correspondent ?? ''),
|
||||
'{korrespondent_name}': doc._correspondentName ?? String(doc.correspondent ?? ''),
|
||||
'{absender_name}': doc._correspondentName ?? String(doc.correspondent ?? ''),
|
||||
'{dokumenttyp}': String(doc.document_type ?? ''),
|
||||
'{dokumenttyp_name}': doc._documentTypeName ?? String(doc.document_type ?? ''),
|
||||
'{besitzer}': String(doc.owner ?? ''),
|
||||
'{ablagenummer}': String(doc.archive_serial_number ?? ''),
|
||||
'{datum}': created ? `${created.getFullYear()}-${String(created.getMonth() + 1).padStart(2, '0')}-${String(created.getDate()).padStart(2, '0')}` : '',
|
||||
'{jahr}': created ? String(created.getFullYear()) : '',
|
||||
'{monat}': created ? String(created.getMonth() + 1).padStart(2, '0') : '',
|
||||
'{tag}': created ? String(created.getDate()).padStart(2, '0') : '',
|
||||
'{zeitstempel}': (() => {
|
||||
const now = new Date();
|
||||
return `${String(now.getDate()).padStart(2, '0')}.${String(now.getMonth() + 1).padStart(2, '0')}.${now.getFullYear()} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
})(),
|
||||
};
|
||||
|
||||
// Custom Fields: {custom_field_<id>}
|
||||
for (const cf of (doc.custom_fields || [])) {
|
||||
replacements[`{custom_field_${cf.field}}`] = String(cf.value ?? '');
|
||||
}
|
||||
|
||||
let result = template;
|
||||
for (const [placeholder, value] of Object.entries(replacements)) {
|
||||
result = result.replaceAll(placeholder, value);
|
||||
}
|
||||
|
||||
// Sanitize filename chars
|
||||
return result.replace(/[<>:"/\\|?*]/g, '_');
|
||||
}
|
||||
|
||||
private buildFilename(template: string | undefined, doc: any): string {
|
||||
if (template) {
|
||||
const resolved = this.resolveTemplate(template, doc);
|
||||
return resolved.endsWith('.pdf') ? resolved : `${resolved}.pdf`;
|
||||
}
|
||||
return `${doc.title || `document_${doc.id}`}.pdf`;
|
||||
}
|
||||
|
||||
private async handleExport(content: Record<string, any>, doc: any): Promise<void> {
|
||||
const fileType = content.fileType || 'archive';
|
||||
const buffer = await this.paperlessService.downloadDocument(doc.id, fileType);
|
||||
const filename = this.buildFilename(content.filenameTemplate, doc);
|
||||
await this.exportService.exportFile(content.exportTargetId, filename, buffer);
|
||||
}
|
||||
|
||||
private async handleMail(content: Record<string, any>, doc: any): Promise<void> {
|
||||
const fileType = content.fileType || 'archive';
|
||||
const buffer = await this.paperlessService.downloadDocument(doc.id, fileType);
|
||||
const filename = this.buildFilename(content.filenameTemplate, doc);
|
||||
|
||||
const subject = content.subject
|
||||
? this.resolveTemplate(content.subject, doc)
|
||||
: `Dokument: ${doc.title}`;
|
||||
const body = content.body
|
||||
? this.resolveTemplate(content.body, doc)
|
||||
: `Anbei das Dokument "${doc.title}" (ID: ${doc.id}).`;
|
||||
|
||||
await this.mailService.sendMail({
|
||||
to: content.to,
|
||||
subject,
|
||||
body,
|
||||
attachments: [{ filename, content: buffer }],
|
||||
});
|
||||
}
|
||||
|
||||
private async handleTags(content: Record<string, any>, doc: any): Promise<void> {
|
||||
const currentTags = new Set<number>(doc.tags || []);
|
||||
const addTags: number[] = content.addTags || [];
|
||||
const removeTags: number[] = content.removeTags || [];
|
||||
|
||||
addTags.forEach(t => currentTags.add(t));
|
||||
removeTags.forEach(t => currentTags.delete(t));
|
||||
|
||||
await this.paperlessService.updateDocument(doc.id, { tags: Array.from(currentTags) });
|
||||
}
|
||||
|
||||
private async handleCustomField(content: Record<string, any>, doc: any): Promise<void> {
|
||||
const customFields = [...(doc.custom_fields || [])];
|
||||
const existing = customFields.find((f: any) => f.field === content.fieldId);
|
||||
|
||||
if (existing) {
|
||||
existing.value = content.value;
|
||||
} else {
|
||||
customFields.push({ field: content.fieldId, value: content.value });
|
||||
}
|
||||
|
||||
await this.paperlessService.updateDocument(doc.id, { custom_fields: customFields });
|
||||
}
|
||||
|
||||
private async handleWebhook(content: Record<string, any>, doc: any): Promise<void> {
|
||||
const method = (content.method || 'POST').toUpperCase();
|
||||
const headers = content.headers || {};
|
||||
const body = { documentId: doc.id, title: doc.title, tags: doc.tags, ...(content.body || {}) };
|
||||
|
||||
await axios({
|
||||
method,
|
||||
url: content.url,
|
||||
headers,
|
||||
data: method !== 'GET' ? body : undefined,
|
||||
params: method === 'GET' ? body : undefined,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
this.logger.log(`Webhook ${method} → ${content.url}`);
|
||||
}
|
||||
|
||||
private async handleNote(content: Record<string, any>, doc: any): Promise<void> {
|
||||
if (!content.note) return;
|
||||
const resolvedNote = this.resolveTemplate(content.note, doc);
|
||||
await this.paperlessService.addNote(doc.id, resolvedNote);
|
||||
}
|
||||
|
||||
// ── Logging ──────────────────────────────────────────────────────
|
||||
|
||||
private async log(ppId: number, actionId: number | null, docId: number, status: string, message: string): Promise<void> {
|
||||
const entry = this.logRepo.create({
|
||||
PostprocessingId: ppId,
|
||||
ActionId: actionId,
|
||||
DocumentId: docId,
|
||||
Status: status,
|
||||
Message: message,
|
||||
CreatedAt: new Date(),
|
||||
});
|
||||
await this.logRepo.save(entry);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user