From dad0136365afad2bdc9a1c19003a68cba18ecb39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 8 Jun 2026 09:02:02 +0200 Subject: [PATCH] chore: apply ESLint auto-fix across entire backend Reformats code style (line breaks, indentation, type annotations) without changing logic. Also includes minor feature additions bundled in the same lint run (stats service, user-settings groups, agrarmonitor polling improvements). Co-Authored-By: Claude Sonnet 4.6 --- docs/LABEL_PRINT_AGENT_ARCHITEKTUR.md | 988 ++++++++++++++++++ paperless-backend/package-lock.json | 4 +- .../agrarmonitor-polling.service.ts | 438 ++++++-- .../agrarmonitor/agrarmonitor.controller.ts | 27 +- .../src/agrarmonitor/agrarmonitor.service.ts | 43 +- paperless-backend/src/app.module.ts | 4 +- paperless-backend/src/auth/api-key.guard.ts | 20 +- .../src/auth/api-keys.controller.ts | 10 +- .../src/auth/api-keys.service.ts | 23 +- paperless-backend/src/auth/auth.module.ts | 10 +- .../src/auth/jwt-or-apikey.guard.ts | 11 +- paperless-backend/src/auth/jwt.strategy.ts | 9 +- .../src/auth/permissions.decorator.ts | 3 +- .../src/auth/permissions.enum.ts | 9 +- .../src/auth/permissions.guard.ts | 24 +- .../src/barcode/barcode-scanner.service.ts | 74 +- .../barcode/barcode-templates.controller.ts | 53 +- .../src/barcode/page-cache.service.ts | 10 +- .../daily-digest/daily-digest.controller.ts | 12 +- .../src/daily-digest/daily-digest.service.ts | 193 +++- .../src/database/entities/api-key.entity.ts | 8 +- .../database/entities/attachment.entity.ts | 10 +- .../entities/barcode-template.entity.ts | 22 +- .../src/database/entities/content.entity.ts | 9 +- .../src/database/entities/email.entity.ts | 8 +- .../entities/postprocessing.entity.ts | 2 +- .../database/entities/user-client.entity.ts | 6 +- .../email-download.controller.ts | 3 +- .../email-download/email-download.module.ts | 3 +- .../email-download/email-download.service.ts | 169 ++- .../src/email-download/zugferd.util.ts | 6 +- .../src/email/email-import.controller.ts | 63 +- .../src/email/email-import.service.ts | 477 ++++++--- .../src/email/email-page-cache.service.ts | 34 +- .../src/email/email.controller.spec.ts | 26 +- .../src/email/email.controller.ts | 104 +- paperless-backend/src/email/email.module.ts | 8 +- .../src/freigabe/freigabe.controller.ts | 5 +- .../src/freigabe/freigabe.module.ts | 5 +- .../src/freigabe/freigabe.service.ts | 31 +- .../src/inbox-postprocessor/edit-applier.ts | 6 +- .../inbox-postprocessor.module.ts | 7 +- .../inbox-postprocessor.service.ts | 241 +++-- .../inbox-postprocessor/variable-resolver.ts | 5 +- .../src/inbox/clients.controller.ts | 7 +- .../src/inbox/inbox-migration.service.ts | 27 +- .../src/inbox/inbox.controller.ts | 116 +- paperless-backend/src/inbox/inbox.service.ts | 74 +- .../kontonummern/kontonummern.controller.ts | 1 - .../label-print-agent.controller.ts | 36 +- .../label-print-agent.service.ts | 65 +- .../label-renderer.service.ts | 29 +- paperless-backend/src/main.ts | 4 +- .../paperless/paperless-processor.service.ts | 114 +- .../paperless-task-processor.service.ts | 303 ++++-- .../src/paperless/paperless.controller.ts | 179 +++- .../src/paperless/paperless.module.ts | 14 +- .../src/paperless/paperless.service.ts | 110 +- .../src/postprocessing/export.service.ts | 30 +- .../src/postprocessing/mail.service.ts | 20 +- .../postprocessing/postprocessing.module.ts | 7 +- .../postprocessing.service.spec.ts | 48 +- .../postprocessing/postprocessing.service.ts | 199 +++- .../document-pipeline.service.ts | 10 +- .../src/preprocessing/ocr.service.ts | 9 +- .../src/preprocessing/pdf.service.ts | 16 +- .../src/scanner/scanner-watcher.service.ts | 40 +- .../src/settings/settings.controller.spec.ts | 23 +- .../src/settings/settings.controller.ts | 180 +++- paperless-backend/src/stats/stats.module.ts | 6 +- paperless-backend/src/stats/stats.service.ts | 51 +- .../user-settings/user-settings.controller.ts | 36 +- .../user-settings/user-settings.service.ts | 80 +- .../src/webhook/webhook.controller.ts | 17 +- 74 files changed, 4022 insertions(+), 1052 deletions(-) create mode 100644 docs/LABEL_PRINT_AGENT_ARCHITEKTUR.md diff --git a/docs/LABEL_PRINT_AGENT_ARCHITEKTUR.md b/docs/LABEL_PRINT_AGENT_ARCHITEKTUR.md new file mode 100644 index 0000000..6c9e607 --- /dev/null +++ b/docs/LABEL_PRINT_AGENT_ARCHITEKTUR.md @@ -0,0 +1,988 @@ +# Label-Print-Agent – Architektur & Implementierungsreferenz + +Dieses Dokument beschreibt vollständig das Label-Print-Agent-System aus dem Paperless Manager. Es dient als Grundlage für die Reimplementierung in einem anderen Projekt. + +--- + +## 1. Systemübersicht + +Das System ermöglicht es, **Etiketten-Templates** zu definieren (mit Layout, Variablen-Feldern und Abmessungen) und daraus **Druckjobs** zu erzeugen, die von einem oder mehreren **Print Agents** abgeholt und gedruckt werden. + +### Beteiligte Komponenten + +| Komponente | Aufgabe | +|---|---| +| **Frontend** | Template-Verwaltung, Job-Erstellung, Label-Vorschau | +| **Backend API** | REST-Endpunkte, Job-Queue, Rendering | +| **Datenbank** | Persistenz für Templates und Jobs | +| **LabelRenderer** | SVG → PNG (300 DPI) | +| **Print Agent** | Eigenständiger Client, der Jobs abholt und druckt | + +### Datenfluss (ASCII) + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ FRONTEND │ +│ • Template anlegen (Name, Layout, Felder, Maße) │ +│ • Vorschau rendern (POST /preview → PNG) │ +│ • Druckjob erstellen (POST /jobs → { jobId }) │ +└──────────────────────────────┬─────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ BACKEND: createJob() │ +│ 1. Template laden │ +│ 2. Variablen aufbauen (Felder + Datumssubfelder + {number}) │ +│ 3. LabelGetUrl aufrufen → {number} reservieren (optional) │ +│ 4. Job speichern (Status=pending, imageData=null) │ +│ 5. newJob$.next() → SSE-Notification an alle Agents │ +└──────────────────────────────┬─────────────────────────────────────┘ + │ + ┌───────────────┴──────────────────┐ + │ SSE-Push │ Polling (Fallback) + ▼ ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ PRINT AGENT │ +│ GET /jobs/next?agentId=MEIN-PC │ +│ → Lock setzen (lockedAt = now) │ +│ → Lazy Render: Layout → SVG → PNG → in DB speichern │ +│ → { jobId, labelImageBase64, widthMm, heightMm } │ +│ │ +│ Drucken → POST /jobs/:id/printed (Erfolg) │ +│ → POST /jobs/:id/error (Fehler) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Datenmodelle + +### 2.1 LabelTemplate (in PM: Teil des `BarcodeTemplate`) + +Für das neue Projekt empfiehlt sich eine eigenständige `LabelTemplate`-Entity: + +```typescript +export interface LabelInputField { + name: string; // Technischer Feldname (Variablenname) + label: string; // Anzeigetext im Formular + type: 'text' | 'number' | 'date'; +} + +export type LabelElement = + | { + type: 'text'; + content: string; // Inhalt, kann {variablenname} enthalten + x: number; // X-Position in mm ab linker Kante + y: number; // Y-Position in mm ab oberer Kante + fontSize: number; // Schriftgröße in mm + bold?: boolean; + align?: 'left' | 'center' | 'right'; + maxWidth?: number; // Maximale Breite in mm (Text wird gestreckt/gestaucht) + } + | { + type: 'qr'; + content: string; // QR-Inhalt, kann {variablenname} enthalten + x: number; // X-Position in mm + y: number; // Y-Position in mm + sizeMm: number; // Quadratische Größe in mm + } + | { + type: 'line'; + x1: number; y1: number; // Startpunkt in mm + x2: number; y2: number; // Endpunkt in mm + lineWidth?: number; // Linienbreite in mm (default: ~0,085mm = 1px bei 300DPI) + }; + +// TypeORM Entity (vereinfacht) +@Entity('label_templates') +export class LabelTemplate { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'int', default: 57 }) + widthMm: number; // Etikettenbreite in mm + + @Column({ type: 'int', default: 32 }) + heightMm: number; // Etikettenhöhe in mm + + @Column({ type: 'simple-json', nullable: true }) + inputFields: LabelInputField[] | null; // Formularfelder + + @Column({ type: 'simple-json', nullable: true }) + layout: LabelElement[] | null; // Etikett-Elemente + + @Column({ length: 1000, nullable: true }) + getUrl: string | null; // URL zur Nummernreservierung (GET, {var} möglich) + + @Column({ length: 1000, nullable: true }) + printedUrl: string | null; // Callback nach erfolgreichem Druck (POST) + + @Column({ length: 1000, nullable: true }) + releaseUrl: string | null; // Callback bei Druckfehler (POST) + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} +``` + +### 2.2 LabelPrintJob + +Die DB-Tabelle fungiert als **persistente Job-Queue**. + +```typescript +// Originaldatei: paperless-backend/src/database/entities/label-print-job.entity.ts + +@Entity('label_print_jobs') +export class LabelPrintJob { + @PrimaryGeneratedColumn() + Id: number; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + Status: 'pending' | 'printed' | 'error'; + + @Column({ type: 'mediumblob', nullable: true }) + LabelImageData: Buffer | null; // Lazy-rendered PNG (kann null sein bis Claim) + + @Column({ type: 'int' }) + LabelWidthMm: number; + + @Column({ type: 'int' }) + LabelHeightMm: number; + + @Column({ type: 'int', nullable: true }) + BarcodeTemplateId: number | null; // Referenz auf Template (ohne FK-Constraint) + + @Column({ type: 'simple-json', nullable: true }) + LabelVariables: Record | null; // Aufgelöste Variablen zum Druckzeitpunkt + + @Index() + @Column({ type: 'datetime', nullable: true }) + LockedAt: Date | null; // Zeitstempel des Locks + + @Column({ length: 255, nullable: true }) + LockedByAgent: string | null; // Agenten-ID (z.B. "BUERO-PC") + + @Column({ type: 'datetime', nullable: true }) + PrintedAt: Date | null; + + @Column({ length: 255, nullable: true }) + PrintedByAgent: string | null; + + @Column({ length: 255, nullable: true }) + PrinterName: string | null; + + @Column({ type: 'text', nullable: true }) + ErrorMessage: string | null; + + @CreateDateColumn() + CreatedAt: Date; +} +``` + +**Wichtig:** `LabelVariables` speichert die bereits aufgelösten Variablen zum Zeitpunkt der Job-Erstellung. Das Template-Layout kann sich danach ändern — der Job verwendet trotzdem die ursprünglichen Werte. + +--- + +## 3. API-Endpunkte + +Alle Endpunkte unter `/api/label-print-agent`: + +### Frontend-Endpunkte (erfordern Authentifizierung) + +#### `POST /preview` — Label-Vorschau rendern + +Rendert ein PNG ohne DB-Eintrag. Zum Testen von Layouts. + +``` +Request: { templateId: number, fieldValues?: Record } +Response: image/png (Buffer) +``` + +#### `POST /jobs` — Druckjob erstellen + +``` +Request: { templateId: number, fieldValues?: Record } +Response: { jobId: string } +Status: 201 Created +``` + +### Agent-Endpunkte + +#### `GET /events` — SSE-Stream (Server-Sent Events) + +Agent hält diese Verbindung dauerhaft offen. Bei neuem Job erhält er eine Notification. + +``` +Response: text/event-stream +Payload: { type: 'label-job-available' } +Header: X-Accel-Buffering: no (wichtig für nginx-Reverse-Proxy) +``` + +#### `GET /jobs/next?agentId=MEIN-PC` — Nächsten Job beanspruchen + +``` +Response 200: { + jobId: string, + labelImageBase64: string | null, // PNG als Base64 + labelImageContentType: 'image/png', + labelWidthMm: number, + labelHeightMm: number +} +Response 204: (kein Job verfügbar) +``` + +#### `GET /jobs/:id/image` — Job-Bild separat abrufen + +``` +Response: image/png (Buffer) +``` + +#### `POST /jobs/:id/printed` — Job als gedruckt markieren + +``` +Request: { agentId?: string, printerName?: string } +Response: { ok: true } +``` + +#### `POST /jobs/:id/error` — Druckfehler melden + +``` +Request: { agentId?: string, printerName?: string, errorMessage?: string } +Response: { ok: true } +``` + +--- + +## 4. SVG-Rendering-Pipeline + +**Originaldatei:** `paperless-backend/src/label-print-agent/label-renderer.service.ts` + +### Abhängigkeiten + +```bash +npm install qrcode @resvg/resvg-js +npm install --save-dev @types/qrcode +``` + +### Vollständige Implementierung + +```typescript +import * as QRCode from 'qrcode'; +import { Resvg } from '@resvg/resvg-js'; + +const MM_TO_PX = 300 / 25.4; // 300 DPI + +function mm(v: number): number { + return Math.round(v * MM_TO_PX); +} + +function escape(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// Variablen-Substitution: {varName} oder {varName:6} (zero-padded auf 6 Stellen) +function applyVars(template: string, vars: Record): string { + return template.replace(/\{([^}]+)\}/g, (_, key: string) => { + const colonIdx = key.indexOf(':'); + if (colonIdx !== -1) { + const varName = key.slice(0, colonIdx); + const width = parseInt(key.slice(colonIdx + 1), 10); + if (!isNaN(width)) { + return (vars[varName] ?? '').padStart(width, '0'); + } + } + return vars[key] ?? `{${key}}`; // Unbekannte Variablen: Platzhalter stehen lassen + }); +} + +async function renderLabel( + layout: LabelElement[], + widthMm: number, + heightMm: number, + variables: Record, +): Promise { + const W = mm(widthMm); + const H = mm(heightMm); + const parts: string[] = []; + + for (const el of layout) { + if (el.type === 'text') { + const x = mm(el.x); + const fontSize = mm(el.fontSize); + const content = escape(applyVars(el.content, variables)); + const fontWeight = el.bold ? 'bold' : 'normal'; + const textAnchor = + el.align === 'center' ? 'middle' : el.align === 'right' ? 'end' : 'start'; + const maxWidthAttr = el.maxWidth + ? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"` + : ''; + // Wichtig: librsvg ignoriert dominant-baseline. + // Y-Koordinate ist die Oberkante des Texts → fontSize addieren für Baseline. + const yBaseline = mm(el.y) + fontSize; + parts.push( + `${content}`, + ); + + } else if (el.type === 'qr') { + const size = mm(el.sizeMm); + const content = applyVars(el.content, variables); + const qrBuffer = await QRCode.toBuffer(content, { + type: 'png', + margin: 0, + width: size, + errorCorrectionLevel: 'M', + }); + const b64 = qrBuffer.toString('base64'); + parts.push( + ``, + ); + + } else if (el.type === 'line') { + const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1; + parts.push( + ``, + ); + } + } + + const svg = [ + ``, + ` `, + ` ${parts.join('\n ')}`, + ``, + ].join('\n'); + + const resvg = new Resvg(svg, { + font: { + loadSystemFonts: true, + fontDirs: ['/usr/share/fonts', '/usr/local/share/fonts'], + defaultFontFamily: 'Liberation Sans', + sansSerifFamily: 'Liberation Sans', + }, + }); + return Buffer.from(resvg.render().asPng()); +} +``` + +### Bekannte Eigenheiten + +- **Baseline-Problem:** `librsvg` (verwendet von `@resvg/resvg-js`) ignoriert `dominant-baseline`. Daher muss zur Y-Koordinate die Schriftgröße addiert werden, damit die **Oberkante** des Textes der eingegebenen Y-Position entspricht (s. `yBaseline`-Berechnung). +- **Schriften:** Auf Systemen ohne `Liberation Sans` (z.B. Windows) fällt die Bibliothek auf Arial zurück. Für plattformübergreifende Konsistenz können Schriften auch per Buffer eingebettet werden. +- **QR-Code:** `errorCorrectionLevel: 'M'` ist ein guter Kompromiss zwischen Fehlertoleranz und Dichte. + +--- + +## 5. Job Queue & SSE-Mechanismus + +**Originaldateien:** +- `paperless-backend/src/label-print-agent/label-print-agent.service.ts` +- `paperless-backend/src/label-print-agent/label-print-agent.controller.ts` + +### Job erstellen + +```typescript +// Originaldatei: label-print-agent.service.ts + +function applyVars(template: string, vars: Record): string { + return template.replace(/\{([^}]+)\}/g, (_, key: string) => { + const colonIdx = key.indexOf(':'); + if (colonIdx !== -1) { + const varName = key.slice(0, colonIdx); + const width = parseInt(key.slice(colonIdx + 1), 10); + if (!isNaN(width) && varName in vars) { + return vars[varName].padStart(width, '0'); + } + } + return vars[key] ?? ''; + }); +} + +async createJob(templateId: number, fieldValues: Record): Promise { + const template = await this.templateRepo.findOne({ where: { Id: templateId } }); + if (!template) throw new NotFoundException('Template nicht gefunden'); + + const vars: Record = { ...fieldValues }; + + // Datum-Felder aufsplitten: {datum} → dd.MM.yyyy, {datum.year}, {datum.month}, {datum.day} + for (const field of template.LabelInputFields ?? []) { + if (field.type === 'date' && fieldValues[field.name]) { + const parts = fieldValues[field.name].split('-'); // YYYY-MM-DD + if (parts.length === 3) { + vars[field.name] = `${parts[2]}.${parts[1]}.${parts[0]}`; + vars[`${field.name}.year`] = parts[0]; + vars[`${field.name}.month`] = parts[1]; + vars[`${field.name}.day`] = parts[2]; + } + } + } + + // Externe Nummern-Reservierung (optional) + if (template.LabelGetUrl) { + const url = applyVars(template.LabelGetUrl, vars); + const res = await fetch(url); + vars['number'] = (await res.text()).trim(); + } + + const job = this.jobRepo.create({ + Status: 'pending', + LabelImageData: null, // Lazy rendering: erst beim Claim rendern + LabelWidthMm: template.LabelWidthMm ?? 57, + LabelHeightMm: template.LabelHeightMm ?? 32, + BarcodeTemplateId: template.Id, + LabelVariables: vars, + LockedAt: null, + LockedByAgent: null, + }); + + const saved = await this.jobRepo.save(job); + this.jobCreated$.next(); // SSE-Notification an alle verbundenen Agents + return saved; +} +``` + +### Job beanspruchen (Optimistic Locking) + +```typescript +// Lock-Ablaufzeit: 5 Minuten in der Vergangenheit +function lockExpiry(): Date { + const d = new Date(); + d.setMinutes(d.getMinutes() - 5); + return d; +} + +async claimNextJob(agentId: string): Promise { + // Finde den ältesten pending Job ohne Lock oder mit abgelaufenem Lock + const candidate = await this.jobRepo.findOne({ + where: [ + { Status: 'pending', LockedAt: IsNull() }, + { Status: 'pending', LockedAt: LessThan(lockExpiry()) }, + ], + order: { CreatedAt: 'ASC' }, // FIFO + }); + if (!candidate) return null; + + // Lock setzen (optimistisch, kein DB-Row-Lock) + candidate.LockedAt = new Date(); + candidate.LockedByAgent = agentId; + await this.jobRepo.save(candidate); + + // Lazy Render: PNG erst jetzt erzeugen + if (!candidate.LabelImageData) { + const template = await this.templateRepo.findOne({ where: { Id: candidate.BarcodeTemplateId } }); + if (template?.LabelLayout?.length) { + candidate.LabelImageData = await this.renderer.render( + template.LabelLayout, + candidate.LabelWidthMm, + candidate.LabelHeightMm, + candidate.LabelVariables ?? {}, + ); + await this.jobRepo.save(candidate); + } + } + + return candidate; +} +``` + +### SSE-Stream (NestJS) + +```typescript +// Originaldatei: label-print-agent.controller.ts + +private readonly jobCreated$ = new Subject(); // im Service + +// Controller: +@Sse('events') +sseEvents(@Res({ passthrough: true }) res: Response): Observable { + res.setHeader('X-Accel-Buffering', 'no'); // nginx: kein Buffering + return this.service.newJob$.pipe( + map(() => ({ data: { type: 'label-job-available' } } as MessageEvent)), + ); +} +``` + +### SSE-Client (Agent-Seite, Plain JavaScript) + +```javascript +const evtSource = new EventSource('http://backend/api/label-print-agent/events', { + headers: { Authorization: `Bearer ${token}` }, +}); + +evtSource.onmessage = async (event) => { + const data = JSON.parse(event.data); + if (data.type === 'label-job-available') { + await pollAndPrint(); + } +}; + +// Fallback: alle 30 Sekunden pollen +setInterval(pollAndPrint, 30_000); + +async function pollAndPrint() { + const res = await fetch(`http://backend/api/label-print-agent/jobs/next?agentId=MEIN-PC`); + if (res.status === 204) return; // kein Job + + const job = await res.json(); + const imageBytes = Buffer.from(job.labelImageBase64, 'base64'); + + try { + await printToPrinter(imageBytes, job.labelWidthMm, job.labelHeightMm); + await fetch(`http://backend/api/label-print-agent/jobs/${job.jobId}/printed`, { + method: 'POST', + body: JSON.stringify({ agentId: 'MEIN-PC', printerName: 'DYMO 450' }), + headers: { 'Content-Type': 'application/json' }, + }); + } catch (err) { + await fetch(`http://backend/api/label-print-agent/jobs/${job.jobId}/error`, { + method: 'POST', + body: JSON.stringify({ agentId: 'MEIN-PC', errorMessage: String(err) }), + headers: { 'Content-Type': 'application/json' }, + }); + } +} +``` + +--- + +## 6. Variablen-System + +### Template-Syntax + +| Syntax | Bedeutung | Beispiel | +|---|---|---| +| `{feldname}` | Wert des Feldes | `{name}` → `Müller GmbH` | +| `{feldname:6}` | Zero-padded auf N Stellen | `{nr:6}` → `000042` | +| `{datum}` (date-Feld) | Formatiert als `dd.MM.yyyy` | `{datum}` → `15.05.2026` | +| `{datum.year}` | Nur das Jahr | `{datum.year}` → `2026` | +| `{datum.month}` | Nur den Monat | `{datum.month}` → `05` | +| `{datum.day}` | Nur den Tag | `{datum.day}` → `15` | +| `{number}` | Reservierte Nummer (via GetUrl) | `{number}` → `1234` | + +### URL-Templates + +Die Felder `LabelGetUrl`, `LabelPrintedUrl`, `LabelReleaseUrl` unterstützen ebenfalls Variablen-Substitution: + +``` +LabelGetUrl: https://api.example.com/numbers/next?batch={batch} +LabelPrintedUrl: https://api.example.com/numbers/{number}/confirm +LabelReleaseUrl: https://api.example.com/numbers/{number}/release +``` + +**Sicherheitscheck:** Nur `http://` und `https://` werden akzeptiert. + +--- + +## 7. Vollständiger Job-Lebenszyklus + +``` +Status: pending → (Lock gesetzt) → printed / error + +┌─ createJob() ──────────────────────────────────────────────────┐ +│ Template laden → Vars aufbauen → GetUrl aufrufen (optional) │ +│ Job speichern: Status=pending, LockedAt=null, imageData=null │ +│ newJob$.next() → SSE-Push │ +└─────────────────────────────────────────────────────────────────┘ + +┌─ claimNextJob() ───────────────────────────────────────────────┐ +│ Kandidat suchen: pending AND (LockedAt IS NULL OR < -5min) │ +│ Lock setzen: LockedAt=now, LockedByAgent=agentId │ +│ Lazy Render: Layout + Vars → SVG → PNG → LabelImageData │ +│ Rückgabe: Job + PNG (base64) │ +└─────────────────────────────────────────────────────────────────┘ + +┌─ markPrinted() ────────────────────────────────────────────────┐ +│ Status = printed, PrintedAt = now, PrintedByAgent, Printer │ +│ POST LabelPrintedUrl (optional, Variablen aufgelöst) │ +└─────────────────────────────────────────────────────────────────┘ + +┌─ markError() ──────────────────────────────────────────────────┐ +│ Status = error, ErrorMessage │ +│ POST LabelReleaseUrl (optional) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Lock-TTL:** 5 Minuten. Stürzt ein Agent ab, kann ein anderer Agent (oder derselbe nach Neustart) den Job nach 5 Minuten erneut beanspruchen. + +--- + +## 8. NestJS-Modul-Setup + +```typescript +// label-print-agent.module.ts +@Module({ + imports: [ + TypeOrmModule.forFeature([LabelPrintJob, LabelTemplate]), + ], + controllers: [LabelPrintAgentController], + providers: [LabelPrintAgentService, LabelRendererService], +}) +export class LabelPrintAgentModule {} +``` + +--- + +## 9. Empfehlungen für das neue Projekt + +### Architektur übernehmen + +Das Muster **DB als Queue + SSE als Push + Polling als Fallback** ist einfach und robust. Es benötigt keine externe Message-Queue (Redis, RabbitMQ etc.) und funktioniert gut für geringe bis mittlere Job-Volumina. + +### Locking-Strategie + +| Szenario | Empfehlung | +|---|---| +| Ein Agent | Optimistic Lock (5-Min-TTL) reicht aus | +| Mehrere Agents, wenige Jobs | Optimistic Lock reicht aus | +| Viele Agents, hoher Durchsatz | DB-Row-Lock (`SELECT ... FOR UPDATE`) oder Redis-Lock | + +### Rendering-Alternativen + +| Umgebung | Alternative | +|---|---| +| Node.js | `@resvg/resvg-js` + `qrcode` (wie hier) | +| Node.js (Docker) | Puppeteer (HTML → PNG/PDF, braucht Chrome) | +| Python | `reportlab` oder `cairosvg` | +| Kein Node.js | Server-seitiges Rendering via separatem Microservice | + +### SSE-Alternativen + +| Alternative | Wann | +|---|---| +| SSE (wie hier) | Einfachste Lösung, funktioniert durch nginx | +| WebSocket | Wenn bidirektionale Kommunikation nötig ist | +| Polling-only | Wenn der Agent kein persistentes HTTP unterstützt | + +### Template-Verwaltung im neuen Projekt + +Im Paperless Manager sind Label-Templates Teil der `BarcodeTemplate`-Entity (historisch gewachsen). Für ein neues Projekt empfiehlt sich eine **eigenständige `LabelTemplate`-Entity** ohne nicht-Label-relevante Felder — das vereinfacht CRUD und API erheblich. + +--- + +## 10. Quell-Dateien (Paperless Manager) + +| Datei | Inhalt | +|---|---| +| `paperless-backend/src/label-print-agent/label-print-agent.service.ts` | Job-Lifecycle, Locking, Callbacks | +| `paperless-backend/src/label-print-agent/label-print-agent.controller.ts` | REST-Endpunkte, SSE | +| `paperless-backend/src/label-print-agent/label-renderer.service.ts` | SVG→PNG Rendering | +| `paperless-backend/src/database/entities/label-print-job.entity.ts` | Job-Entity | +| `paperless-backend/src/database/entities/barcode-template.entity.ts` | Template-Entity (inkl. `LabelElement`, `LabelInputField` Typen) | +| `paperless-frontend/src/api/labelPrintAgent.ts` | Frontend-API-Client | + +--- + +## 11. Frontend-UI: Template-Editor (SettingsPage) + +**Originaldatei:** `paperless-frontend/src/pages/SettingsPage.tsx` + +Der Template-Editor ist ein **Modal-Formular** (720px breit) mit Ant Design. Er enthält zwei eigenständige React-Komponenten. + +### Komponenten-Übersicht + +| Komponente | Zweck | +|---|---| +| `BarcodeTemplatesTab` | Tabelle mit allen Templates + CRUD-Logik | +| `LabelElementRow` | Eine Zeile im Layout-Editor (typ-abhängige Felder) | + +--- + +### BarcodeTemplatesTab + +Verwaltet den gesamten Template-Lebenszyklus. State: + +```typescript +const [data, setData] = useState([]); +const [editing, setEditing] = useState(null); +const [previewUrl, setPreviewUrl] = useState(null); +const [testPrinting, setTestPrinting] = useState(false); +const [form] = Form.useForm(); +``` + +**Formular-Initialwerte** (beim Neu-Anlegen): + +```typescript +form.setFieldsValue({ + SplitBefore: false, + DateinameTemplate: '', + LabelEnabled: false, + LabelWidthMm: 57, // Standard: DYMO 57mm + LabelHeightMm: 32, + LabelInputFields: [], + LabelLayout: [], +}); +``` + +**Testetikett-Funktion** (`handleTestLabel`): + +```typescript +const handleTestLabel = async () => { + const values = await form.validateFields(); + await barcodeTemplatesApi.update(editing.Id, values); // erst speichern + + // Auto-Testwerte für alle Felder generieren + const testFieldValues: Record = {}; + const today = new Date().toISOString().slice(0, 10); + for (const f of values.LabelInputFields ?? []) { + if (f.type === 'date') testFieldValues[f.name] = today; + else if (f.type === 'number') testFieldValues[f.name] = '1'; + else testFieldValues[f.name] = 'Test'; + } + + const url = await labelPrintAgentApi.previewLabel(editing.Id, testFieldValues); + setPreviewUrl(url); // öffnet zweites Modal mit PNG +}; +``` + +**Modal-Footer-Buttons:** + +``` +[Testetikett erstellen] [Abbrechen] [Speichern] + ↑ + Nur sichtbar wenn bestehende Vorlage (editing && !isNew) +``` + +--- + +### Formular-Aufbau (innerhalb des Modals) + +``` +┌─ Name ────────────────────────────────────────────────────────┐ +│ Input: "z. B. Lager-Etikett" (required) │ +└───────────────────────────────────────────────────────────────┘ +┌─ Regex ───────────────────────────────────────────────────────┐ +│ Input mit Regex-Validator │ +│ Extra: "JavaScript-Syntax, z. B. ^\d{4}-\d{6}-\d{8}$" │ +└───────────────────────────────────────────────────────────────┘ +┌─ Regex testen ────────────────────────────────────────────────┐ +│ Input (live) → Tag: [Treffer] / [Kein Treffer] / [Ungültig] │ +└───────────────────────────────────────────────────────────────┘ +┌─ Checkbox: "Vor diesem Barcode ein neues Dokument starten" ───┐ +└───────────────────────────────────────────────────────────────┘ +┌─ Belegname ───────────────────────────────────────────────────┐ +│ Input: Platzhalter {barcode}, {datum}, {barcode.gruppe} │ +└───────────────────────────────────────────────────────────────┘ + +────────────── Etikett ────────────── + +┌─ Switch: "Etikett-Druck aktivieren" ─────────────────────────┐ +└───────────────────────────────────────────────────────────────┘ + + [Nur sichtbar wenn Switch aktiv] + ┌─ Breite (mm) │ Höhe (mm) ─────────────────────────────────┐ + │ InputNumber │ InputNumber (Standard: 57 / 32) │ + └──────────────────────────────────────────────────────────────┘ + ┌─ Eingabefelder ─────────────────────────────────────────────┐ + │ Form.List: je Zeile: │ + │ [Feldname Input] [Bezeichnung Input] [Typ-Select] [−] │ + │ Typen: text | number | date │ + │ [+ Feld hinzufügen] │ + └──────────────────────────────────────────────────────────────┘ + ┌─ GET-URL ───────────────────────────────────────────────────┐ + │ Extra zeigt verfügbare Platzhalter live aus Eingabefeldern │ + └──────────────────────────────────────────────────────────────┘ + ┌─ PRINTED-URL ───────────────────────────────────────────────┐ + │ Extra: Platzhalter + {number} │ + └──────────────────────────────────────────────────────────────┘ + ┌─ RELEASE-URL ───────────────────────────────────────────────┐ + │ Extra: Platzhalter + {number} │ + └──────────────────────────────────────────────────────────────┘ + ┌─ Layout-Elemente ───────────────────────────────────────────┐ + │ Extra: verfügbare Platzhalter │ + │ Form.List → je Element: LabelElementRow-Komponente │ + │ [+ Text] [+ QR-Code] [+ Linie] │ + └──────────────────────────────────────────────────────────────┘ +``` + +**Platzhalter-Chips werden live berechnet:** + +```typescript +// Aus den aktuellen LabelInputFields dynamisch erzeugt +const chips: string[] = []; +for (const f of inputFields) { + chips.push(`{${f.name}}`); + if (f.type === 'date') { + chips.push(`{${f.name}.year}`, `{${f.name}.month}`, `{${f.name}.day}`); + } +} +const chipsWithNumber = [...chips, '{number}']; +// → wird als `extra`-Text bei URL-Feldern und Layout-Elementen angezeigt +``` + +--- + +### LabelElementRow (je Layout-Element eine Card) + +Typ wird per `Form.useWatch` reaktiv ausgelesen → zeigt typ-spezifische Felder: + +```typescript +const type = Form.useWatch(['LabelLayout', listName, 'type'], form); +``` + +**Typ: Text** + +``` +[X mm] [Y mm] [Schrift (mm)] [Max. Breite (mm)] [Fett ☐] [Ausrichtung ▼] +[Inhalt: Input mit Platzhalter-Hinweis "{nummer} oder {datum}"] +``` + +Default beim Hinzufügen: `{ type: 'text', x: 0, y: 0, fontSize: 3, content: '', bold: false }` + +**Typ: QR-Code** + +``` +[X mm] [Y mm] [Größe (mm)] [Inhalt: Input mit "{number}"] +``` + +Default: `{ type: 'qr', x: 0, y: 0, sizeMm: 20, content: '' }` + +**Typ: Linie** + +``` +[X1 mm] [Y1 mm] [X2 mm] [Y2 mm] [Stärke (mm, step: 0.1)] +``` + +Default: `{ type: 'line', x1: 0, y1: 0, x2: 50, y2: 0 }` + +Jede Card hat rechts oben einen Löschen-Button (`DeleteOutlined`). + +--- + +### Vorschau-Modal + +Nach „Testetikett erstellen" öffnet sich ein zweites Modal (520px) mit dem gerenderten PNG: + +```tsx + +
+ +
+
+``` + +Das `previewUrl` ist ein `URL.createObjectURL(blob)` — muss mit `URL.revokeObjectURL()` beim Schließen freigegeben werden. + +--- + +## 12. Frontend-UI: Druck-Dialog (InboxPage) + +**Originaldatei:** `paperless-frontend/src/pages/InboxPage.tsx` + +### Einstiegspunkt + +Button in der Toolbar der InboxPage: + +```tsx + +``` + +### openPrintDialog + +Lädt beim Öffnen parallel Templates und User-Settings (für Standard-Template): + +```typescript +const openPrintDialog = async () => { + const [all, settings] = await Promise.all([ + barcodeTemplatesApi.list(), + userSettingsApi.get(), + ]); + const enabled = all.filter((t) => t.LabelEnabled); // nur aktive Templates + setLabelTemplates(enabled); + + // Standard-Template aus User-Settings vorauswählen + const defaultId = settings.defaultLabelTemplateId; + const defaultTemplate = defaultId != null + ? (enabled.find((t) => t.Id === defaultId) ?? null) + : null; + setSelectedTemplate(defaultTemplate); + setFieldValues(buildInitialFieldValues(defaultTemplate)); + setLabelCount(1); + setPrintDialogOpen(true); +}; +``` + +### Datum-Vorausfüllung + +```typescript +function buildInitialFieldValues(template: BarcodeTemplate | null): Record { + const today = dayjs().format('YYYY-MM-DD'); + const values: Record = {}; + for (const field of template?.LabelInputFields ?? []) { + if (field.type === 'date') values[field.name] = today; + // text- und number-Felder bleiben leer + } + return values; +} +``` + +### Druck-Modal-Aufbau + +``` +┌─ Eingangsdokumentart ─────────────────────────────────────────┐ +│ Select → nur Templates mit LabelEnabled=true │ +└───────────────────────────────────────────────────────────────┘ +┌─ Anzahl Etiketten ────────────────────────────────────────────┐ +│ InputNumber (min: 1, max: 100, default: 1) │ +└───────────────────────────────────────────────────────────────┘ +┌─ [dynamisch: LabelInputFields des gewählten Templates] ────────┐ +│ date → DatePicker (Format: DD.MM.YYYY, Wert: YYYY-MM-DD) │ +│ number → InputNumber │ +│ text → Input │ +└───────────────────────────────────────────────────────────────┘ + +Footer: [Abbrechen] [Drucken] ← disabled solange kein Template +``` + +### Batch-Druck + +```typescript +const handlePrint = async () => { + setPrinting(true); + for (let i = 0; i < labelCount; i++) { + await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues); + // Erzeugt N separate Jobs sequenziell (selbe fieldValues) + } + message.success(labelCount === 1 ? 'Druckauftrag erstellt' : `${labelCount} Druckaufträge erstellt`); +}; +``` + +--- + +## 13. Frontend API-Client + +**Originaldatei:** `paperless-frontend/src/api/labelPrintAgent.ts` + +```typescript +export const labelPrintAgentApi = { + // Job erstellen (von InboxPage) + createJob: (templateId: number, fieldValues: Record) => + api.post('/api/label-print-agent/jobs', { templateId, fieldValues }), + + // Vorschau-PNG als Object-URL (von SettingsPage) + previewLabel: async (templateId: number, fieldValues: Record): Promise => { + const res = await api.post('/api/label-print-agent/preview', + { templateId, fieldValues }, + { responseType: 'blob' }, + ); + return URL.createObjectURL(res.data); // muss nach Verwendung revoked werden + }, +}; +``` diff --git a/paperless-backend/package-lock.json b/paperless-backend/package-lock.json index 141a35e..21116f7 100644 --- a/paperless-backend/package-lock.json +++ b/paperless-backend/package-lock.json @@ -20,7 +20,7 @@ "@types/form-data": "^2.2.1", "@types/passport-jwt": "^4.0.1", "@types/uuid": "^10.0.0", - "agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#cd89a30", + "agrarmonitor-connector": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git", "axios": "^1.14.0", "basic-ftp": "^5.2.1", "chokidar": "^4.0.3", @@ -4862,7 +4862,7 @@ }, "node_modules/agrarmonitor-connector": { "version": "0.1.0", - "resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#cd89a3063144a4f7746c9946d1e4f888f8f0f8b4", + "resolved": "git+https://gitea.poettker-cloud.de/bjoernpoettker/AgrarmonitorConnector.git#5cb93b3258bd013024013d76ee922e5f8b89244a", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 49e5fa6..9e0cdab 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -43,9 +43,11 @@ export class AgrarmonitorPollingService implements OnModuleInit { constructor( private readonly agrarmonitorService: AgrarmonitorService, private readonly paperlessService: PaperlessService, - @InjectRepository(Setting) private readonly settingRepo: Repository, + @InjectRepository(Setting) + private readonly settingRepo: Repository, @InjectRepository(Client) private readonly clientRepo: Repository, - @InjectRepository(CorrespondentSetting) private readonly corrSettingRepo: Repository, + @InjectRepository(CorrespondentSetting) + private readonly corrSettingRepo: Repository, ) {} async onModuleInit() { @@ -59,23 +61,34 @@ export class AgrarmonitorPollingService implements OnModuleInit { @Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *') async scheduledPolling() { if (!process.env['AGRARMONITOR_POLLING_CRON']) return; - this.runPolling().catch((err) => this.logger.error('Cron-Polling-Fehler:', err)); + this.runPolling().catch((err) => + this.logger.error('Cron-Polling-Fehler:', err), + ); } @Cron(process.env['AGRARMONITOR_UPLOAD_CHECK_CRON'] || '0 * * * * *') async scheduledUploadCheck() { if (!process.env['AGRARMONITOR_UPLOAD_CHECK_CRON']) return; - this.processVerarbeiteteDocuments().catch((err) => this.logger.error('Cron-Upload-Check-Fehler:', err)); + this.processVerarbeiteteDocuments().catch((err) => + this.logger.error('Cron-Upload-Check-Fehler:', err), + ); } - async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string; tagManuell: string }> { - const [fertig, verbucht, hochgeladen, linkField, manuell] = await Promise.all([ - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }), - ]); + async getPollingConfig(): Promise<{ + tagFertig: string; + tagVerbucht: string; + tagHochgeladen: string; + linkField: string; + tagManuell: string; + }> { + const [fertig, verbucht, hochgeladen, linkField, manuell] = + await Promise.all([ + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }), + ]); return { tagFertig: fertig?.Wert ?? '4', tagVerbucht: verbucht?.Wert ?? '9', @@ -91,13 +104,34 @@ export class AgrarmonitorPollingService implements OnModuleInit { tagHochgeladen: string, linkField: string, tagManuell: string, - ): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string; tagManuell: string }> { + ): Promise<{ + tagFertig: string; + tagVerbucht: string; + tagHochgeladen: string; + linkField: string; + tagManuell: string; + }> { await Promise.all([ - this.settingRepo.update({ Tag: 'agrarmonitor_tag_fertig' }, { Wert: tagFertig }), - this.settingRepo.update({ Tag: 'agrarmonitor_tag_verbucht' }, { Wert: tagVerbucht }), - this.settingRepo.update({ Tag: 'agrarmonitor_tag_hochgeladen' }, { Wert: tagHochgeladen }), - this.settingRepo.update({ Tag: 'agrarmonitor_link_field' }, { Wert: linkField }), - this.settingRepo.update({ Tag: 'agrarmonitor_tag_manuell' }, { Wert: tagManuell }), + this.settingRepo.update( + { Tag: 'agrarmonitor_tag_fertig' }, + { Wert: tagFertig }, + ), + this.settingRepo.update( + { Tag: 'agrarmonitor_tag_verbucht' }, + { Wert: tagVerbucht }, + ), + this.settingRepo.update( + { Tag: 'agrarmonitor_tag_hochgeladen' }, + { Wert: tagHochgeladen }, + ), + this.settingRepo.update( + { Tag: 'agrarmonitor_link_field' }, + { Wert: linkField }, + ), + this.settingRepo.update( + { Tag: 'agrarmonitor_tag_manuell' }, + { Wert: tagManuell }, + ), ]); return { tagFertig, tagVerbucht, tagHochgeladen, linkField, tagManuell }; } @@ -105,11 +139,21 @@ export class AgrarmonitorPollingService implements OnModuleInit { async runPolling(): Promise { if (this.pollingRunning) { this.logger.warn('Polling läuft bereits, überspringe'); - return { processed: 0, updated: 0, skipped: 0, errors: ['Polling bereits aktiv'] }; + return { + processed: 0, + updated: 0, + skipped: 0, + errors: ['Polling bereits aktiv'], + }; } this.pollingRunning = true; - const result: PollingResult = { processed: 0, updated: 0, skipped: 0, errors: [] }; + const result: PollingResult = { + processed: 0, + updated: 0, + skipped: 0, + errors: [], + }; this.logger.log('Starte Agrarmonitor-Polling'); try { @@ -126,7 +170,9 @@ export class AgrarmonitorPollingService implements OnModuleInit { return { ...result, errors: [msg] }; } - let amClient: Awaited>; + let amClient: Awaited< + ReturnType + >; try { amClient = await this.agrarmonitorService.getClient(); } catch (err: unknown) { @@ -150,7 +196,9 @@ export class AgrarmonitorPollingService implements OnModuleInit { try { await this.getOrCreateCorrespondent(customer, Number(customer.id)); } catch (err: unknown) { - this.logger.warn(`Korrespondenten-Sync fehlgeschlagen: ${err instanceof Error ? err.message : err}`); + this.logger.warn( + `Korrespondenten-Sync fehlgeschlagen: ${err instanceof Error ? err.message : err}`, + ); } } @@ -162,7 +210,9 @@ export class AgrarmonitorPollingService implements OnModuleInit { }); const docs: any[] = docsResponse?.results ?? []; if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) { - this.logger.warn(`Mehr als ${DOCS_PAGE_SIZE} Dokumente bereit — nur erste ${DOCS_PAGE_SIZE} werden verarbeitet`); + this.logger.warn( + `Mehr als ${DOCS_PAGE_SIZE} Dokumente bereit — nur erste ${DOCS_PAGE_SIZE} werden verarbeitet`, + ); } this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`); @@ -170,20 +220,25 @@ export class AgrarmonitorPollingService implements OnModuleInit { result.processed++; const interneBelegnummer = - ((doc.custom_fields as any[]) ?? []).find( + (((doc.custom_fields as any[]) ?? []).find( (cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID, - )?.value as string ?? ''; + )?.value as string) ?? ''; if (!interneBelegnummer) { - this.logger.log(`Dokument ${doc.id as number} hat keine interne Belegnummer`); + this.logger.log( + `Dokument ${doc.id as number} hat keine interne Belegnummer`, + ); result.skipped++; await this.delay(500); continue; } - let amResults: Awaited>; + let amResults: Awaited< + ReturnType + >; try { - amResults = await amClient.eingangsrechnungenLivesearch(interneBelegnummer); + amResults = + await amClient.eingangsrechnungenLivesearch(interneBelegnummer); } catch (err: unknown) { const status = (err as any)?.response?.status; if (status === 401 || status === 403) { @@ -194,14 +249,18 @@ export class AgrarmonitorPollingService implements OnModuleInit { break; } const msg = `${interneBelegnummer}: Livesearch-Fehler`; - this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `${msg}: ${err instanceof Error ? err.message : err}`, + ); result.errors.push(msg); await this.delay(500); continue; } if (amResults.length === 0) { - this.logger.log(`${interneBelegnummer} nicht in Agrarmonitor gefunden`); + this.logger.log( + `${interneBelegnummer} nicht in Agrarmonitor gefunden`, + ); result.skipped++; await this.delay(500); continue; @@ -219,9 +278,14 @@ export class AgrarmonitorPollingService implements OnModuleInit { if (!amDoc.interneBelegNummer && interneBelegnummer) { try { - await amClient.setLieferscheinNummer(amDoc.eingangId, interneBelegnummer); + await amClient.setLieferscheinNummer( + amDoc.eingangId, + interneBelegnummer, + ); } catch (err: unknown) { - this.logger.warn(`${interneBelegnummer}: Lieferscheinnummer setzen fehlgeschlagen: ${err instanceof Error ? err.message : err}`); + this.logger.warn( + `${interneBelegnummer}: Lieferscheinnummer setzen fehlgeschlagen: ${err instanceof Error ? err.message : err}`, + ); } } @@ -233,17 +297,24 @@ export class AgrarmonitorPollingService implements OnModuleInit { const eingangsdatum = new Date(eingangsdatumField.value as string); if (!isNaN(eingangsdatum.getTime())) { await amClient.setEingangsdatum(amDoc.eingangId, eingangsdatum); - this.logger.log(`Eingangsdatum für ${interneBelegnummer} gesetzt`); + this.logger.log( + `Eingangsdatum für ${interneBelegnummer} gesetzt`, + ); } } - } - + } + if (amDoc.buchungsDatum) { try { let correspondentId: number | undefined; - const customer = customers.find((c) => Number(c.id) === amDoc.kundenId); + const customer = customers.find( + (c) => Number(c.id) === amDoc.kundenId, + ); if (customer) { - const corr = await this.getOrCreateCorrespondent(customer, amDoc.kundenId); + const corr = await this.getOrCreateCorrespondent( + customer, + amDoc.kundenId, + ); if (corr) correspondentId = corr.id as number; } @@ -254,28 +325,40 @@ export class AgrarmonitorPollingService implements OnModuleInit { if (matchedClient) ownerId = matchedClient.PaperlessUserId; const currentTags: number[] = (doc.tags as number[]) ?? []; - const newTags = [...new Set(currentTags.filter((t) => t !== tagFertigId).concat([tagVerbuchtId]))]; + const newTags = [ + ...new Set( + currentTags + .filter((t) => t !== tagFertigId) + .concat([tagVerbuchtId]), + ), + ]; const updateData: Record = { tags: newTags }; - if (correspondentId !== undefined) updateData.correspondent = correspondentId; + if (correspondentId !== undefined) + updateData.correspondent = correspondentId; if (ownerId !== undefined) updateData.owner = ownerId; - await this.paperlessService.updateDocument(doc.id as number, updateData); + await this.paperlessService.updateDocument( + doc.id as number, + updateData, + ); this.logger.log(`Beleg ${interneBelegnummer} gebucht`); result.updated++; } catch (err: unknown) { const msg = `${interneBelegnummer}: Update-Fehler`; - this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `${msg}: ${err instanceof Error ? err.message : err}`, + ); result.errors.push(msg); } - } + } await this.delay(500); } this.logger.log( `Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ` + - `${result.skipped} übersprungen, ${result.errors.length} Fehler`, + `${result.skipped} übersprungen, ${result.errors.length} Fehler`, ); } finally { this.pollingRunning = false; @@ -287,15 +370,30 @@ export class AgrarmonitorPollingService implements OnModuleInit { async processVerarbeiteteDocuments(): Promise { if (this.uploadCheckRunning) { this.logger.warn('Upload-Check läuft bereits, überspringe'); - return { processed: 0, updated: 0, skipped: 0, errors: ['Upload-Check bereits aktiv'] }; + return { + processed: 0, + updated: 0, + skipped: 0, + errors: ['Upload-Check bereits aktiv'], + }; } this.uploadCheckRunning = true; - const result: PollingResult = { processed: 0, updated: 0, skipped: 0, errors: [] }; + const result: PollingResult = { + processed: 0, + updated: 0, + skipped: 0, + errors: [], + }; this.logger.log('Starte Upload-Check'); try { - const [hochgeladenSetting, fertigSetting, linkFieldSetting, manuellSetting] = await Promise.all([ + const [ + hochgeladenSetting, + fertigSetting, + linkFieldSetting, + manuellSetting, + ] = await Promise.all([ this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), @@ -308,11 +406,15 @@ export class AgrarmonitorPollingService implements OnModuleInit { const tagManuellId = parseInt(manuellSetting?.Wert ?? '', 10); if (isNaN(tagHochgeladenId)) { - this.logger.warn('Tag "hochgeladen" nicht konfiguriert — Upload-Check übersprungen'); + this.logger.warn( + 'Tag "hochgeladen" nicht konfiguriert — Upload-Check übersprungen', + ); return { ...result, errors: ['Tag "hochgeladen" nicht konfiguriert'] }; } - let amClient: Awaited>; + let amClient: Awaited< + ReturnType + >; try { amClient = await this.agrarmonitorService.getClient(); } catch (err: unknown) { @@ -329,20 +431,26 @@ export class AgrarmonitorPollingService implements OnModuleInit { }); const docs: any[] = docsResponse?.results ?? []; if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) { - this.logger.warn(`Mehr als ${DOCS_PAGE_SIZE} Dokumente hochgeladen — nur erste ${DOCS_PAGE_SIZE} werden geprüft`); + this.logger.warn( + `Mehr als ${DOCS_PAGE_SIZE} Dokumente hochgeladen — nur erste ${DOCS_PAGE_SIZE} werden geprüft`, + ); } - this.logger.log(`${docs.length} Dokumente laut Paperless im Dateieingang`); + this.logger.log( + `${docs.length} Dokumente laut Paperless im Dateieingang`, + ); for (const doc of docs) { result.processed++; const interneBelegnummer = - ((doc.custom_fields as any[]) ?? []).find( + (((doc.custom_fields as any[]) ?? []).find( (cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID, - )?.value as string ?? ''; + )?.value as string) ?? ''; if (!interneBelegnummer) { - this.logger.log(`Dokument ${doc.id as number} hat keine interne Belegnummer`); + this.logger.log( + `Dokument ${doc.id as number} hat keine interne Belegnummer`, + ); result.skipped++; await this.delay(500); continue; @@ -350,7 +458,8 @@ export class AgrarmonitorPollingService implements OnModuleInit { let vorhanden: boolean; try { - vorhanden = await amClient.eingangsrechnungVorhanden(interneBelegnummer); + vorhanden = + await amClient.eingangsrechnungVorhanden(interneBelegnummer); } catch (err: unknown) { const status = (err as any)?.response?.status; if (status === 401 || status === 403) { @@ -361,7 +470,9 @@ export class AgrarmonitorPollingService implements OnModuleInit { break; } const msg = `${interneBelegnummer}: Vorhanden-Check fehlgeschlagen`; - this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `${msg}: ${err instanceof Error ? err.message : err}`, + ); result.errors.push(msg); await this.delay(500); continue; @@ -371,7 +482,10 @@ export class AgrarmonitorPollingService implements OnModuleInit { // Prüfen ob Beleg noch im Dateieingang von Agrarmonitor liegt let imDateieingang: boolean; try { - imDateieingang = await amClient.eingangsrechnungImDateieingangVorhanden(interneBelegnummer); + imDateieingang = + await amClient.eingangsrechnungImDateieingangVorhanden( + interneBelegnummer, + ); } catch (err: unknown) { const status = (err as any)?.response?.status; if (status === 401 || status === 403) { @@ -383,7 +497,9 @@ export class AgrarmonitorPollingService implements OnModuleInit { } // Bei Fehler vorsichtig: nicht verschieben const msg = `${interneBelegnummer}: Dateieingang-Check fehlgeschlagen`; - this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `${msg}: ${err instanceof Error ? err.message : err}`, + ); result.errors.push(msg); await this.delay(500); continue; @@ -399,9 +515,19 @@ export class AgrarmonitorPollingService implements OnModuleInit { // Weder verbucht noch im Dateieingang → Tags "Manuell bearbeiten" + "Von AM zurück" setzen if (!isNaN(tagManuellId)) { const currentTags: number[] = (doc.tags as number[]) ?? []; - const newTags = [...new Set(currentTags.filter(t => t !== tagHochgeladenId).concat([tagManuellId, 19]))]; - await this.paperlessService.updateDocument(doc.id as number, { tags: newTags }); - this.logger.log(`${interneBelegnummer} nicht mehr in Agrarmonitor — als manuell bearbeiten markiert`); + const newTags = [ + ...new Set( + currentTags + .filter((t) => t !== tagHochgeladenId) + .concat([tagManuellId, 19]), + ), + ]; + await this.paperlessService.updateDocument(doc.id as number, { + tags: newTags, + }); + this.logger.log( + `${interneBelegnummer} nicht mehr in Agrarmonitor — als manuell bearbeiten markiert`, + ); result.updated++; } else { result.skipped++; @@ -410,11 +536,16 @@ export class AgrarmonitorPollingService implements OnModuleInit { continue; } - this.logger.log(`Dokument ${interneBelegnummer} ist bereits verarbeitet, aktualisiere Paperless`); + this.logger.log( + `Dokument ${interneBelegnummer} ist bereits verarbeitet, aktualisiere Paperless`, + ); - let amResults: Awaited>; + let amResults: Awaited< + ReturnType + >; try { - amResults = await amClient.eingangsrechnungenLivesearch(interneBelegnummer); + amResults = + await amClient.eingangsrechnungenLivesearch(interneBelegnummer); } catch (err: unknown) { const status = (err as any)?.response?.status; if (status === 401 || status === 403) { @@ -425,14 +556,18 @@ export class AgrarmonitorPollingService implements OnModuleInit { break; } const msg = `${interneBelegnummer}: Livesearch-Fehler`; - this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `${msg}: ${err instanceof Error ? err.message : err}`, + ); result.errors.push(msg); await this.delay(500); continue; } if (amResults.length > 1) { - this.logger.log(`Dokument ${interneBelegnummer} ist doppelt vorhanden`); + this.logger.log( + `Dokument ${interneBelegnummer} ist doppelt vorhanden`, + ); result.skipped++; await this.delay(500); continue; @@ -443,29 +578,49 @@ export class AgrarmonitorPollingService implements OnModuleInit { try { // Kundendaten abrufen const customer = await amClient.getCustomerById(amDoc.kundenId); - const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; + const lieferantennummer = + (customer['lieferantennummer'] as string) ?? ''; if (!lieferantennummer) { - this.logger.log(`Kunde ${amDoc.kundenId} hat keine Lieferantennummer — Dokument wird übersprungen`); + this.logger.log( + `Kunde ${amDoc.kundenId} hat keine Lieferantennummer — Dokument wird übersprungen`, + ); result.skipped++; await this.delay(500); continue; } // Korrespondent ermitteln oder anlegen - const corr = await this.getOrCreateCorrespondent(customer, amDoc.kundenId); + const corr = await this.getOrCreateCorrespondent( + customer, + amDoc.kundenId, + ); // Owner aus Client-Tabelle let ownerId: number | undefined; - const matchedClient = await this.clientRepo.findOneBy({ AgrarmonitorBetriebId: amDoc.betriebId }); + const matchedClient = await this.clientRepo.findOneBy({ + AgrarmonitorBetriebId: amDoc.betriebId, + }); if (matchedClient) ownerId = matchedClient.PaperlessUserId; // Tags: hochgeladen entfernen, fertig hinzufügen const currentTags: number[] = (doc.tags as number[]) ?? []; - const newTags = [...new Set(currentTags.filter((t) => t !== tagHochgeladenId).concat([tagFertigId]))]; + const newTags = [ + ...new Set( + currentTags + .filter((t) => t !== tagHochgeladenId) + .concat([tagFertigId]), + ), + ]; // Custom fields aufbauen: bestehende behalten, extern + link setzen - const existingFields: any[] = ((doc.custom_fields as any[]) ?? []).map((f: any) => ({ ...f })); - this.setCustomField(existingFields, EXTERN_BELEGNUMMER_FIELD_ID, amDoc.belegNummer); + const existingFields: any[] = ( + (doc.custom_fields as any[]) ?? [] + ).map((f: any) => ({ ...f })); + this.setCustomField( + existingFields, + EXTERN_BELEGNUMMER_FIELD_ID, + amDoc.belegNummer, + ); if (!isNaN(linkFieldId)) { this.setCustomField( existingFields, @@ -475,16 +630,21 @@ export class AgrarmonitorPollingService implements OnModuleInit { } const updateData: Record = { - title: (amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer, + title: + (amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer, document_type: amDoc.dokumentTyp === 0 ? 1 : 2, tags: newTags, custom_fields: existingFields, }; - if (amDoc.belegDatum) updateData.created = amDoc.belegDatum.toISOString().slice(0, 10); + if (amDoc.belegDatum) + updateData.created = amDoc.belegDatum.toISOString().slice(0, 10); if (corr) updateData.correspondent = corr.id as number; if (ownerId !== undefined) updateData.owner = ownerId; - await this.paperlessService.updateDocument(doc.id as number, updateData); + await this.paperlessService.updateDocument( + doc.id as number, + updateData, + ); await this.paperlessService.addNote( doc.id as number, `Beleg in Agrarmonitor verarbeitet: ${new Date().toLocaleString('de-DE')}`, @@ -493,7 +653,9 @@ export class AgrarmonitorPollingService implements OnModuleInit { result.updated++; } catch (err: unknown) { const msg = `${interneBelegnummer}: Update-Fehler`; - this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `${msg}: ${err instanceof Error ? err.message : err}`, + ); result.errors.push(msg); } @@ -502,7 +664,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { this.logger.log( `Upload-Check abgeschlossen: ${result.processed} geprüft, ${result.updated} aktualisiert, ` + - `${result.skipped} übersprungen, ${result.errors.length} Fehler`, + `${result.skipped} übersprungen, ${result.errors.length} Fehler`, ); } finally { this.uploadCheckRunning = false; @@ -521,16 +683,20 @@ export class AgrarmonitorPollingService implements OnModuleInit { } async syncCorrespondentIds(): Promise { - let amClient: Awaited>; + let amClient: Awaited< + ReturnType + >; try { amClient = await this.agrarmonitorService.getClient(); } catch (err: unknown) { - throw new Error(`Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`); + throw new Error( + `Connector-Fehler: ${err instanceof Error ? err.message : 'unbekannt'}`, + ); } const customers = await amClient.fetchCustomers(); const lieferantMap = new Map(); // lieferantennummer → AM-ID - const kundenMap = new Map(); // kundennummer → AM-ID + const kundenMap = new Map(); // kundennummer → AM-ID for (const c of customers) { const liefNr = String(c['lieferantennummer'] ?? '').trim(); if (liefNr) lieferantMap.set(liefNr, Number(c.id)); @@ -541,14 +707,17 @@ export class AgrarmonitorPollingService implements OnModuleInit { const allCorrespondents: any[] = []; let page = 1; while (true) { - const resp = await this.paperlessService.getCorrespondents({ page, page_size: 250 }); + const resp = await this.paperlessService.getCorrespondents({ + page, + page_size: 250, + }); allCorrespondents.push(...(resp.results ?? [])); if (!resp.next) break; page++; } - const lieferantRegex = /\((\d+)\)$/; // reine Zahl → Lieferantennummer - const kundenRegex = /\(KD(\d+)\)$/; // KD-Prefix → Kundennummer + const lieferantRegex = /\((\d+)\)$/; // reine Zahl → Lieferantennummer + const kundenRegex = /\(KD(\d+)\)$/; // KD-Prefix → Kundennummer let matched = 0; let unmatched = 0; @@ -564,11 +733,19 @@ export class AgrarmonitorPollingService implements OnModuleInit { if (liefMatch) amId = lieferantMap.get(liefMatch[1]); } - if (amId === undefined) { unmatched++; continue; } + if (amId === undefined) { + unmatched++; + continue; + } - let setting = await this.corrSettingRepo.findOneBy({ CorrespondentId: corr.id as number }); + let setting = await this.corrSettingRepo.findOneBy({ + CorrespondentId: corr.id as number, + }); if (!setting) { - setting = this.corrSettingRepo.create({ CorrespondentId: corr.id as number, AgrarmonitorId: amId }); + setting = this.corrSettingRepo.create({ + CorrespondentId: corr.id as number, + AgrarmonitorId: amId, + }); } else { setting.AgrarmonitorId = amId; } @@ -594,29 +771,47 @@ export class AgrarmonitorPollingService implements OnModuleInit { for (const [amId, corrIds] of byAmId) { if (corrIds.length <= 1) continue; - const corrs = await Promise.all(corrIds.map(id => this.paperlessService.getCorrespondent(id))); + const corrs = await Promise.all( + corrIds.map((id) => this.paperlessService.getCorrespondent(id)), + ); const uniqueNames = new Set(corrs.map((c: any) => c.name as string)); if (uniqueNames.size === 1) { // Gleicher Name — automatisch zusammenführen - const withoutDocs = corrs.filter((c: any) => Number(c.document_count) === 0); + const withoutDocs = corrs.filter( + (c: any) => Number(c.document_count) === 0, + ); const withDocs = corrs.filter((c: any) => Number(c.document_count) > 0); if (withoutDocs.length > 0) { for (const toDelete of withoutDocs) { - await this.paperlessService.deleteCorrespondent(toDelete.id as number); - await this.corrSettingRepo.delete({ CorrespondentId: toDelete.id as number }); + await this.paperlessService.deleteCorrespondent( + toDelete.id as number, + ); + await this.corrSettingRepo.delete({ + CorrespondentId: toDelete.id as number, + }); autoMerged++; - this.logger.log(`Duplikat gelöscht (keine Dokumente): ${toDelete.name as string} (ID ${toDelete.id as number})`); + this.logger.log( + `Duplikat gelöscht (keine Dokumente): ${toDelete.name as string} (ID ${toDelete.id as number})`, + ); } } else { // Alle haben Dokumente — in den mit den meisten Dokumenten zusammenführen - const sorted = [...withDocs].sort((a: any, b: any) => Number(b.document_count) - Number(a.document_count)); - const keep = sorted[0] as any; + const sorted = [...withDocs].sort( + (a: any, b: any) => + Number(b.document_count) - Number(a.document_count), + ); + const keep = sorted[0]; for (const toMerge of sorted.slice(1)) { - await this.mergeCorrespondents(keep.id as number, toMerge.id as number); + await this.mergeCorrespondents( + keep.id as number, + toMerge.id as number, + ); autoMerged++; - this.logger.log(`Duplikat zusammengeführt in ${keep.name as string} (ID ${keep.id as number})`); + this.logger.log( + `Duplikat zusammengeführt in ${keep.name as string} (ID ${keep.id as number})`, + ); } } } else { @@ -634,12 +829,21 @@ export class AgrarmonitorPollingService implements OnModuleInit { this.logger.log( `Korrespondenten-Abgleich: ${matched} zugeordnet, ${unmatched} ohne Treffer, ` + - `${autoMerged} automatisch zusammengeführt, ${conflicts.length} Konflikte`, + `${autoMerged} automatisch zusammengeführt, ${conflicts.length} Konflikte`, ); - return { total: allCorrespondents.length, matched, unmatched, autoMerged, conflicts }; + return { + total: allCorrespondents.length, + matched, + unmatched, + autoMerged, + conflicts, + }; } - async mergeCorrespondents(keepId: number, deleteId: number): Promise<{ mergedDocuments: number }> { + async mergeCorrespondents( + keepId: number, + deleteId: number, + ): Promise<{ mergedDocuments: number }> { let mergedDocuments = 0; let page = 1; while (true) { @@ -651,7 +855,9 @@ export class AgrarmonitorPollingService implements OnModuleInit { }); const docs: any[] = resp?.results ?? []; for (const doc of docs) { - await this.paperlessService.updateDocument(doc.id as number, { correspondent: keepId }); + await this.paperlessService.updateDocument(doc.id as number, { + correspondent: keepId, + }); mergedDocuments++; } if (!resp?.next) break; @@ -659,14 +865,21 @@ export class AgrarmonitorPollingService implements OnModuleInit { } await this.paperlessService.deleteCorrespondent(deleteId); await this.corrSettingRepo.delete({ CorrespondentId: deleteId }); - this.logger.log(`Korrespondent ${deleteId} → ${keepId} zusammengeführt (${mergedDocuments} Dokumente)`); + this.logger.log( + `Korrespondent ${deleteId} → ${keepId} zusammengeführt (${mergedDocuments} Dokumente)`, + ); return { mergedDocuments }; } - private async getOrCreateCorrespondent(customer: Record, kundenId?: number): Promise { + private async getOrCreateCorrespondent( + customer: Record, + kundenId?: number, + ): Promise { // Direkter Lookup über gespeicherte Agrarmonitor-ID if (kundenId !== undefined) { - const setting = await this.corrSettingRepo.findOneBy({ AgrarmonitorId: kundenId }); + const setting = await this.corrSettingRepo.findOneBy({ + AgrarmonitorId: kundenId, + }); if (setting) { return { id: setting.CorrespondentId }; } @@ -688,9 +901,14 @@ export class AgrarmonitorPollingService implements OnModuleInit { // Link für künftige Läufe speichern if (corr && kundenId !== undefined) { - let setting = await this.corrSettingRepo.findOneBy({ CorrespondentId: corr.id as number }); + let setting = await this.corrSettingRepo.findOneBy({ + CorrespondentId: corr.id as number, + }); if (!setting) { - setting = this.corrSettingRepo.create({ CorrespondentId: corr.id as number, AgrarmonitorId: kundenId }); + setting = this.corrSettingRepo.create({ + CorrespondentId: corr.id as number, + AgrarmonitorId: kundenId, + }); } else { setting.AgrarmonitorId = kundenId; } @@ -700,11 +918,14 @@ export class AgrarmonitorPollingService implements OnModuleInit { return corr; } - private buildCustomerName(customer: Record, nummer: string): string { + private buildCustomerName( + customer: Record, + nummer: string, + ): string { const firma = (customer['firma'] as string) ?? ''; const nachname = (customer['nachname'] as string) ?? ''; const vorname = (customer['vorname'] as string) ?? ''; - const name = firma || (nachname + (vorname ? ', ' + vorname : '')); + const name = firma || nachname + (vorname ? ', ' + vorname : ''); return `${name} (${nummer})`; } @@ -712,7 +933,10 @@ export class AgrarmonitorPollingService implements OnModuleInit { return new Promise((resolve) => setTimeout(resolve, ms)); } - private async upsertSetting(tag: string, defaultValue: string): Promise { + private async upsertSetting( + tag: string, + defaultValue: string, + ): Promise { const existing = await this.settingRepo.findOneBy({ Tag: tag }); if (!existing) { await this.settingRepo.save( diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts index b315a41..5d5cfef 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts @@ -20,7 +20,9 @@ export class AgrarmonitorController { @Post('register') @HttpCode(200) @RequirePermissions(Permission.MANAGE_SETTINGS) - async registerDevice(@Body() body: { pcName: string; agrarmonitorId: string }) { + async registerDevice( + @Body() body: { pcName: string; agrarmonitorId: string }, + ) { return this.service.registerDevice(body.pcName, body.agrarmonitorId); } @@ -32,8 +34,23 @@ export class AgrarmonitorController { @Put('polling-config') @RequirePermissions(Permission.MANAGE_SETTINGS) - async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string; tagManuell: string }) { - return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht, body.tagHochgeladen, body.linkField, body.tagManuell ?? ''); + async updatePollingConfig( + @Body() + body: { + tagFertig: string; + tagVerbucht: string; + tagHochgeladen: string; + linkField: string; + tagManuell: string; + }, + ) { + return this.pollingService.updatePollingConfig( + body.tagFertig, + body.tagVerbucht, + body.tagHochgeladen, + body.linkField, + body.tagManuell ?? '', + ); } @Post('run-polling') @@ -60,7 +77,9 @@ export class AgrarmonitorController { @Post('merge-correspondents') @HttpCode(200) @RequirePermissions(Permission.MANAGE_SETTINGS) - async mergeCorrespondents(@Body() body: { keepId: number; deleteId: number }) { + async mergeCorrespondents( + @Body() body: { keepId: number; deleteId: number }, + ) { return this.pollingService.mergeCorrespondents(body.keepId, body.deleteId); } } diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts index dfba116..0d9cf76 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.service.ts @@ -29,16 +29,38 @@ export class AgrarmonitorService { async getClient(): Promise { if (this.client) return this.client; - const username = this.configService.get('AGRARMONITOR_USERNAME', ''); - const password = this.configService.get('AGRARMONITOR_PASSWORD', ''); - const baseUrl = this.configService.get('AGRARMONITOR_BASE_URL', 'https://admin7.agrarmonitor.de'); - const apiBaseUrl = this.configService.get('AGRARMONITOR_API_BASE_URL', 'https://api.agrarmonitor.de'); + const username = this.configService.get( + 'AGRARMONITOR_USERNAME', + '', + ); + const password = this.configService.get( + 'AGRARMONITOR_PASSWORD', + '', + ); + const baseUrl = this.configService.get( + 'AGRARMONITOR_BASE_URL', + 'https://admin7.agrarmonitor.de', + ); + const apiBaseUrl = this.configService.get( + 'AGRARMONITOR_API_BASE_URL', + 'https://api.agrarmonitor.de', + ); const apiToken = this.configService.get('AGRARMONITOR_API_TOKEN'); - const cookiePath = this.configService.get('AGRARMONITOR_COOKIE_PATH', './data/agrarmonitor-cookies.json'); - const encryptionKey = this.configService.get('AGRARMONITOR_ENCRYPTION_KEY'); + const cookiePath = this.configService.get( + 'AGRARMONITOR_COOKIE_PATH', + './data/agrarmonitor-cookies.json', + ); + const encryptionKey = this.configService.get( + 'AGRARMONITOR_ENCRYPTION_KEY', + ); - const encryptor = encryptionKey ? new AesGcmCookieEncryptor(encryptionKey) : undefined; - const cookieStore = new FileCookieStore(cookiePath, { encryptor, logger: this.logger }); + const encryptor = encryptionKey + ? new AesGcmCookieEncryptor(encryptionKey) + : undefined; + const cookieStore = new FileCookieStore(cookiePath, { + encryptor, + logger: this.logger, + }); this.client = await createAgrarmonitorClient({ baseUrl, @@ -84,7 +106,10 @@ export class AgrarmonitorService { } } - async registerDevice(pcName: string, agrarmonitorId: string): Promise { + async registerDevice( + pcName: string, + agrarmonitorId: string, + ): Promise { const client = await this.getClient(); const result = await client.registerDevice({ agrarmonitorId, pcName }); return { success: result.success, message: result.message }; diff --git a/paperless-backend/src/app.module.ts b/paperless-backend/src/app.module.ts index 06faed6..f28e841 100644 --- a/paperless-backend/src/app.module.ts +++ b/paperless-backend/src/app.module.ts @@ -28,8 +28,8 @@ import * as path from 'path'; ConfigModule.forRoot({ isGlobal: true, envFilePath: [ - path.resolve(__dirname, '../../.env'), // Root .env (zentral) - path.resolve(__dirname, '../.env'), // Lokale .env (Fallback) + path.resolve(__dirname, '../../.env'), // Root .env (zentral) + path.resolve(__dirname, '../.env'), // Lokale .env (Fallback) ], }), ScheduleModule.forRoot(), diff --git a/paperless-backend/src/auth/api-key.guard.ts b/paperless-backend/src/auth/api-key.guard.ts index 9fe9507..fad19c5 100644 --- a/paperless-backend/src/auth/api-key.guard.ts +++ b/paperless-backend/src/auth/api-key.guard.ts @@ -1,4 +1,10 @@ -import { CanActivate, ExecutionContext, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; import { ApiKeysService } from './api-keys.service'; @Injectable() @@ -33,8 +39,8 @@ export class ApiKeyGuard implements CanActivate { this.logger.log( `[${method} ${url}] key source: ${apiKey ? source : 'NONE'} | ` + - `headers: ${JSON.stringify(Object.keys(request.headers))} | ` + - `key prefix: ${apiKey ? String(apiKey).slice(0, 8) + '…' : 'n/a'}`, + `headers: ${JSON.stringify(Object.keys(request.headers))} | ` + + `key prefix: ${apiKey ? String(apiKey).slice(0, 8) + '…' : 'n/a'}`, ); if (!apiKey) { @@ -44,11 +50,15 @@ export class ApiKeyGuard implements CanActivate { try { const keyEntry = await this.apiKeysService.validateKey(apiKey as string); - this.logger.log(`[${method} ${url}] accepted – key "${keyEntry.name}" (id=${keyEntry.id})`); + this.logger.log( + `[${method} ${url}] accepted – key "${keyEntry.name}" (id=${keyEntry.id})`, + ); request.apiKeyMetadata = { id: keyEntry.id, name: keyEntry.name }; return true; } catch (err) { - this.logger.warn(`[${method} ${url}] rejected – validation failed: ${err.message}`); + this.logger.warn( + `[${method} ${url}] rejected – validation failed: ${err.message}`, + ); throw new UnauthorizedException(err.message || 'Invalid API Key'); } } diff --git a/paperless-backend/src/auth/api-keys.controller.ts b/paperless-backend/src/auth/api-keys.controller.ts index f31cb95..4d6cc36 100644 --- a/paperless-backend/src/auth/api-keys.controller.ts +++ b/paperless-backend/src/auth/api-keys.controller.ts @@ -1,4 +1,12 @@ -import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; import { ApiKeysService } from './api-keys.service'; import { JwtAuthGuard } from './jwt-auth.guard'; diff --git a/paperless-backend/src/auth/api-keys.service.ts b/paperless-backend/src/auth/api-keys.service.ts index 3b1cf68..ebcb828 100644 --- a/paperless-backend/src/auth/api-keys.service.ts +++ b/paperless-backend/src/auth/api-keys.service.ts @@ -13,22 +13,27 @@ export class ApiKeysService { private readonly apiKeyRepo: Repository, ) {} - async createApiKey(name: string, expiresDays?: number): Promise<{ plainKey: string; entity: ApiKey }> { + async createApiKey( + name: string, + expiresDays?: number, + ): Promise<{ plainKey: string; entity: ApiKey }> { const prefix = 'pm_'; const randomPart = crypto.randomBytes(24).toString('hex'); // 48 chars hex const plainKey = `${prefix}${randomPart}`; - + const keyHash = this.hashKey(plainKey); - + const apiKey = this.apiKeyRepo.create({ name, keyPrefix: prefix, keyHash, - expiresAt: expiresDays ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) : null, + expiresAt: expiresDays + ? new Date(Date.now() + expiresDays * 24 * 60 * 60 * 1000) + : null, }); const savedKey = await this.apiKeyRepo.save(apiKey); - + return { plainKey, entity: savedKey, @@ -37,7 +42,7 @@ export class ApiKeysService { async validateKey(plainKey: string): Promise { const keyHash = this.hashKey(plainKey); - + const apiKey = await this.apiKeyRepo.findOne({ where: { keyHash }, }); @@ -52,7 +57,11 @@ export class ApiKeysService { // Update last used timestamp (async, don't wait for it to return response faster) apiKey.lastUsedAt = new Date(); - this.apiKeyRepo.save(apiKey).catch(err => this.logger.error('Fehler beim Aktualisieren von lastUsedAt', err)); + this.apiKeyRepo + .save(apiKey) + .catch((err) => + this.logger.error('Fehler beim Aktualisieren von lastUsedAt', err), + ); return apiKey; } diff --git a/paperless-backend/src/auth/auth.module.ts b/paperless-backend/src/auth/auth.module.ts index 5a89319..47af530 100644 --- a/paperless-backend/src/auth/auth.module.ts +++ b/paperless-backend/src/auth/auth.module.ts @@ -33,6 +33,14 @@ import { PermissionsGuard } from './permissions.guard'; useClass: PermissionsGuard, }, ], - exports: [PassportModule, ApiKeysService, ApiKeyGuard, JwtAuthGuard, JwtOrApiKeyGuard, PermissionsGuard, TypeOrmModule], + exports: [ + PassportModule, + ApiKeysService, + ApiKeyGuard, + JwtAuthGuard, + JwtOrApiKeyGuard, + PermissionsGuard, + TypeOrmModule, + ], }) export class AuthModule {} diff --git a/paperless-backend/src/auth/jwt-or-apikey.guard.ts b/paperless-backend/src/auth/jwt-or-apikey.guard.ts index ab06c16..1b3175e 100644 --- a/paperless-backend/src/auth/jwt-or-apikey.guard.ts +++ b/paperless-backend/src/auth/jwt-or-apikey.guard.ts @@ -1,4 +1,9 @@ -import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common'; +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, +} from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { JwtAuthGuard } from './jwt-auth.guard'; import { ApiKeyGuard } from './api-key.guard'; @@ -28,7 +33,9 @@ export class JwtOrApiKeyGuard implements CanActivate { // Try JWT first try { const result = this.jwtGuard.canActivate(context); - const jwtOk = isObservable(result) ? await lastValueFrom(result) : await result; + const jwtOk = isObservable(result) + ? await lastValueFrom(result) + : await result; if (jwtOk) { this.logger.log(`${tag} authenticated via JWT`); return true; diff --git a/paperless-backend/src/auth/jwt.strategy.ts b/paperless-backend/src/auth/jwt.strategy.ts index ba2fb5b..1153c05 100644 --- a/paperless-backend/src/auth/jwt.strategy.ts +++ b/paperless-backend/src/auth/jwt.strategy.ts @@ -24,7 +24,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { }); } - validate(payload: any): { userId: string; email: string; name: string; preferredUsername: string | null; groups: string[]; permissions: any[] } { + validate(payload: any): { + userId: string; + email: string; + name: string; + preferredUsername: string | null; + groups: string[]; + permissions: any[]; + } { const groups = payload.groups || []; return { userId: payload.sub, diff --git a/paperless-backend/src/auth/permissions.decorator.ts b/paperless-backend/src/auth/permissions.decorator.ts index e840cce..8618a10 100644 --- a/paperless-backend/src/auth/permissions.decorator.ts +++ b/paperless-backend/src/auth/permissions.decorator.ts @@ -2,4 +2,5 @@ import { SetMetadata } from '@nestjs/common'; import { Permission } from './permissions.enum'; export const PERMISSIONS_KEY = 'permissions'; -export const RequirePermissions = (...permissions: Permission[]) => SetMetadata(PERMISSIONS_KEY, permissions); +export const RequirePermissions = (...permissions: Permission[]) => + SetMetadata(PERMISSIONS_KEY, permissions); diff --git a/paperless-backend/src/auth/permissions.enum.ts b/paperless-backend/src/auth/permissions.enum.ts index 7104ff7..5564e78 100644 --- a/paperless-backend/src/auth/permissions.enum.ts +++ b/paperless-backend/src/auth/permissions.enum.ts @@ -8,9 +8,11 @@ export const Permission = { VIEW_FREIGABE: 'VIEW_FREIGABE', } as const; -export type Permission = typeof Permission[keyof typeof Permission]; +export type Permission = (typeof Permission)[keyof typeof Permission]; -export function mapGroupsToPermissions(groups: string[] | undefined | null): Permission[] { +export function mapGroupsToPermissions( + groups: string[] | undefined | null, +): Permission[] { const permissions = new Set(); if (!groups || !Array.isArray(groups)) { @@ -28,7 +30,8 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per return Array.from(permissions); } - if (groups.includes('PM_Belege')) permissions.add(Permission.PROCESS_MANUALLY); + if (groups.includes('PM_Belege')) + permissions.add(Permission.PROCESS_MANUALLY); if (groups.includes('PM_Maileingang')) permissions.add(Permission.VIEW_MAIL); if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX); if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER); diff --git a/paperless-backend/src/auth/permissions.guard.ts b/paperless-backend/src/auth/permissions.guard.ts index 5b2b937..9be61d7 100644 --- a/paperless-backend/src/auth/permissions.guard.ts +++ b/paperless-backend/src/auth/permissions.guard.ts @@ -8,32 +8,34 @@ export class PermissionsGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredPermissions = this.reflector.getAllAndOverride(PERMISSIONS_KEY, [ - context.getHandler(), - context.getClass(), - ]); - + const requiredPermissions = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + if (!requiredPermissions) { return true; } - + const request = context.switchToHttp().getRequest(); const { user } = request; if (request.apiKeyMetadata) { return true; } - + if (!user || !user.permissions) { return false; } - + const userPermissions = user.permissions as Permission[]; - + if (userPermissions.includes(Permission.MANAGE_ALL)) { return true; } - - return requiredPermissions.some((permission) => userPermissions.includes(permission)); + + return requiredPermissions.some((permission) => + userPermissions.includes(permission), + ); } } diff --git a/paperless-backend/src/barcode/barcode-scanner.service.ts b/paperless-backend/src/barcode/barcode-scanner.service.ts index 78c6b0f..11a57d2 100644 --- a/paperless-backend/src/barcode/barcode-scanner.service.ts +++ b/paperless-backend/src/barcode/barcode-scanner.service.ts @@ -9,9 +9,15 @@ import { BarcodeTemplate, type BarcodeActionType, } from '../database/entities/barcode-template.entity'; -import { InboxDocument, type StoredQrCode } from '../database/entities/inbox-document.entity'; +import { + InboxDocument, + type StoredQrCode, +} from '../database/entities/inbox-document.entity'; import { PageCacheService } from './page-cache.service'; -import { applyTemplate, buildVariables } from '../inbox-postprocessor/variable-resolver'; +import { + applyTemplate, + buildVariables, +} from '../inbox-postprocessor/variable-resolver'; export interface MatchedBarcode { page: number; @@ -51,7 +57,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap { try { rows = await this.templateRepo.find(); } catch (err: any) { - this.logger.warn(`Template-Migration: Query fehlgeschlagen: ${err.message}`); + this.logger.warn( + `Template-Migration: Query fehlgeschlagen: ${err.message}`, + ); return; } let migrated = 0; @@ -59,13 +67,17 @@ export class BarcodeScannerService implements OnApplicationBootstrap { const actions = (tpl.Actions ?? []) as string[]; if (actions.includes('SPLIT_BEFORE')) { tpl.SplitBefore = true; - tpl.Actions = actions.filter((a) => a !== 'SPLIT_BEFORE') as BarcodeActionType[]; + tpl.Actions = actions.filter( + (a) => a !== 'SPLIT_BEFORE', + ) as BarcodeActionType[]; await this.templateRepo.save(tpl); migrated += 1; } } if (migrated > 0) { - this.logger.log(`Template-Migration: ${migrated} Vorlage(n) auf SplitBefore-Flag umgestellt`); + this.logger.log( + `Template-Migration: ${migrated} Vorlage(n) auf SplitBefore-Flag umgestellt`, + ); } } @@ -83,7 +95,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap { try { await this.documentRepo.save(doc); } catch (err: any) { - this.logger.warn(`Scan-Ergebnis konnte nicht gespeichert werden (${doc.Id}): ${err.message}`); + this.logger.warn( + `Scan-Ergebnis konnte nicht gespeichert werden (${doc.Id}): ${err.message}`, + ); } return this.matchTemplates(qrCodes); @@ -105,7 +119,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap { return this.matchTemplates(doc.QrCodes ?? []); } - private async matchTemplates(qrCodes: StoredQrCode[]): Promise { + private async matchTemplates( + qrCodes: StoredQrCode[], + ): Promise { if (qrCodes.length === 0) return []; const templates = await this.getTemplates(); return qrCodes.map((qr) => { @@ -118,7 +134,11 @@ export class BarcodeScannerService implements OnApplicationBootstrap { dateinameTemplate: tpl?.DateinameTemplate ? applyTemplate( tpl.DateinameTemplate, - buildVariables({ doc: {} as InboxDocument, template: tpl, matchingQrValue: qr.value }), + buildVariables({ + doc: {} as InboxDocument, + template: tpl, + matchingQrValue: qr.value, + }), ) : null, splitBefore: tpl?.SplitBefore ?? false, @@ -127,7 +147,10 @@ export class BarcodeScannerService implements OnApplicationBootstrap { }); } - private firstMatch(value: string, templates: BarcodeTemplate[]): BarcodeTemplate | null { + private firstMatch( + value: string, + templates: BarcodeTemplate[], + ): BarcodeTemplate | null { for (const tpl of templates) { try { const re = new RegExp(tpl.Regex); @@ -141,7 +164,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap { private async getTemplates(): Promise { if (!this.templatesCache) { - this.templatesCache = await this.templateRepo.find({ order: { Id: 'ASC' } }); + this.templatesCache = await this.templateRepo.find({ + order: { Id: 'ASC' }, + }); } return this.templatesCache; } @@ -168,7 +193,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap { } } } catch (err: any) { - this.logger.warn(`QR-Scan fehlgeschlagen (${pdfPath}, Seite ${i + 1}): ${err.message}`); + this.logger.warn( + `QR-Scan fehlgeschlagen (${pdfPath}, Seite ${i + 1}): ${err.message}`, + ); } } @@ -206,17 +233,22 @@ export class BarcodeScannerService implements OnApplicationBootstrap { const { width: imgW, height: imgH } = await image.metadata(); if (!imgW || !imgH) return { found: [] }; - const left = Math.round(Math.max(0, x * imgW)); - const top = Math.round(Math.max(0, y * imgH)); - const width = Math.round(Math.min(imgW - left, w * imgW)); - const height = Math.round(Math.min(imgH - top, h * imgH)); + const left = Math.round(Math.max(0, x * imgW)); + const top = Math.round(Math.max(0, y * imgH)); + const width = Math.round(Math.min(imgW - left, w * imgW)); + const height = Math.round(Math.min(imgH - top, h * imgH)); if (width <= 0 || height <= 0) return { found: [] }; - const cropped = await image.extract({ left, top, width, height }).png().toBuffer(); + const cropped = await image + .extract({ left, top, width, height }) + .png() + .toBuffer(); const qrResults = await this.qrCodeService.extractFromImage(cropped); if (qrResults.length === 0) return { found: [] }; - const existingKeys = new Set((doc.QrCodes ?? []).map((qr) => `${qr.page}:${qr.value}`)); + const existingKeys = new Set( + (doc.QrCodes ?? []).map((qr) => `${qr.page}:${qr.value}`), + ); const found: string[] = []; let changed = false; @@ -254,7 +286,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap { } if (docs.length === 0) return { scanned: 0, failed: 0 }; - this.logger.log(`Rescan: starte Neuerfassung für ${docs.length} Inbox-Dokument(e)`); + this.logger.log( + `Rescan: starte Neuerfassung für ${docs.length} Inbox-Dokument(e)`, + ); let scanned = 0; let failed = 0; for (const doc of docs) { @@ -277,7 +311,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap { failed++; } } - this.logger.log(`Rescan abgeschlossen: ${scanned} ok, ${failed} fehlgeschlagen`); + this.logger.log( + `Rescan abgeschlossen: ${scanned} ok, ${failed} fehlgeschlagen`, + ); return { scanned, failed }; } } diff --git a/paperless-backend/src/barcode/barcode-templates.controller.ts b/paperless-backend/src/barcode/barcode-templates.controller.ts index 6e67607..840005b 100644 --- a/paperless-backend/src/barcode/barcode-templates.controller.ts +++ b/paperless-backend/src/barcode/barcode-templates.controller.ts @@ -13,12 +13,20 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { BarcodeTemplate, type BarcodeActionType, type LabelInputField, type LabelElement } from '../database/entities/barcode-template.entity'; +import { + BarcodeTemplate, + type BarcodeActionType, + type LabelInputField, + type LabelElement, +} from '../database/entities/barcode-template.entity'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; import { BarcodeScannerService } from './barcode-scanner.service'; -const VALID_ACTIONS: BarcodeActionType[] = ['SEND_TO_PAPERLESS', 'SEND_BY_EMAIL']; +const VALID_ACTIONS: BarcodeActionType[] = [ + 'SEND_TO_PAPERLESS', + 'SEND_BY_EMAIL', +]; interface UpsertDto { Name?: string; @@ -55,8 +63,11 @@ function validate(dto: UpsertDto, partial = false): void { if (dto.SplitBefore !== undefined && typeof dto.SplitBefore !== 'boolean') { throw new BadRequestException('SplitBefore muss ein Boolean sein'); } - if (dto.DateinameTemplate !== undefined && dto.DateinameTemplate !== null && - typeof dto.DateinameTemplate !== 'string') { + if ( + dto.DateinameTemplate !== undefined && + dto.DateinameTemplate !== null && + typeof dto.DateinameTemplate !== 'string' + ) { throw new BadRequestException('DateinameTemplate muss ein String sein'); } if (!partial || dto.Actions !== undefined) { @@ -83,7 +94,9 @@ export class BarcodeTemplatesController { private triggerRescan(): void { this.scanner.rescanAll().catch((err) => { - this.logger.error(`Rescan nach Template-Änderung fehlgeschlagen: ${err.message}`); + this.logger.error( + `Rescan nach Template-Änderung fehlgeschlagen: ${err.message}`, + ); }); } @@ -125,21 +138,31 @@ export class BarcodeTemplatesController { const existing = await this.repo.findOneBy({ Id: id }); if (!existing) throw new NotFoundException('Vorlage nicht gefunden'); - const regexChanged = dto.Regex !== undefined && dto.Regex !== existing.Regex; + const regexChanged = + dto.Regex !== undefined && dto.Regex !== existing.Regex; if (dto.Name !== undefined) existing.Name = dto.Name.trim(); if (dto.Regex !== undefined) existing.Regex = dto.Regex; if (dto.SplitBefore !== undefined) existing.SplitBefore = dto.SplitBefore; - if (dto.DateinameTemplate !== undefined) existing.DateinameTemplate = dto.DateinameTemplate ?? null; + if (dto.DateinameTemplate !== undefined) + existing.DateinameTemplate = dto.DateinameTemplate ?? null; if (dto.Actions !== undefined) existing.Actions = dto.Actions; - if (dto.LabelEnabled !== undefined) existing.LabelEnabled = dto.LabelEnabled; - if (dto.LabelWidthMm !== undefined) existing.LabelWidthMm = dto.LabelWidthMm ?? null; - if (dto.LabelHeightMm !== undefined) existing.LabelHeightMm = dto.LabelHeightMm ?? null; - if (dto.LabelInputFields !== undefined) existing.LabelInputFields = dto.LabelInputFields ?? null; - if (dto.LabelGetUrl !== undefined) existing.LabelGetUrl = dto.LabelGetUrl ?? null; - if (dto.LabelPrintedUrl !== undefined) existing.LabelPrintedUrl = dto.LabelPrintedUrl ?? null; - if (dto.LabelReleaseUrl !== undefined) existing.LabelReleaseUrl = dto.LabelReleaseUrl ?? null; - if (dto.LabelLayout !== undefined) existing.LabelLayout = dto.LabelLayout ?? null; + if (dto.LabelEnabled !== undefined) + existing.LabelEnabled = dto.LabelEnabled; + if (dto.LabelWidthMm !== undefined) + existing.LabelWidthMm = dto.LabelWidthMm ?? null; + if (dto.LabelHeightMm !== undefined) + existing.LabelHeightMm = dto.LabelHeightMm ?? null; + if (dto.LabelInputFields !== undefined) + existing.LabelInputFields = dto.LabelInputFields ?? null; + if (dto.LabelGetUrl !== undefined) + existing.LabelGetUrl = dto.LabelGetUrl ?? null; + if (dto.LabelPrintedUrl !== undefined) + existing.LabelPrintedUrl = dto.LabelPrintedUrl ?? null; + if (dto.LabelReleaseUrl !== undefined) + existing.LabelReleaseUrl = dto.LabelReleaseUrl ?? null; + if (dto.LabelLayout !== undefined) + existing.LabelLayout = dto.LabelLayout ?? null; const saved = await this.repo.save(existing); this.scanner.invalidateTemplates(); diff --git a/paperless-backend/src/barcode/page-cache.service.ts b/paperless-backend/src/barcode/page-cache.service.ts index 1f596f0..32c5c9a 100644 --- a/paperless-backend/src/barcode/page-cache.service.ts +++ b/paperless-backend/src/barcode/page-cache.service.ts @@ -12,7 +12,10 @@ export class PageCacheService { private readonly inboxRoot: string; constructor(configService: ConfigService) { - this.inboxRoot = configService.get('INBOX_DATA_DIR', '/mnt/data/inbox'); + this.inboxRoot = configService.get( + 'INBOX_DATA_DIR', + '/mnt/data/inbox', + ); } documentDir(documentId: string): string { @@ -47,7 +50,10 @@ export class PageCacheService { try { await fs.copyFile(src, previewDest); - await sharp(src).resize({ width: THUMBNAIL_WIDTH }).png().toFile(thumbDest); + await sharp(src) + .resize({ width: THUMBNAIL_WIDTH }) + .png() + .toFile(thumbDest); } catch (err: any) { this.logger.warn( `Seiten-Cache fehlgeschlagen (${documentId} Seite ${page}): ${err.message}`, diff --git a/paperless-backend/src/daily-digest/daily-digest.controller.ts b/paperless-backend/src/daily-digest/daily-digest.controller.ts index 4dc7a64..936fe9d 100644 --- a/paperless-backend/src/daily-digest/daily-digest.controller.ts +++ b/paperless-backend/src/daily-digest/daily-digest.controller.ts @@ -10,10 +10,18 @@ export class DailyDigestController { async sendNow(@Request() req: any) { const { userId, email, preferredUsername, groups } = req.user; if (!email) { - return { ok: false, error: 'Keine E-Mail-Adresse im Benutzerprofil gefunden.' }; + return { + ok: false, + error: 'Keine E-Mail-Adresse im Benutzerprofil gefunden.', + }; } try { - await this.dailyDigestService.sendDigestForUser(userId, email, preferredUsername, groups); + await this.dailyDigestService.sendDigestForUser( + userId, + email, + preferredUsername, + groups, + ); return { ok: true }; } catch (err: any) { return { ok: false, error: err.message }; diff --git a/paperless-backend/src/daily-digest/daily-digest.service.ts b/paperless-backend/src/daily-digest/daily-digest.service.ts index e9eced1..e65f84e 100644 --- a/paperless-backend/src/daily-digest/daily-digest.service.ts +++ b/paperless-backend/src/daily-digest/daily-digest.service.ts @@ -17,16 +17,56 @@ interface DigestTile { } const DIGEST_TILES: DigestTile[] = [ - { key: 'inbox', title: 'Eingangsbox', description: 'Neu eingegangene Scans, Uploads und E-Mail-Anhänge prüfen.', icon: '📥', accent: '#1677ff', accentSoft: '#e6f0ff', permission: Permission.VIEW_SCANNER }, - { key: 'posteingang', title: 'Posteingang', description: 'Verarbeitete Dokumente sichten und an Paperless übergeben.', icon: '📄', accent: '#13c2c2', accentSoft: '#e6fffb', permission: Permission.VIEW_INBOX }, - { key: 'manuell', title: 'Manuell bearbeiten', description: 'Dokumente mit fehlender Erkennung manuell ergänzen.', icon: '✏️', accent: '#fa8c16', accentSoft: '#fff7e6', permission: Permission.PROCESS_MANUALLY }, - { key: 'mailpostfach', title: 'Mailpostfach', description: 'Eingehende E-Mails mit Anhängen durchsuchen und zuordnen.', icon: '📬', accent: '#722ed1', accentSoft: '#f9f0ff', permission: Permission.VIEW_MAIL }, - { key: 'agrarmonitor', title: 'In Agrarmonitor', description: 'Dokumente im Agrarmonitor-Eingang anzeigen und verwalten.', icon: '🌱', accent: '#52c41a', accentSoft: '#f6ffed', permission: Permission.PROCESS_MANUALLY }, + { + key: 'inbox', + title: 'Eingangsbox', + description: 'Neu eingegangene Scans, Uploads und E-Mail-Anhänge prüfen.', + icon: '📥', + accent: '#1677ff', + accentSoft: '#e6f0ff', + permission: Permission.VIEW_SCANNER, + }, + { + key: 'posteingang', + title: 'Posteingang', + description: 'Verarbeitete Dokumente sichten und an Paperless übergeben.', + icon: '📄', + accent: '#13c2c2', + accentSoft: '#e6fffb', + permission: Permission.VIEW_INBOX, + }, + { + key: 'manuell', + title: 'Manuell bearbeiten', + description: 'Dokumente mit fehlender Erkennung manuell ergänzen.', + icon: '✏️', + accent: '#fa8c16', + accentSoft: '#fff7e6', + permission: Permission.PROCESS_MANUALLY, + }, + { + key: 'mailpostfach', + title: 'Mailpostfach', + description: 'Eingehende E-Mails mit Anhängen durchsuchen und zuordnen.', + icon: '📬', + accent: '#722ed1', + accentSoft: '#f9f0ff', + permission: Permission.VIEW_MAIL, + }, + { + key: 'agrarmonitor', + title: 'In Agrarmonitor', + description: 'Dokumente im Agrarmonitor-Eingang anzeigen und verwalten.', + icon: '🌱', + accent: '#52c41a', + accentSoft: '#f6ffed', + permission: Permission.PROCESS_MANUALLY, + }, ]; function getVisibleTiles(groups: string[] | null | undefined): DigestTile[] { const permissions = mapGroupsToPermissions(groups ?? []); - return DIGEST_TILES.filter(t => permissions.includes(t.permission)); + return DIGEST_TILES.filter((t) => permissions.includes(t.permission)); } @Injectable() @@ -41,19 +81,42 @@ export class DailyDigestService { private readonly mailService: MailService, private readonly configService: ConfigService, ) { - this.appUrl = this.configService.get('APP_URL', '').replace(/\/+$/, ''); - this.agrarmonitorBaseUrl = this.configService.get('AGRARMONITOR_BASE_URL', '').replace(/\/+$/, ''); + this.appUrl = this.configService + .get('APP_URL', '') + .replace(/\/+$/, ''); + this.agrarmonitorBaseUrl = this.configService + .get('AGRARMONITOR_BASE_URL', '') + .replace(/\/+$/, ''); } - async sendDigestForUser(userId: string, email: string, preferredUsername?: string, groups?: string[]) { + async sendDigestForUser( + userId: string, + email: string, + preferredUsername?: string, + groups?: string[], + ) { const visibleTiles = getVisibleTiles(groups); if (visibleTiles.length === 0) { - this.logger.warn(`Kein Digest für ${email}: keine sichtbaren Kacheln (Gruppen: ${JSON.stringify(groups)})`); + this.logger.warn( + `Kein Digest für ${email}: keine sichtbaren Kacheln (Gruppen: ${JSON.stringify(groups)})`, + ); return; } - const today = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); - const counts = await this.statsService.getDashboardCounts(preferredUsername); - const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl, visibleTiles); + const today = new Date().toLocaleDateString('de-DE', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + const counts = + await this.statsService.getDashboardCounts(preferredUsername); + const html = buildDigestHtml( + counts, + today, + this.appUrl, + this.agrarmonitorBaseUrl, + visibleTiles, + ); const plainText = buildDigestPlainText(counts, today, visibleTiles); await this.mailService.sendMail({ to: email, @@ -61,30 +124,50 @@ export class DailyDigestService { body: plainText, html, }); - this.logger.log(`Manueller Digest gesendet an ${email} (userId: ${userId})`); + this.logger.log( + `Manueller Digest gesendet an ${email} (userId: ${userId})`, + ); } - @Cron(process.env.DAILY_DIGEST_CRON || '0 7 * * *', { timeZone: 'Europe/Berlin' }) + @Cron(process.env.DAILY_DIGEST_CRON || '0 7 * * *', { + timeZone: 'Europe/Berlin', + }) async sendDailyDigests() { this.logger.log('Starte täglichen E-Mail-Digest...'); - const subscribers = await this.userSettingsService.findAllDigestSubscribers(); + const subscribers = + await this.userSettingsService.findAllDigestSubscribers(); if (subscribers.length === 0) { this.logger.log('Keine Abonnenten für den täglichen Digest.'); return; } - const today = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + const today = new Date().toLocaleDateString('de-DE', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); for (const sub of subscribers) { try { const visibleTiles = getVisibleTiles(sub.UserGroups); if (visibleTiles.length === 0) { - this.logger.warn(`Überspringe Digest für ${sub.UserEmail}: keine sichtbaren Kacheln`); + this.logger.warn( + `Überspringe Digest für ${sub.UserEmail}: keine sichtbaren Kacheln`, + ); continue; } - const counts = await this.statsService.getDashboardCounts(sub.UserPreferredUsername ?? undefined); - const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl, visibleTiles); + const counts = await this.statsService.getDashboardCounts( + sub.UserPreferredUsername ?? undefined, + ); + const html = buildDigestHtml( + counts, + today, + this.appUrl, + this.agrarmonitorBaseUrl, + visibleTiles, + ); const plainText = buildDigestPlainText(counts, today, visibleTiles); await this.mailService.sendMail({ to: sub.UserEmail!, @@ -94,41 +177,60 @@ export class DailyDigestService { }); this.logger.log(`Digest gesendet an ${sub.UserEmail}`); } catch (err) { - this.logger.error(`Fehler beim Senden des Digests an ${sub.UserEmail}: ${err.message}`); + this.logger.error( + `Fehler beim Senden des Digests an ${sub.UserEmail}: ${err.message}`, + ); } } } } -function tileUrl(tile: DigestTile, appUrl: string, agrarmonitorBaseUrl: string): string { - if (tile.key === 'agrarmonitor') return agrarmonitorBaseUrl ? `${agrarmonitorBaseUrl}/dateien/eingang#dateien` : ''; +function tileUrl( + tile: DigestTile, + appUrl: string, + agrarmonitorBaseUrl: string, +): string { + if (tile.key === 'agrarmonitor') + return agrarmonitorBaseUrl + ? `${agrarmonitorBaseUrl}/dateien/eingang#dateien` + : ''; return appUrl ? `${appUrl}/${tile.key === 'inbox' ? 'inbox' : tile.key}` : ''; } -function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string, agrarmonitorBaseUrl: string, visibleTiles: DigestTile[]): string { - const tiles = visibleTiles.map(t => ({ +function buildDigestHtml( + counts: DashboardCounts, + today: string, + appUrl: string, + agrarmonitorBaseUrl: string, + visibleTiles: DigestTile[], +): string { + const tiles = visibleTiles.map((t) => ({ ...t, url: tileUrl(t, appUrl, agrarmonitorBaseUrl), count: counts[t.key], })); const totalOpen = tiles.reduce((sum, t) => sum + t.count, 0); - const summaryText = totalOpen > 0 - ? `Sie haben ${totalOpen} offene Vorgänge in Ihren Bereichen.` - : 'Alle Bereiche sind auf dem aktuellen Stand. ✓'; + const summaryText = + totalOpen > 0 + ? `Sie haben ${totalOpen} offene Vorgänge in Ihren Bereichen.` + : 'Alle Bereiche sind auf dem aktuellen Stand. ✓'; - const cards = tiles.map(t => { - const badge = t.count > 0 - ? `${t.count}` - : ''; - const footerCount = t.count > 0 - ? `${t.count} offen` - : `Keine offenen Vorgänge`; - const openLink = t.url - ? `Öffnen ›` - : ''; + const cards = tiles + .map((t) => { + const badge = + t.count > 0 + ? `${t.count}` + : ''; + const footerCount = + t.count > 0 + ? `${t.count} offen` + : `Keine offenen Vorgänge`; + const openLink = t.url + ? `Öffnen ›` + : ''; - return ` + return `
@@ -153,7 +255,8 @@ function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string,
`; - }).join(''); + }) + .join(''); return ` @@ -197,8 +300,14 @@ function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string, `; } -function buildDigestPlainText(counts: DashboardCounts, today: string, visibleTiles: DigestTile[]): string { - const lines = visibleTiles.map(t => ` ${t.title.padEnd(22)} ${counts[t.key]}`).join('\n'); +function buildDigestPlainText( + counts: DashboardCounts, + today: string, + visibleTiles: DigestTile[], +): string { + const lines = visibleTiles + .map((t) => ` ${t.title.padEnd(22)} ${counts[t.key]}`) + .join('\n'); return `Paperless Manager – Tagesübersicht ${today} Offene Vorgänge: diff --git a/paperless-backend/src/database/entities/api-key.entity.ts b/paperless-backend/src/database/entities/api-key.entity.ts index 2d732b9..907b630 100644 --- a/paperless-backend/src/database/entities/api-key.entity.ts +++ b/paperless-backend/src/database/entities/api-key.entity.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('api_keys') export class ApiKey { diff --git a/paperless-backend/src/database/entities/attachment.entity.ts b/paperless-backend/src/database/entities/attachment.entity.ts index ffe7d51..6f3b084 100644 --- a/paperless-backend/src/database/entities/attachment.entity.ts +++ b/paperless-backend/src/database/entities/attachment.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToOne, JoinColumn, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; import { Email } from './email.entity'; import { Content } from './content.entity'; diff --git a/paperless-backend/src/database/entities/barcode-template.entity.ts b/paperless-backend/src/database/entities/barcode-template.entity.ts index 1454e8d..6c97e95 100644 --- a/paperless-backend/src/database/entities/barcode-template.entity.ts +++ b/paperless-backend/src/database/entities/barcode-template.entity.ts @@ -15,9 +15,25 @@ export interface LabelInputField { } export type LabelElement = - | { type: 'text'; content: string; x: number; y: number; fontSize: number; bold?: boolean; align?: 'left' | 'center' | 'right'; maxWidth?: number } - | { type: 'qr'; content: string; x: number; y: number; sizeMm: number } - | { type: 'line'; x1: number; y1: number; x2: number; y2: number; lineWidth?: number }; + | { + type: 'text'; + content: string; + x: number; + y: number; + fontSize: number; + bold?: boolean; + align?: 'left' | 'center' | 'right'; + maxWidth?: number; + } + | { type: 'qr'; content: string; x: number; y: number; sizeMm: number } + | { + type: 'line'; + x1: number; + y1: number; + x2: number; + y2: number; + lineWidth?: number; + }; @Entity('barcode_templates') export class BarcodeTemplate { diff --git a/paperless-backend/src/database/entities/content.entity.ts b/paperless-backend/src/database/entities/content.entity.ts index 9118870..76aa9c0 100644 --- a/paperless-backend/src/database/entities/content.entity.ts +++ b/paperless-backend/src/database/entities/content.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; import { Attachment } from './attachment.entity'; @Entity('Contents') diff --git a/paperless-backend/src/database/entities/email.entity.ts b/paperless-backend/src/database/entities/email.entity.ts index ce061dd..38e79a6 100644 --- a/paperless-backend/src/database/entities/email.entity.ts +++ b/paperless-backend/src/database/entities/email.entity.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, OneToMany } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + OneToMany, +} from 'typeorm'; import { Attachment } from './attachment.entity'; @Entity('Emails') diff --git a/paperless-backend/src/database/entities/postprocessing.entity.ts b/paperless-backend/src/database/entities/postprocessing.entity.ts index 43283a4..0094ae9 100644 --- a/paperless-backend/src/database/entities/postprocessing.entity.ts +++ b/paperless-backend/src/database/entities/postprocessing.entity.ts @@ -1,7 +1,7 @@ import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; export interface FilterCondition { - field: string; // 'document_type' | 'correspondent' | 'owner' | 'tag' | 'custom_field_' | 'title' | 'archive_serial_number' + field: string; // 'document_type' | 'correspondent' | 'owner' | 'tag' | 'custom_field_' | 'title' | 'archive_serial_number' operator: string; // 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'is_set' | 'is_not_set' value: any; } diff --git a/paperless-backend/src/database/entities/user-client.entity.ts b/paperless-backend/src/database/entities/user-client.entity.ts index 4fd63e9..ffb7317 100644 --- a/paperless-backend/src/database/entities/user-client.entity.ts +++ b/paperless-backend/src/database/entities/user-client.entity.ts @@ -11,6 +11,10 @@ export class UserClient { @Column({ type: 'int' }) ClientId!: number; - @Column({ type: 'enum', enum: ['viewer', 'editor', 'admin'], default: 'editor' }) + @Column({ + type: 'enum', + enum: ['viewer', 'editor', 'admin'], + default: 'editor', + }) Role!: 'viewer' | 'editor' | 'admin'; } diff --git a/paperless-backend/src/email-download/email-download.controller.ts b/paperless-backend/src/email-download/email-download.controller.ts index 82386e8..c69f16a 100644 --- a/paperless-backend/src/email-download/email-download.controller.ts +++ b/paperless-backend/src/email-download/email-download.controller.ts @@ -23,7 +23,8 @@ export class EmailDownloadController { @HttpCode(HttpStatus.OK) async backfillThumbnails() { this.logger.log('Manueller Backfill für Thumbnails wurde ausgelöst.'); - const result = await this.emailDownloadService.backfillThumbnailsForNewEmails(); + const result = + await this.emailDownloadService.backfillThumbnailsForNewEmails(); return { message: 'Backfill abgeschlossen.', result }; } } diff --git a/paperless-backend/src/email-download/email-download.module.ts b/paperless-backend/src/email-download/email-download.module.ts index d91219f..46ebf29 100644 --- a/paperless-backend/src/email-download/email-download.module.ts +++ b/paperless-backend/src/email-download/email-download.module.ts @@ -12,11 +12,10 @@ import { EmailModule } from '../email/email.module'; imports: [ TypeOrmModule.forFeature([Email, Attachment, Content]), PreprocessingModule, - EmailModule + EmailModule, ], controllers: [EmailDownloadController], providers: [EmailDownloadService], exports: [EmailDownloadService], }) export class EmailDownloadModule {} - diff --git a/paperless-backend/src/email-download/email-download.service.ts b/paperless-backend/src/email-download/email-download.service.ts index 2b81aac..a21283d 100644 --- a/paperless-backend/src/email-download/email-download.service.ts +++ b/paperless-backend/src/email-download/email-download.service.ts @@ -4,7 +4,11 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ImapFlow, type FetchMessageObject } from 'imapflow'; -import { simpleParser, type AddressObject, type Attachment as MailAttachment } from 'mailparser'; +import { + simpleParser, + type AddressObject, + type Attachment as MailAttachment, +} from 'mailparser'; import * as crypto from 'crypto'; import * as os from 'os'; import * as path from 'path'; @@ -26,8 +30,10 @@ export class EmailDownloadService { private readonly pdfService: PdfService, private readonly pageCache: EmailPageCacheService, @InjectRepository(Email) private readonly emailRepo: Repository, - @InjectRepository(Attachment) private readonly attachmentRepo: Repository, - @InjectRepository(Content) private readonly contentRepo: Repository, + @InjectRepository(Attachment) + private readonly attachmentRepo: Repository, + @InjectRepository(Content) + private readonly contentRepo: Repository, ) {} @Cron(CronExpression.EVERY_5_MINUTES) @@ -40,7 +46,10 @@ export class EmailDownloadService { try { await this.fetchAndStore(); } catch (err: any) { - this.logger.error(`Fehler im E-Mail-Download-Job: ${err.message}`, err.stack); + this.logger.error( + `Fehler im E-Mail-Download-Job: ${err.message}`, + err.stack, + ); } finally { this.running = false; } @@ -49,12 +58,15 @@ export class EmailDownloadService { private async fetchAndStore(): Promise { const host = this.configService.get('IMAP_HOST'); const port = this.configService.get('IMAP_PORT', 993); - const secure = this.configService.get('IMAP_USE_SSL', 'true') === 'true'; + const secure = + this.configService.get('IMAP_USE_SSL', 'true') === 'true'; const user = this.configService.get('IMAP_USERNAME'); const pass = this.configService.get('IMAP_PASSWORD'); if (!host || !user || !pass) { - this.logger.warn('IMAP-Konfiguration unvollständig – Job wird übersprungen.'); + this.logger.warn( + 'IMAP-Konfiguration unvollständig – Job wird übersprungen.', + ); return; } @@ -74,35 +86,47 @@ export class EmailDownloadService { const lock = await client.getMailboxLock('INBOX'); try { const status = await client.status('INBOX', { messages: true }); - this.logger.log(`Posteingang geöffnet. Anzahl der Nachrichten: ${status.messages ?? 0}`); + this.logger.log( + `Posteingang geöffnet. Anzahl der Nachrichten: ${status.messages ?? 0}`, + ); if (!status.messages || status.messages === 0) { return; } - const iter = client.fetch( - '1:*', - { envelope: true, uid: true, source: true }, - ); + const iter = client.fetch('1:*', { + envelope: true, + uid: true, + source: true, + }); for await (const msg of iter) { const messageId = msg.envelope?.messageId; if (!messageId) continue; try { - const existing = await this.emailRepo.findOne({ where: { MessageId: messageId } }); + const existing = await this.emailRepo.findOne({ + where: { MessageId: messageId }, + }); if (existing) { - this.logger.debug(`E-Mail mit MessageId ${messageId} bereits vorhanden.`); + this.logger.debug( + `E-Mail mit MessageId ${messageId} bereits vorhanden.`, + ); continue; } await this.processMessage(msg); } catch (err: any) { - this.logger.error(`Fehler beim Abrufen der Nachricht ${messageId}: ${err.message}`, err.stack); + this.logger.error( + `Fehler beim Abrufen der Nachricht ${messageId}: ${err.message}`, + err.stack, + ); } } - this.logger.log('Alle neuen E-Mails und deren Anhänge wurden in der Datenbank gespeichert.'); + this.logger.log( + 'Alle neuen E-Mails und deren Anhänge wurden in der Datenbank gespeichert.', + ); } finally { lock.release(); await client.logout().catch(() => undefined); @@ -119,12 +143,18 @@ export class EmailDownloadService { email.MessageId = messageId; email.SenderAddress = formatAddress(parsed.from); email.RecipientAddress = formatAddress(parsed.to); - email.Subject = (msg.envelope?.subject ?? parsed.subject ?? '').slice(0, 500); + email.Subject = (msg.envelope?.subject ?? parsed.subject ?? '').slice( + 0, + 500, + ); email.Date = msg.envelope?.date ?? parsed.date ?? new Date(); email.Body = parsed.html || parsed.text || ''; email.Status = 0; - const attachmentsToPersist: Array<{ attachment: Attachment; buffer: Buffer }> = []; + const attachmentsToPersist: Array<{ + attachment: Attachment; + buffer: Buffer; + }> = []; for (const att of parsed.attachments) { const entry = await this.buildAttachment(att); @@ -132,9 +162,13 @@ export class EmailDownloadService { } // Double-Check: nochmal gegen DB prüfen (Race-Condition-Schutz wie in C#) - const existing2 = await this.emailRepo.findOne({ where: { MessageId: messageId } }); + const existing2 = await this.emailRepo.findOne({ + where: { MessageId: messageId }, + }); if (existing2) { - this.logger.debug(`E-Mail mit MessageId ${messageId} nach dem Download bereits vorhanden.`); + this.logger.debug( + `E-Mail mit MessageId ${messageId} nach dem Download bereits vorhanden.`, + ); return; } @@ -152,55 +186,79 @@ export class EmailDownloadService { // Generate PDF thumbnails if it's a PDF if (savedAttachment.ContentType === 'application/pdf') { - await this.generateThumbnailsForAttachment(savedAttachment, buffer); + await this.generateThumbnailsForAttachment(savedAttachment, buffer); } } - this.logger.debug(`Neue E-Mail mit MessageId ${messageId} hinzugefügt (${attachmentsToPersist.length} Anhänge).`); + this.logger.debug( + `Neue E-Mail mit MessageId ${messageId} hinzugefügt (${attachmentsToPersist.length} Anhänge).`, + ); } - public async generateThumbnailsForAttachment(attachment: Attachment, buffer: Buffer): Promise { - try { - const tempPdfPath = path.join(os.tmpdir(), `email-att-${attachment.Id}.pdf`); - await fs.writeFile(tempPdfPath, buffer); - - const images = await this.pdfService.pdfToImages(tempPdfPath, 400); - await this.pageCache.generate(attachment.Id, images); - - attachment.PageCount = images.length; - await this.attachmentRepo.save(attachment); - - await this.pdfService.cleanup(images); - await fs.unlink(tempPdfPath).catch(() => {}); - } catch (err: any) { - this.logger.warn(`Konnte Vorschaubilder für Anhang ${attachment.Id} nicht generieren: ${err.message}`); - } + public async generateThumbnailsForAttachment( + attachment: Attachment, + buffer: Buffer, + ): Promise { + try { + const tempPdfPath = path.join( + os.tmpdir(), + `email-att-${attachment.Id}.pdf`, + ); + await fs.writeFile(tempPdfPath, buffer); + + const images = await this.pdfService.pdfToImages(tempPdfPath, 400); + await this.pageCache.generate(attachment.Id, images); + + attachment.PageCount = images.length; + await this.attachmentRepo.save(attachment); + + await this.pdfService.cleanup(images); + await fs.unlink(tempPdfPath).catch(() => {}); + } catch (err: any) { + this.logger.warn( + `Konnte Vorschaubilder für Anhang ${attachment.Id} nicht generieren: ${err.message}`, + ); + } } - public async backfillThumbnailsForNewEmails(): Promise<{ processed: number; failed: number }> { + public async backfillThumbnailsForNewEmails(): Promise<{ + processed: number; + failed: number; + }> { const emails = await this.emailRepo.find({ - where: { Status: 0 }, - relations: ['Attachments', 'Attachments.Content'] + where: { Status: 0 }, + relations: ['Attachments', 'Attachments.Content'], }); let processed = 0; let failed = 0; for (const email of emails) { - for (const attachment of email.Attachments) { - if (attachment.ContentType === 'application/pdf' && attachment.PageCount === 0 && attachment.Content?.Content1) { - this.logger.log(`Backfill: Generiere Thumbnails für Attachment ${attachment.Id} (Email ${email.Id})`); - try { - await this.generateThumbnailsForAttachment(attachment, attachment.Content.Content1); - processed++; - } catch (err) { - failed++; - } + for (const attachment of email.Attachments) { + if ( + attachment.ContentType === 'application/pdf' && + attachment.PageCount === 0 && + attachment.Content?.Content1 + ) { + this.logger.log( + `Backfill: Generiere Thumbnails für Attachment ${attachment.Id} (Email ${email.Id})`, + ); + try { + await this.generateThumbnailsForAttachment( + attachment, + attachment.Content.Content1, + ); + processed++; + } catch (err) { + failed++; } - } + } + } } - this.logger.log(`Backfill abgeschlossen: ${processed} erfolgreich, ${failed} fehlgeschlagen.`); + this.logger.log( + `Backfill abgeschlossen: ${processed} erfolgreich, ${failed} fehlgeschlagen.`, + ); return { processed, failed }; } @@ -234,14 +292,19 @@ export class EmailDownloadService { attachment.IsEmbedded = isEmbedded; attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null; attachment.Checksum = crypto.createHash('md5').update(buffer).digest('hex'); - attachment.Erechnung = contentType.toLowerCase() === 'application/pdf' ? isERechnung(buffer) : false; + attachment.Erechnung = + contentType.toLowerCase() === 'application/pdf' + ? isERechnung(buffer) + : false; attachment.ParentId = null; return { attachment, buffer }; } } -function formatAddress(addr: AddressObject | AddressObject[] | undefined): string { +function formatAddress( + addr: AddressObject | AddressObject[] | undefined, +): string { if (!addr) return ''; const first = Array.isArray(addr) ? addr[0] : addr; return (first?.text ?? '').slice(0, 255); diff --git a/paperless-backend/src/email-download/zugferd.util.ts b/paperless-backend/src/email-download/zugferd.util.ts index d4f9d27..f3aff1f 100644 --- a/paperless-backend/src/email-download/zugferd.util.ts +++ b/paperless-backend/src/email-download/zugferd.util.ts @@ -1,8 +1,4 @@ -const KNOWN_NAMES = [ - 'factur-x.xml', - 'zugferd-invoice.xml', - 'xrechnung.xml', -]; +const KNOWN_NAMES = ['factur-x.xml', 'zugferd-invoice.xml', 'xrechnung.xml']; export function isERechnung(pdfBuffer: Buffer): boolean { const asText = pdfBuffer.toString('latin1').toLowerCase(); diff --git a/paperless-backend/src/email/email-import.controller.ts b/paperless-backend/src/email/email-import.controller.ts index 3a7cdc0..7555c60 100644 --- a/paperless-backend/src/email/email-import.controller.ts +++ b/paperless-backend/src/email/email-import.controller.ts @@ -1,4 +1,16 @@ -import { Controller, Get, Post, Body, Param, Query, Res, HttpException, HttpStatus, Logger, Delete } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Body, + Param, + Query, + Res, + HttpException, + HttpStatus, + Logger, + Delete, +} from '@nestjs/common'; import type { Response } from 'express'; import { EmailImportService } from './email-import.service'; import { EmailPageCacheService } from './email-page-cache.service'; @@ -23,11 +35,19 @@ export class EmailImportController { @Post('mappings') @RequirePermissions(Permission.MANAGE_SETTINGS) - async addMapping(@Body() body: { emailAddress: string; paperlessCorrespondentId: number }) { + async addMapping( + @Body() body: { emailAddress: string; paperlessCorrespondentId: number }, + ) { if (!body.emailAddress || !body.paperlessCorrespondentId) { - throw new HttpException('Missing emailAddress or paperlessCorrespondentId', HttpStatus.BAD_REQUEST); + throw new HttpException( + 'Missing emailAddress or paperlessCorrespondentId', + HttpStatus.BAD_REQUEST, + ); } - return this.importService.addMapping(body.emailAddress, body.paperlessCorrespondentId); + return this.importService.addMapping( + body.emailAddress, + body.paperlessCorrespondentId, + ); } @Delete('mappings/:id') @@ -54,7 +74,11 @@ export class EmailImportController { @Get('belegnummer') @RequirePermissions(Permission.VIEW_MAIL) async getBelegnummer(@Query('date') date: string) { - if (!date) throw new HttpException('Date query parameter required', HttpStatus.BAD_REQUEST); + if (!date) + throw new HttpException( + 'Date query parameter required', + HttpStatus.BAD_REQUEST, + ); const nummer = await this.importService.getBelegnummer(date); return { nummer }; } @@ -62,7 +86,11 @@ export class EmailImportController { @Post('belegnummer/release') @RequirePermissions(Permission.VIEW_MAIL) async releaseBelegnummer(@Body() body: { date: string; number: string }) { - if (!body.date || !body.number) throw new HttpException('Date and number required', HttpStatus.BAD_REQUEST); + if (!body.date || !body.number) + throw new HttpException( + 'Date and number required', + HttpStatus.BAD_REQUEST, + ); await this.importService.releaseBelegnummer(body.date, body.number); return { success: true }; } @@ -74,7 +102,10 @@ export class EmailImportController { @Param('attachmentId') attachmentId: number, @Body() body: { pages: { start: number; end: number } }, ) { - const isDuplicate = await this.importService.checkSplitChecksum(attachmentId, body.pages); + const isDuplicate = await this.importService.checkSplitChecksum( + attachmentId, + body.pages, + ); return { isDuplicate }; } @@ -87,10 +118,18 @@ export class EmailImportController { @Res() res: Response, ) { try { - const pdfBuffer = await this.importService.generatePrintPdf(attachmentId, barcodeData); - this.logger.log(`Print preview generated for attachment ${attachmentId}: ${pdfBuffer.length} bytes`); + const pdfBuffer = await this.importService.generatePrintPdf( + attachmentId, + barcodeData, + ); + this.logger.log( + `Print preview generated for attachment ${attachmentId}: ${pdfBuffer.length} bytes`, + ); res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `inline; filename="preview-${attachmentId}.pdf"`); + res.setHeader( + 'Content-Disposition', + `inline; filename="preview-${attachmentId}.pdf"`, + ); res.send(pdfBuffer); } catch (err: any) { this.logger.error(`Error generating print preview: ${err.message}`); @@ -134,7 +173,9 @@ export class EmailImportController { @Get('jobs/:jobId/status') @RequirePermissions(Permission.VIEW_MAIL) getJobStatus(@Param('jobId') jobId: string) { - return this.importService.getJobStatus(jobId) ?? { message: '', done: false }; + return ( + this.importService.getJobStatus(jobId) ?? { message: '', done: false } + ); } // --- Final Import --- diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index be8c083..022b167 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -21,9 +21,16 @@ import * as crypto from 'crypto'; @Injectable() export class EmailImportService { private readonly logger = new Logger(EmailImportService.name); - private readonly importJobs = new Map(); + private readonly importJobs = new Map< + string, + { message: string; done: boolean } + >(); - private setJobStatus(jobId: string | undefined, message: string, done = false): void { + private setJobStatus( + jobId: string | undefined, + message: string, + done = false, + ): void { if (!jobId) return; this.importJobs.set(jobId, { message, done }); } @@ -35,9 +42,12 @@ export class EmailImportService { constructor( private readonly configService: ConfigService, @InjectRepository(Email) private readonly emailRepo: Repository, - @InjectRepository(Attachment) private readonly attachmentRepo: Repository, - @InjectRepository(Content) private readonly contentRepo: Repository, - @InjectRepository(CorrespondentEmailMapping) private readonly mappingRepo: Repository, + @InjectRepository(Attachment) + private readonly attachmentRepo: Repository, + @InjectRepository(Content) + private readonly contentRepo: Repository, + @InjectRepository(CorrespondentEmailMapping) + private readonly mappingRepo: Repository, @InjectRepository(Task) private readonly taskRepo: Repository, private readonly paperlessService: PaperlessService, private readonly pdfService: PdfService, @@ -53,8 +63,13 @@ export class EmailImportService { for (const attachment of attachments) { const hasPreview = await this.pageCache.hasPreview(attachment.Id, 1); if (!hasPreview && attachment.Content?.Content1) { - this.logger.log(`Generiere fehlende Vorschaubilder für Anhang ${attachment.Id} (Email ${emailId})`); - const tempPdfPath = path.join(os.tmpdir(), `email-att-gen-${attachment.Id}.pdf`); + this.logger.log( + `Generiere fehlende Vorschaubilder für Anhang ${attachment.Id} (Email ${emailId})`, + ); + const tempPdfPath = path.join( + os.tmpdir(), + `email-att-gen-${attachment.Id}.pdf`, + ); try { await fs.writeFile(tempPdfPath, attachment.Content.Content1); @@ -66,7 +81,9 @@ export class EmailImportService { await this.pdfService.cleanup(images); } catch (err: any) { - this.logger.warn(`Fehler bei on-demand Vorschau-Generierung für Anhang ${attachment.Id}: ${err.message}`); + this.logger.warn( + `Fehler bei on-demand Vorschau-Generierung für Anhang ${attachment.Id}: ${err.message}`, + ); } finally { await fs.unlink(tempPdfPath).catch(() => {}); } @@ -80,9 +97,14 @@ export class EmailImportService { } async addMapping(emailAddress: string, paperlessCorrespondentId: number) { - let mapping = await this.mappingRepo.findOne({ where: { EmailAddress: emailAddress } }); + let mapping = await this.mappingRepo.findOne({ + where: { EmailAddress: emailAddress }, + }); if (!mapping) { - mapping = this.mappingRepo.create({ EmailAddress: emailAddress, PaperlessCorrespondentId: paperlessCorrespondentId }); + mapping = this.mappingRepo.create({ + EmailAddress: emailAddress, + PaperlessCorrespondentId: paperlessCorrespondentId, + }); } else { mapping.PaperlessCorrespondentId = paperlessCorrespondentId; } @@ -94,114 +116,177 @@ export class EmailImportService { } async getCorrespondentByEmail(emailAddress: string): Promise { - const mapping = await this.mappingRepo.findOne({ where: { EmailAddress: emailAddress } }); + const mapping = await this.mappingRepo.findOne({ + where: { EmailAddress: emailAddress }, + }); return mapping ? mapping.PaperlessCorrespondentId : null; } // --- Belegnummern API --- private buildUrl(urlTemplate: string, dateStr: string): string { const dateObj = new Date(dateStr); - const year = (isNaN(dateObj.getTime()) ? new Date() : dateObj).getFullYear().toString(); + const year = (isNaN(dateObj.getTime()) ? new Date() : dateObj) + .getFullYear() + .toString(); return urlTemplate.replace('{Jahr}', year); } async getBelegnummer(emailDate: string): Promise { const urlTemplate = this.configService.get('BELEGNUMMER_GET_URL'); - if (!urlTemplate) throw new HttpException('BELEGNUMMER_GET_URL not configured', HttpStatus.INTERNAL_SERVER_ERROR); + if (!urlTemplate) + throw new HttpException( + 'BELEGNUMMER_GET_URL not configured', + HttpStatus.INTERNAL_SERVER_ERROR, + ); const url = this.buildUrl(urlTemplate, emailDate); try { this.logger.debug(`Fetching Belegnummer from ${url}`); const response = await axios.get(url); - + // If the response is an object, try to extract 'nummer' or 'number' let result = response.data; if (result && typeof result === 'object') { - result = result.nummer || result.number || result.data?.nummer || JSON.stringify(result); + result = + result.nummer || + result.number || + result.data?.nummer || + JSON.stringify(result); } - + this.logger.debug(`Received Belegnummer: ${result}`); return String(result); } catch (error: any) { const status = error.response?.status || 'UNKNOWN'; - const detail = error.response?.data ? JSON.stringify(error.response.data) : error.message; - this.logger.error(`Failed to fetch Belegnummer from ${url}. Status: ${status}, Detail: ${detail}`); - throw new HttpException(`Fehler beim Abrufen der Belegnummer: ${detail}`, HttpStatus.BAD_GATEWAY); + const detail = error.response?.data + ? JSON.stringify(error.response.data) + : error.message; + this.logger.error( + `Failed to fetch Belegnummer from ${url}. Status: ${status}, Detail: ${detail}`, + ); + throw new HttpException( + `Fehler beim Abrufen der Belegnummer: ${detail}`, + HttpStatus.BAD_GATEWAY, + ); } } async releaseBelegnummer(emailDate: string, number: string): Promise { - const urlTemplate = this.configService.get('BELEGNUMMER_RELEASE_URL'); + const urlTemplate = this.configService.get( + 'BELEGNUMMER_RELEASE_URL', + ); if (!urlTemplate) { - this.logger.warn('BELEGNUMMER_RELEASE_URL not configured, skipping release.'); + this.logger.warn( + 'BELEGNUMMER_RELEASE_URL not configured, skipping release.', + ); return; } const cleanNumber = number.replace(/^0+/, '') || '0'; let url = this.buildUrl(urlTemplate, emailDate); url = url.replace('{Nummer}', cleanNumber); - + try { - this.logger.log(`Releasing Belegnummer: ${cleanNumber} (original: ${number}) via ${url}`); + this.logger.log( + `Releasing Belegnummer: ${cleanNumber} (original: ${number}) via ${url}`, + ); await axios.get(url); } catch (error: any) { - this.logger.error(`Failed to release Belegnummer at ${url}: ${error.message}`); + this.logger.error( + `Failed to release Belegnummer at ${url}: ${error.message}`, + ); } } async setBelegnummer(emailDate: string, number: string): Promise { const urlTemplate = this.configService.get('BELEGNUMMER_SET_URL'); - if (!urlTemplate) throw new HttpException('BELEGNUMMER_SET_URL not configured', HttpStatus.INTERNAL_SERVER_ERROR); + if (!urlTemplate) + throw new HttpException( + 'BELEGNUMMER_SET_URL not configured', + HttpStatus.INTERNAL_SERVER_ERROR, + ); const cleanNumber = number.replace(/^0+/, '') || '0'; let url = this.buildUrl(urlTemplate, emailDate); url = url.replace('{Nummer}', cleanNumber); - + try { - this.logger.log(`Setting Belegnummer: ${cleanNumber} (original: ${number}) via ${url}`); + this.logger.log( + `Setting Belegnummer: ${cleanNumber} (original: ${number}) via ${url}`, + ); await axios.get(url); } catch (error: any) { - this.logger.error(`Failed to set Belegnummer at ${url}: ${error.message}`); - throw new HttpException('Fehler beim Setzen der Belegnummer', HttpStatus.BAD_GATEWAY); + this.logger.error( + `Failed to set Belegnummer at ${url}: ${error.message}`, + ); + throw new HttpException( + 'Fehler beim Setzen der Belegnummer', + HttpStatus.BAD_GATEWAY, + ); } } // --- Checksum Check for Split Documents --- - async checkSplitChecksum(attachmentId: number, pages: { start: number; end: number }): Promise { - const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachmentId } }); + async checkSplitChecksum( + attachmentId: number, + pages: { start: number; end: number }, + ): Promise { + const content = await this.contentRepo.findOne({ + where: { AttachmentEntityId: attachmentId }, + }); if (!content) return false; - const pdfDoc = await PDFDocument.load(content.Content1, { ignoreEncryption: true }); + const pdfDoc = await PDFDocument.load(content.Content1, { + ignoreEncryption: true, + }); const total = pdfDoc.getPageCount(); const startIdx = Math.max(1, pages.start) - 1; const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1; const sliced = await PDFDocument.create(); - const indices = Array.from({ length: endIdx - startIdx + 1 }, (_, i) => startIdx + i); + const indices = Array.from( + { length: endIdx - startIdx + 1 }, + (_, i) => startIdx + i, + ); const copied = await sliced.copyPages(pdfDoc, indices); - copied.forEach(p => sliced.addPage(p)); + copied.forEach((p) => sliced.addPage(p)); - const checksum = crypto.createHash('md5').update(Buffer.from(await sliced.save())).digest('hex'); + const checksum = crypto + .createHash('md5') + .update(Buffer.from(await sliced.save())) + .digest('hex'); return this.paperlessService.checksumExists(checksum); } // --- Print Preview --- - async generatePrintPdf(attachmentId: number, barcodeData: any): Promise { - const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachmentId } }); - if (!content) throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND); + async generatePrintPdf( + attachmentId: number, + barcodeData: any, + ): Promise { + const content = await this.contentRepo.findOne({ + where: { AttachmentEntityId: attachmentId }, + }); + if (!content) + throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND); let pdfBytes: Buffer = content.Content1; - const pages: { start: number; end: number } | undefined = barcodeData._pages; + const pages: { start: number; end: number } | undefined = + barcodeData._pages; if (pages) { - const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); + const pdfDoc = await PDFDocument.load(pdfBytes, { + ignoreEncryption: true, + }); const total = pdfDoc.getPageCount(); const startIdx = Math.max(1, pages.start) - 1; const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1; const sliced = await PDFDocument.create(); - const indices = Array.from({ length: endIdx - startIdx + 1 }, (_, i) => startIdx + i); + const indices = Array.from( + { length: endIdx - startIdx + 1 }, + (_, i) => startIdx + i, + ); const copied = await sliced.copyPages(pdfDoc, indices); - copied.forEach(p => sliced.addPage(p)); + copied.forEach((p) => sliced.addPage(p)); pdfBytes = Buffer.from(await sliced.save()); } @@ -210,18 +295,24 @@ export class EmailImportService { } async applyBarcodeToPdf(pdfBytes: Buffer, barcodeData: any): Promise { - this.logger.debug(`applyBarcodeToPdf: Input size = ${pdfBytes.length} bytes`); - + this.logger.debug( + `applyBarcodeToPdf: Input size = ${pdfBytes.length} bytes`, + ); + let currentPdfBytes = pdfBytes; const tempInputPath = path.join(os.tmpdir(), `input-${Date.now()}.pdf`); await fs.writeFile(tempInputPath, pdfBytes); try { // First try to load to check encryption - let pdfDoc = await PDFDocument.load(currentPdfBytes, { ignoreEncryption: true }); - + let pdfDoc = await PDFDocument.load(currentPdfBytes, { + ignoreEncryption: true, + }); + if (pdfDoc.isEncrypted) { - this.logger.log('PDF ist verschlüsselt, versuche Bereinigung via Ghostscript...'); + this.logger.log( + 'PDF ist verschlüsselt, versuche Bereinigung via Ghostscript...', + ); const sanitizedPath = await this.pdfService.sanitizePdf(tempInputPath); currentPdfBytes = await fs.readFile(sanitizedPath); await fs.unlink(sanitizedPath).catch(() => {}); @@ -230,107 +321,128 @@ export class EmailImportService { } const pages = pdfDoc.getPages(); - this.logger.debug(`applyBarcodeToPdf: Pages = ${pages.length}, Encrypted = ${pdfDoc.isEncrypted}`); - + this.logger.debug( + `applyBarcodeToPdf: Pages = ${pages.length}, Encrypted = ${pdfDoc.isEncrypted}`, + ); + if (pages.length === 0) { this.logger.warn('applyBarcodeToPdf: Keine Seiten gefunden'); return Buffer.from(await pdfDoc.save()); } - + const firstPage = pages[0]; - const { x, y, nummer, datum, jahr } = barcodeData; - - // Parse date - const d = new Date(datum); - const yyyy = (isNaN(d.getTime()) ? new Date() : d).getFullYear().toString(); - const mm = String((isNaN(d.getTime()) ? new Date() : d).getMonth() + 1).padStart(2, '0'); - const dd = String((isNaN(d.getTime()) ? new Date() : d).getDate()).padStart(2, '0'); - - const qrDateStr = `${yyyy}${mm}${dd}`; // yyyyMMdd - const qrContent = `${String(jahr).padStart(4, '0')}-${String(nummer).padStart(6, '0')}-${qrDateStr}`; - const printDateStr = `${dd}.${mm}.${yyyy}`; + const { x, y, nummer, datum, jahr } = barcodeData; - // Dimensions: 57x32 mm - const PT_PER_MM = 2.83465; - const boxW = 57 * PT_PER_MM; - const boxH = 32 * PT_PER_MM; - - // A4 dimensions: 210x297 mm - const PAGE_H_PT = 297 * PT_PER_MM; - - // Convert mm to points (Y is from bottom in pdf-lib) - const startX = Number(x) * PT_PER_MM; - const startY = PAGE_H_PT - (Number(y) * PT_PER_MM) - boxH; + // Parse date + const d = new Date(datum); + const yyyy = (isNaN(d.getTime()) ? new Date() : d) + .getFullYear() + .toString(); + const mm = String( + (isNaN(d.getTime()) ? new Date() : d).getMonth() + 1, + ).padStart(2, '0'); + const dd = String( + (isNaN(d.getTime()) ? new Date() : d).getDate(), + ).padStart(2, '0'); - // 1. Draw Background Box (White with Black border) - firstPage.drawRectangle({ - x: startX, - y: startY, - width: boxW, - height: boxH, - color: rgb(1, 1, 1), - borderColor: rgb(0, 0, 0), - borderWidth: 1, - }); + const qrDateStr = `${yyyy}${mm}${dd}`; // yyyyMMdd + const qrContent = `${String(jahr).padStart(4, '0')}-${String(nummer).padStart(6, '0')}-${qrDateStr}`; + const printDateStr = `${dd}.${mm}.${yyyy}`; - // 2. Draw QR Code - const qrBuffer = await QRCode.toBuffer(qrContent, { - errorCorrectionLevel: 'H', - margin: 0, - width: 300, - color: { dark: '#000000', light: '#FFFFFF' } - }); - const qrImage = await pdfDoc.embedPng(qrBuffer); - - // QR Code size: 27x27 mm (10% smaller than 30x30) - const qrSize = 27 * PT_PER_MM; - const padding = (32 - 27) / 2; // Center vertically in 32mm box - const qrX = startX + (padding * PT_PER_MM); - const qrY = startY + (padding * PT_PER_MM); - - firstPage.drawImage(qrImage, { - x: qrX, - y: qrY, - width: qrSize, - height: qrSize, - }); + // Dimensions: 57x32 mm + const PT_PER_MM = 2.83465; + const boxW = 57 * PT_PER_MM; + const boxH = 32 * PT_PER_MM; - // 3. Draw Texts - const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); - - // Helper to draw centered text in a specific area - const drawCenteredInArea = (text: string, relX: number, relY: number, areaW: number, areaH: number, fontSize: number) => { - const textWidth = helveticaBold.widthOfTextAtSize(text, fontSize); - const absX = startX + (relX * PT_PER_MM) + (areaW * PT_PER_MM / 2) - (textWidth / 2); - const absY = (startY + boxH) - (relY * PT_PER_MM) - (areaH * PT_PER_MM / 2) - (fontSize / 2.5); - - firstPage.drawText(text, { - x: absX, - y: absY, - size: fontSize, - font: helveticaBold, - color: rgb(0, 0, 0), + // A4 dimensions: 210x297 mm + const PAGE_H_PT = 297 * PT_PER_MM; + + // Convert mm to points (Y is from bottom in pdf-lib) + const startX = Number(x) * PT_PER_MM; + const startY = PAGE_H_PT - Number(y) * PT_PER_MM - boxH; + + // 1. Draw Background Box (White with Black border) + firstPage.drawRectangle({ + x: startX, + y: startY, + width: boxW, + height: boxH, + color: rgb(1, 1, 1), + borderColor: rgb(0, 0, 0), + borderWidth: 1, }); - }; - const isNeu = barcodeData.isNeu === true; + // 2. Draw QR Code + const qrBuffer = await QRCode.toBuffer(qrContent, { + errorCorrectionLevel: 'H', + margin: 0, + width: 300, + color: { dark: '#000000', light: '#FFFFFF' }, + }); + const qrImage = await pdfDoc.embedPng(qrBuffer); - // Text Area X: +33.3mm, Width: 21mm - // Year: Y + 3mm, Height: 7.5mm - drawCenteredInArea(String(jahr).padStart(4, '0'), 33.3, 3, 21, 7.5, 12); + // QR Code size: 27x27 mm (10% smaller than 30x30) + const qrSize = 27 * PT_PER_MM; + const padding = (32 - 27) / 2; // Center vertically in 32mm box + const qrX = startX + padding * PT_PER_MM; + const qrY = startY + padding * PT_PER_MM; - // Number: Y + 10.5mm, Height: 7.5mm - const numberText = isNeu ? '<- neu ->' : String(nummer).padStart(6, '0'); - drawCenteredInArea(numberText, 33.3, 10.5, 21, 7.5, 12); + firstPage.drawImage(qrImage, { + x: qrX, + y: qrY, + width: qrSize, + height: qrSize, + }); - // "Eingegangen": Y + 19mm, Height: 4mm, Size 8 - drawCenteredInArea('Eingegangen', 33.3, 19, 21, 4, 8); + // 3. Draw Texts + const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); - // Date: Y + 19 + 3.5 = 22.5mm, Height: 4mm, Size 8 - drawCenteredInArea(printDateStr, 33.3, 22.5, 21, 4, 8); + // Helper to draw centered text in a specific area + const drawCenteredInArea = ( + text: string, + relX: number, + relY: number, + areaW: number, + areaH: number, + fontSize: number, + ) => { + const textWidth = helveticaBold.widthOfTextAtSize(text, fontSize); + const absX = + startX + relX * PT_PER_MM + (areaW * PT_PER_MM) / 2 - textWidth / 2; + const absY = + startY + + boxH - + relY * PT_PER_MM - + (areaH * PT_PER_MM) / 2 - + fontSize / 2.5; - return Buffer.from(await pdfDoc.save()); + firstPage.drawText(text, { + x: absX, + y: absY, + size: fontSize, + font: helveticaBold, + color: rgb(0, 0, 0), + }); + }; + + const isNeu = barcodeData.isNeu === true; + + // Text Area X: +33.3mm, Width: 21mm + // Year: Y + 3mm, Height: 7.5mm + drawCenteredInArea(String(jahr).padStart(4, '0'), 33.3, 3, 21, 7.5, 12); + + // Number: Y + 10.5mm, Height: 7.5mm + const numberText = isNeu ? '<- neu ->' : String(nummer).padStart(6, '0'); + drawCenteredInArea(numberText, 33.3, 10.5, 21, 7.5, 12); + + // "Eingegangen": Y + 19mm, Height: 4mm, Size 8 + drawCenteredInArea('Eingegangen', 33.3, 19, 21, 4, 8); + + // Date: Y + 19 + 3.5 = 22.5mm, Height: 4mm, Size 8 + drawCenteredInArea(printDateStr, 33.3, 22.5, 21, 4, 8); + + return Buffer.from(await pdfDoc.save()); } finally { await fs.unlink(tempInputPath).catch(() => {}); } @@ -345,12 +457,20 @@ export class EmailImportService { paperlessCorrespondentId?: number | null; parentDocumentId?: number | null; splitRanges?: { start: number; end: number }[]; - barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string }; + barcode?: { + x: number; + y: number; + nummer: string; + datum: string; + jahr: string; + }; belegnummer?: string; }[]; emailDate: string; }): Promise<{ success: boolean; results: any[] }> { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'paperless-mail-import-')); + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'paperless-mail-import-'), + ); const results = []; this.setJobStatus(data.jobId, 'Dokumente werden vorbereitet...'); @@ -358,10 +478,14 @@ export class EmailImportService { for (const att of data.attachments) { if (att.type === 'IGNORE') continue; - const attachmentEntity = await this.attachmentRepo.findOne({ where: { Id: att.attachmentId } }); + const attachmentEntity = await this.attachmentRepo.findOne({ + where: { Id: att.attachmentId }, + }); if (!attachmentEntity) continue; - const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: att.attachmentId } }); + const content = await this.contentRepo.findOne({ + where: { AttachmentEntityId: att.attachmentId }, + }); if (!content) continue; const originalPdfBytes = content.Content1; @@ -375,26 +499,36 @@ export class EmailImportService { if (att.splitRanges && att.splitRanges.length > 0) { // SPLIT PDF - const pdfDoc = await PDFDocument.load(originalPdfBytes, { ignoreEncryption: true }); + const pdfDoc = await PDFDocument.load(originalPdfBytes, { + ignoreEncryption: true, + }); const totalPages = pdfDoc.getPageCount(); for (const range of att.splitRanges) { const start = Math.max(1, range.start); const end = Math.min(range.end, totalPages); - + if (start > end) { - this.logger.warn(`Ungültiger Bereich für Splitting: ${start}-${end} (Seiten gesamt: ${totalPages})`); + this.logger.warn( + `Ungültiger Bereich für Splitting: ${start}-${end} (Seiten gesamt: ${totalPages})`, + ); continue; } const newPdf = await PDFDocument.create(); // Pages are 0-indexed in pdf-lib - const pageIndices = Array.from({ length: end - start + 1 }, (_, i) => start - 1 + i); + const pageIndices = Array.from( + { length: end - start + 1 }, + (_, i) => start - 1 + i, + ); const copiedPages = await newPdf.copyPages(pdfDoc, pageIndices); copiedPages.forEach((p) => newPdf.addPage(p)); - + const splitPdfBytes = await newPdf.save(); - const tempFilePath = path.join(tempDir, `${baseFilename}_${start}-${end}.pdf`); + const tempFilePath = path.join( + tempDir, + `${baseFilename}_${start}-${end}.pdf`, + ); await fs.writeFile(tempFilePath, Buffer.from(splitPdfBytes)); uploadPromises.push({ @@ -423,20 +557,28 @@ export class EmailImportService { for (const uploadItem of uploadPromises) { const options: any = { filename: uploadItem.filename, - title: att.belegnummer ? `Beleg ${att.belegnummer}` : uploadItem.filename, + title: att.belegnummer + ? `Beleg ${att.belegnummer}` + : uploadItem.filename, created: createdDate, owner: null, }; - if (att.paperlessCorrespondentId) options.correspondent = att.paperlessCorrespondentId; + if (att.paperlessCorrespondentId) + options.correspondent = att.paperlessCorrespondentId; this.setJobStatus(data.jobId, `Lade ${uploadItem.filename} hoch...`); - const paperlessTaskId = await this.paperlessService.uploadDocument(uploadItem.path, options); + const paperlessTaskId = await this.paperlessService.uploadDocument( + uploadItem.path, + options, + ); // Create background task for enrichment (same logic as Inbox) const backgroundTask = this.taskRepo.create({ TaskId: paperlessTaskId, InterneBelegnummer: att.belegnummer || '', - Eingangsdatum: att.barcode?.datum ? new Date(att.barcode.datum) : createdDate, + Eingangsdatum: att.barcode?.datum + ? new Date(att.barcode.datum) + : createdDate, Belegdatum: createdDate, BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null, BetriebID: null, // Owner @@ -450,17 +592,25 @@ export class EmailImportService { // Still poll for Doc ID so we can return it to the frontend for immediate preview let docId = null; for (let i = 0; i < 30; i++) { - this.setJobStatus(data.jobId, `Warte auf Paperless-Verarbeitung... (${i + 1}/30)`); - await new Promise(resolve => setTimeout(resolve, 2000)); - try { - const taskStatus = await this.paperlessService.getTask(paperlessTaskId); - // Paperless returns { results: [ ... ] } for filtered tasks - const statusObj = taskStatus.results ? taskStatus.results[0] : (Array.isArray(taskStatus) ? taskStatus[0] : taskStatus); - if (statusObj && statusObj.related_document) { - docId = statusObj.related_document; - break; - } - } catch(e) {} + this.setJobStatus( + data.jobId, + `Warte auf Paperless-Verarbeitung... (${i + 1}/30)`, + ); + await new Promise((resolve) => setTimeout(resolve, 2000)); + try { + const taskStatus = + await this.paperlessService.getTask(paperlessTaskId); + // Paperless returns { results: [ ... ] } for filtered tasks + const statusObj = taskStatus.results + ? taskStatus.results[0] + : Array.isArray(taskStatus) + ? taskStatus[0] + : taskStatus; + if (statusObj && statusObj.related_document) { + docId = statusObj.related_document; + break; + } + } catch (e) {} } if (docId) { @@ -478,7 +628,9 @@ export class EmailImportService { // Confirm Belegnummer if used if (att.belegnummer && att.barcode?.nummer) { - await this.setBelegnummer(data.emailDate, att.barcode.nummer).catch(e => this.logger.warn(`Failed to set Belegnummer: ${e.message}`)); + await this.setBelegnummer(data.emailDate, att.barcode.nummer).catch( + (e) => this.logger.warn(`Failed to set Belegnummer: ${e.message}`), + ); } results.push({ attachmentId: att.attachmentId, paperlessIds }); @@ -486,12 +638,14 @@ export class EmailImportService { // Mark Email as processed (Status = 1) if (data.attachments.length > 0) { - const firstAtt = await this.attachmentRepo.findOne({ - where: { Id: data.attachments[0].attachmentId } + const firstAtt = await this.attachmentRepo.findOne({ + where: { Id: data.attachments[0].attachmentId }, }); if (firstAtt) { await this.emailRepo.update(firstAtt.EmailMessageId, { Status: 1 }); - this.logger.log(`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`); + this.logger.log( + `Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`, + ); } } @@ -500,7 +654,8 @@ export class EmailImportService { } finally { // Clean up temp dir and job status await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); - if (data.jobId) setTimeout(() => this.importJobs.delete(data.jobId!), 5000); + if (data.jobId) + setTimeout(() => this.importJobs.delete(data.jobId!), 5000); } } } diff --git a/paperless-backend/src/email/email-page-cache.service.ts b/paperless-backend/src/email/email-page-cache.service.ts index dd23b0b..80f8ff0 100644 --- a/paperless-backend/src/email/email-page-cache.service.ts +++ b/paperless-backend/src/email/email-page-cache.service.ts @@ -12,7 +12,10 @@ export class EmailPageCacheService { private readonly mailsRoot: string; constructor(configService: ConfigService) { - this.mailsRoot = configService.get('MAILS_DATA_DIR', '/mnt/data/mails'); + this.mailsRoot = configService.get( + 'MAILS_DATA_DIR', + '/mnt/data/mails', + ); } attachmentDir(attachmentId: number | string): string { @@ -20,14 +23,23 @@ export class EmailPageCacheService { } previewPath(attachmentId: number | string, page: number): string { - return path.join(this.attachmentDir(attachmentId), `page-${page}.preview.png`); + return path.join( + this.attachmentDir(attachmentId), + `page-${page}.preview.png`, + ); } thumbnailPath(attachmentId: number | string, page: number): string { - return path.join(this.attachmentDir(attachmentId), `page-${page}.thumb.png`); + return path.join( + this.attachmentDir(attachmentId), + `page-${page}.thumb.png`, + ); } - async generate(attachmentId: number | string, renderedImages: string[]): Promise { + async generate( + attachmentId: number | string, + renderedImages: string[], + ): Promise { const dir = this.attachmentDir(attachmentId); await fs.mkdir(dir, { recursive: true }); @@ -39,14 +51,22 @@ export class EmailPageCacheService { try { await fs.copyFile(src, previewDest); - await sharp(src).resize({ width: THUMBNAIL_WIDTH }).png().toFile(thumbDest); + await sharp(src) + .resize({ width: THUMBNAIL_WIDTH }) + .png() + .toFile(thumbDest); } catch (err: any) { - this.logger.warn(`E-Mail Page Cache fehlgeschlagen (Attachment ${attachmentId} Seite ${page}): ${err.message}`); + this.logger.warn( + `E-Mail Page Cache fehlgeschlagen (Attachment ${attachmentId} Seite ${page}): ${err.message}`, + ); } } } - async hasPreview(attachmentId: number | string, page: number): Promise { + async hasPreview( + attachmentId: number | string, + page: number, + ): Promise { try { await fs.access(this.previewPath(attachmentId, page)); return true; diff --git a/paperless-backend/src/email/email.controller.spec.ts b/paperless-backend/src/email/email.controller.spec.ts index 3ba7b41..79c5945 100644 --- a/paperless-backend/src/email/email.controller.spec.ts +++ b/paperless-backend/src/email/email.controller.spec.ts @@ -4,8 +4,26 @@ import { EmailController } from './email.controller'; import { Email } from '../database/entities/email.entity'; const mockEmails: Partial[] = [ - { Id: 1, MessageId: 'msg-1', SenderAddress: 'a@test.de', RecipientAddress: 'b@test.de', Subject: 'Test 1', Date: new Date(), Body: 'body', Status: 0 }, - { Id: 2, MessageId: 'msg-2', SenderAddress: 'c@test.de', RecipientAddress: 'd@test.de', Subject: 'Test 2', Date: new Date(), Body: 'body2', Status: 1 }, + { + Id: 1, + MessageId: 'msg-1', + SenderAddress: 'a@test.de', + RecipientAddress: 'b@test.de', + Subject: 'Test 1', + Date: new Date(), + Body: 'body', + Status: 0, + }, + { + Id: 2, + MessageId: 'msg-2', + SenderAddress: 'c@test.de', + RecipientAddress: 'd@test.de', + Subject: 'Test 2', + Date: new Date(), + Body: 'body2', + Status: 1, + }, ]; const mockQueryBuilder = { @@ -44,7 +62,9 @@ describe('EmailController', () => { it('getEmails filters by status', async () => { await controller.getEmails('1'); - expect(mockQueryBuilder.where).toHaveBeenCalledWith('e.Status = :status', { status: 1 }); + expect(mockQueryBuilder.where).toHaveBeenCalledWith('e.Status = :status', { + status: 1, + }); }); it('getEmail returns single item', async () => { diff --git a/paperless-backend/src/email/email.controller.ts b/paperless-backend/src/email/email.controller.ts index 50f2fd8..dc18b9b 100644 --- a/paperless-backend/src/email/email.controller.ts +++ b/paperless-backend/src/email/email.controller.ts @@ -1,4 +1,15 @@ -import { Controller, Get, Post, Param, Query, Res, Logger, NotFoundException, Patch, Body } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Param, + Query, + Res, + Logger, + NotFoundException, + Patch, + Body, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import type { Response } from 'express'; @@ -15,8 +26,10 @@ export class EmailController { constructor( @InjectRepository(Email) private readonly emailRepo: Repository, - @InjectRepository(Attachment) private readonly attachmentRepo: Repository, - @InjectRepository(Content) private readonly contentRepo: Repository, + @InjectRepository(Attachment) + private readonly attachmentRepo: Repository, + @InjectRepository(Content) + private readonly contentRepo: Repository, private readonly paperlessService: PaperlessService, ) {} @@ -26,7 +39,8 @@ export class EmailController { @Query('status') status?: string, @Query('limit') limit?: string, ) { - const qb = this.emailRepo.createQueryBuilder('e') + const qb = this.emailRepo + .createQueryBuilder('e') .leftJoinAndSelect('e.Attachments', 'a') .orderBy('e.Date', 'DESC') .take(parseInt(limit ?? '50', 10)); @@ -66,21 +80,28 @@ export class EmailController { const attachment = await this.attachmentRepo.findOne({ where: { Id: id } }); if (!attachment) throw new NotFoundException('Anhang nicht gefunden'); - const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: id } }); + const content = await this.contentRepo.findOne({ + where: { AttachmentEntityId: id }, + }); if (!content) throw new NotFoundException('Inhalt nicht gefunden'); - res.setHeader('Content-Type', attachment.ContentType || 'application/octet-stream'); - res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(attachment.FileName)}"`); + res.setHeader( + 'Content-Type', + attachment.ContentType || 'application/octet-stream', + ); + res.setHeader( + 'Content-Disposition', + `inline; filename="${encodeURIComponent(attachment.FileName)}"`, + ); res.send(content.Content1); } @Patch(':id/status') @RequirePermissions(Permission.MANAGE_ALL) - async updateStatus( - @Param('id') id: string, - @Body('status') status: number, - ) { - const email = await this.emailRepo.findOneOrFail({ where: { Id: parseInt(id, 10) } }); + async updateStatus(@Param('id') id: string, @Body('status') status: number) { + const email = await this.emailRepo.findOneOrFail({ + where: { Id: parseInt(id, 10) }, + }); email.Status = status; await this.emailRepo.save(email); this.logger.log(`E-Mail ${id} auf Status ${status} gesetzt.`); @@ -91,10 +112,14 @@ export class EmailController { @RequirePermissions(Permission.MANAGE_ALL) async checkAttachments(@Body() body: { includeProcessed?: boolean } = {}) { const { includeProcessed = false } = body; - this.logger.log(`Starte manuelle Prüfung der E-Mail-Anhänge in Paperless... (includeProcessed=${includeProcessed})`); + this.logger.log( + `Starte manuelle Prüfung der E-Mail-Anhänge in Paperless... (includeProcessed=${includeProcessed})`, + ); try { - const whereCondition = includeProcessed ? [{ Status: 0 }, { Status: 1 }] : { Status: 0 }; + const whereCondition = includeProcessed + ? [{ Status: 0 }, { Status: 1 }] + : { Status: 0 }; const emails = await this.emailRepo.find({ where: whereCondition, relations: ['Attachments'], @@ -116,25 +141,43 @@ export class EmailController { for (const attachment of email.Attachments) { // Prüfe nur PDFs mit Checksumme - if (attachment.ContentType === 'application/pdf' && attachment.Checksum) { - this.logger.debug(`Prüfe Checksumme für E-Mail ${email.Id}, Anhang ${attachment.Id} (${attachment.FileName})`); + if ( + attachment.ContentType === 'application/pdf' && + attachment.Checksum + ) { + this.logger.debug( + `Prüfe Checksumme für E-Mail ${email.Id}, Anhang ${attachment.Id} (${attachment.FileName})`, + ); try { - const docId = await this.paperlessService.getDocumentIdByChecksum(attachment.Checksum); + const docId = await this.paperlessService.getDocumentIdByChecksum( + attachment.Checksum, + ); if (docId !== null) { - this.logger.log(`Treffer! Anhang ${attachment.Id} (E-Mail ${email.Id}) → Paperless-Dokument ${docId}.`); + this.logger.log( + `Treffer! Anhang ${attachment.Id} (E-Mail ${email.Id}) → Paperless-Dokument ${docId}.`, + ); hasMatch = true; // PaperlessDocumentId hinterlegen, falls noch nicht vorhanden - const existingIds: Record = attachment.PaperlessDocumentIds ?? {}; + const existingIds: Record = + attachment.PaperlessDocumentIds ?? {}; if (!existingIds['full']) { - attachment.PaperlessDocumentIds = { ...existingIds, full: docId }; + attachment.PaperlessDocumentIds = { + ...existingIds, + full: docId, + }; await this.attachmentRepo.save(attachment); idsUpdated++; - this.logger.log(`Anhang ${attachment.Id}: PaperlessDocumentIds aktualisiert (full=${docId}).`); + this.logger.log( + `Anhang ${attachment.Id}: PaperlessDocumentIds aktualisiert (full=${docId}).`, + ); } } } catch (err: any) { - this.logger.error(`Fehler bei Checksummen-Prüfung für Attachment ${attachment.Id}: ${err.message}`, err.stack); + this.logger.error( + `Fehler bei Checksummen-Prüfung für Attachment ${attachment.Id}: ${err.message}`, + err.stack, + ); } } } @@ -144,18 +187,27 @@ export class EmailController { email.Status = 1; await this.emailRepo.save(email); updatedCount++; - this.logger.log(`E-Mail ${email.Id} auf Status 1 (Verarbeitet) gesetzt.`); + this.logger.log( + `E-Mail ${email.Id} auf Status 1 (Verarbeitet) gesetzt.`, + ); } if ((index + 1) % 10 === 0) { - this.logger.log(`Zwischenstand: ${index + 1} von ${emails.length} E-Mails geprüft.`); + this.logger.log( + `Zwischenstand: ${index + 1} von ${emails.length} E-Mails geprüft.`, + ); } } - this.logger.log(`Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`); + this.logger.log( + `Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`, + ); return { updatedCount, idsUpdated }; } catch (error: any) { - this.logger.error(`Kritischer Fehler bei checkAttachments: ${error.message}`, error.stack); + this.logger.error( + `Kritischer Fehler bei checkAttachments: ${error.message}`, + error.stack, + ); throw error; } } diff --git a/paperless-backend/src/email/email.module.ts b/paperless-backend/src/email/email.module.ts index 1409060..6b52873 100644 --- a/paperless-backend/src/email/email.module.ts +++ b/paperless-backend/src/email/email.module.ts @@ -15,7 +15,13 @@ import { PreprocessingModule } from '../preprocessing/preprocessing.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Email, Attachment, Content, CorrespondentEmailMapping, Task]), + TypeOrmModule.forFeature([ + Email, + Attachment, + Content, + CorrespondentEmailMapping, + Task, + ]), PaperlessModule, PreprocessingModule, ], diff --git a/paperless-backend/src/freigabe/freigabe.controller.ts b/paperless-backend/src/freigabe/freigabe.controller.ts index e64a964..f7e9c6e 100644 --- a/paperless-backend/src/freigabe/freigabe.controller.ts +++ b/paperless-backend/src/freigabe/freigabe.controller.ts @@ -22,10 +22,7 @@ export class FreigabeController { } @Put('documents/:id/freigabe') - setFreigabe( - @Param('id') id: string, - @Body('value') value: string | null, - ) { + setFreigabe(@Param('id') id: string, @Body('value') value: string | null) { return this.freigabeService.setFreigabe(parseInt(id, 10), value ?? null); } diff --git a/paperless-backend/src/freigabe/freigabe.module.ts b/paperless-backend/src/freigabe/freigabe.module.ts index aa60c45..c0d436e 100644 --- a/paperless-backend/src/freigabe/freigabe.module.ts +++ b/paperless-backend/src/freigabe/freigabe.module.ts @@ -6,10 +6,7 @@ import { FreigabeController } from './freigabe.controller'; import { FreigabeService } from './freigabe.service'; @Module({ - imports: [ - TypeOrmModule.forFeature([DocumentType]), - PaperlessModule, - ], + imports: [TypeOrmModule.forFeature([DocumentType]), PaperlessModule], controllers: [FreigabeController], providers: [FreigabeService], }) diff --git a/paperless-backend/src/freigabe/freigabe.service.ts b/paperless-backend/src/freigabe/freigabe.service.ts index 3f5c2c8..f0562a8 100644 --- a/paperless-backend/src/freigabe/freigabe.service.ts +++ b/paperless-backend/src/freigabe/freigabe.service.ts @@ -16,7 +16,11 @@ export class FreigabeService { private readonly paperlessService: PaperlessService, ) {} - async getFreigabeDocuments(page: number, pageSize: number, nurNichtFreigegeben: boolean) { + async getFreigabeDocuments( + page: number, + pageSize: number, + nurNichtFreigegeben: boolean, + ) { const docTypes = await this.documentTypeRepo.find({ where: { FreigabeErforderlich: true as any }, }); @@ -47,7 +51,9 @@ export class FreigabeService { } catch (err: any) { // Fallback: Paperless unterstützt den custom_fields-Filter möglicherweise nicht // In diesem Fall alle Belege laden und client-seitig filtern - this.logger.warn('custom_fields Filter nicht unterstützt, lade alle Belege und filtere lokal'); + this.logger.warn( + 'custom_fields Filter nicht unterstützt, lade alle Belege und filtere lokal', + ); const fallbackParams: Record = { page: 1, page_size: 9999, @@ -60,8 +66,15 @@ export class FreigabeService { if (nurNichtFreigegeben) { const filtered = results.filter((doc: any) => { - const cf = (doc.custom_fields ?? []).find((f: any) => f.field === FREIGABE_FIELD_ID); - return !cf || cf.value === null || cf.value === '' || cf.value === undefined; + const cf = (doc.custom_fields ?? []).find( + (f: any) => f.field === FREIGABE_FIELD_ID, + ); + return ( + !cf || + cf.value === null || + cf.value === '' || + cf.value === undefined + ); }); const start = (page - 1) * pageSize; return { @@ -82,20 +95,24 @@ export class FreigabeService { const doc = await this.paperlessService.getDocument(documentId); const customFields: any[] = [...(doc.custom_fields ?? [])]; - const existing = customFields.find((f: any) => f.field === FREIGABE_FIELD_ID); + const existing = customFields.find( + (f: any) => f.field === FREIGABE_FIELD_ID, + ); if (existing) { existing.value = value; } else if (value !== null && value !== '') { customFields.push({ field: FREIGABE_FIELD_ID, value }); } - await this.paperlessService.updateDocument(documentId, { custom_fields: customFields }); + await this.paperlessService.updateDocument(documentId, { + custom_fields: customFields, + }); return { success: true }; } async getFreigabeOptions(): Promise<{ id: string; label: string }[]> { const fields = await this.paperlessService.getCustomFields(); - const field = (fields as any[]).find((f: any) => f.id === FREIGABE_FIELD_ID); + const field = fields.find((f: any) => f.id === FREIGABE_FIELD_ID); if (!field) return []; const rawOptions: any[] = field.extra_data?.select_options ?? []; diff --git a/paperless-backend/src/inbox-postprocessor/edit-applier.ts b/paperless-backend/src/inbox-postprocessor/edit-applier.ts index b858c07..5b632ea 100644 --- a/paperless-backend/src/inbox-postprocessor/edit-applier.ts +++ b/paperless-backend/src/inbox-postprocessor/edit-applier.ts @@ -22,7 +22,7 @@ export async function applyEditsToTemp( if (!Number.isInteger(pageNum)) continue; const idx = pageNum - 1; if (idx < 0 || idx >= pdf.getPageCount()) continue; - const normalized = ((Math.round(rot / 90) * 90) % 360 + 360) % 360; + const normalized = (((Math.round(rot / 90) * 90) % 360) + 360) % 360; if (normalized === 0) continue; pdf.getPage(idx).setRotation(degrees(normalized)); } @@ -74,7 +74,7 @@ export async function buildSegmentBuffer( copied.forEach((page, i) => { const rot = rotations[String(segmentPages[i])]; if (rot !== undefined) { - const normalized = ((Math.round(rot / 90) * 90) % 360 + 360) % 360; + const normalized = (((Math.round(rot / 90) * 90) % 360) + 360) % 360; if (normalized !== 0) page.setRotation(degrees(normalized)); } outPdf.addPage(page); @@ -91,7 +91,7 @@ export async function extractSectionToTemp( const srcPdf = await PDFDocument.load(bytes, { ignoreEncryption: true }); const outPdf = await PDFDocument.create(); const copied = await outPdf.copyPages(srcPdf, pageIndices); - copied.forEach(p => outPdf.addPage(p)); + copied.forEach((p) => outPdf.addPage(p)); const out = await outPdf.save(); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inbox-section-')); const tmpPath = path.join(tmpDir, 'section.pdf'); diff --git a/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.module.ts b/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.module.ts index e59cfed..ad3d051 100644 --- a/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.module.ts +++ b/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.module.ts @@ -11,7 +11,12 @@ import { PostprocessingModule } from '../postprocessing/postprocessing.module'; @Module({ imports: [ - TypeOrmModule.forFeature([InboxPostprocessingAction, InboxDocument, BarcodeTemplate, Task]), + TypeOrmModule.forFeature([ + InboxPostprocessingAction, + InboxDocument, + BarcodeTemplate, + Task, + ]), BarcodeModule, PaperlessModule, PostprocessingModule, diff --git a/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts b/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts index a1ab8d1..2704547 100644 --- a/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts +++ b/paperless-backend/src/inbox-postprocessor/inbox-postprocessor.service.ts @@ -4,9 +4,7 @@ import { Repository } from 'typeorm'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; -import { - InboxPostprocessingAction, -} from '../database/entities/inbox-postprocessing-action.entity'; +import { InboxPostprocessingAction } from '../database/entities/inbox-postprocessing-action.entity'; import { InboxDocument } from '../database/entities/inbox-document.entity'; import { BarcodeTemplate } from '../database/entities/barcode-template.entity'; import { Task } from '../database/entities/task.entity'; @@ -14,7 +12,11 @@ import { PageCacheService } from '../barcode/page-cache.service'; import { PaperlessService } from '../paperless/paperless.service'; import { MailService } from '../postprocessing/mail.service'; import { ExportService } from '../postprocessing/export.service'; -import { applyEditsToTemp, cleanupTemp, extractSectionToTemp } from './edit-applier'; +import { + applyEditsToTemp, + cleanupTemp, + extractSectionToTemp, +} from './edit-applier'; import { applyTemplate, buildVariables } from './variable-resolver'; function parseFlexDate(s: string): Date | null { @@ -27,7 +29,9 @@ function parseFlexDate(s: string): Date | null { // German: DD.MM.YYYY const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); if (m) { - const d = new Date(`${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`); + const d = new Date( + `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`, + ); return isNaN(d.getTime()) ? null : d; } return null; @@ -96,21 +100,26 @@ export class InboxPostprocessorService { const templates = await this.templateRepo.find({ order: { Id: 'ASC' } }); const matchedTemplateIds = [ ...new Set( - doc.QrCodes - .map((qr) => { - const tpl = templates.find((t) => { - try { return new RegExp(t.Regex).test(qr.value); } - catch { return false; } - }); - return tpl?.Id ?? null; - }) - .filter((id): id is number => id !== null), + doc.QrCodes.map((qr) => { + const tpl = templates.find((t) => { + try { + return new RegExp(t.Regex).test(qr.value); + } catch { + return false; + } + }); + return tpl?.Id ?? null; + }).filter((id): id is number => id !== null), ), ]; - if (matchedTemplateIds.length === 0) return { results: [], totalSections: 0 }; + if (matchedTemplateIds.length === 0) + return { results: [], totalSections: 0 }; const actions = await this.actionRepo.find({ - where: matchedTemplateIds.map((tid) => ({ BarcodeTemplateId: tid, IsActive: true })), + where: matchedTemplateIds.map((tid) => ({ + BarcodeTemplateId: tid, + IsActive: true, + })), order: { BarcodeTemplateId: 'ASC', Order: 'ASC', Id: 'ASC' }, }); @@ -140,18 +149,31 @@ export class InboxPostprocessorService { } // Mapping: Original-Seite → 0-basierter Index in processedPdf const processedPageIndex = new Map(); - survivingOriginalPages.forEach((origPage, idx) => processedPageIndex.set(origPage, idx)); + survivingOriginalPages.forEach((origPage, idx) => + processedPageIndex.set(origPage, idx), + ); // QR-Codes mit ihrem Index in der verarbeiteten PDF (gelöschte QR-Seiten herausfiltern) - const qrsWithIdx = doc.QrCodes - .filter(qr => processedPageIndex.has(qr.page)) - .map(qr => ({ page: qr.page, value: qr.value, processedIdx: processedPageIndex.get(qr.page)! })) + const qrsWithIdx = doc.QrCodes.filter((qr) => + processedPageIndex.has(qr.page), + ) + .map((qr) => ({ + page: qr.page, + value: qr.value, + processedIdx: processedPageIndex.get(qr.page)!, + })) .sort((a, b) => a.processedIdx - b.processedIdx); // Split-Punkte: 0-basierte Indizes in der verarbeiteten PDF wo SplitBefore-Templates beginnen const splitPoints: number[] = []; for (const qr of qrsWithIdx) { - const tplMatch = templates.find(t => { try { return new RegExp(t.Regex).test(qr.value); } catch { return false; } }); + const tplMatch = templates.find((t) => { + try { + return new RegExp(t.Regex).test(qr.value); + } catch { + return false; + } + }); if (tplMatch?.SplitBefore) splitPoints.push(qr.processedIdx); } const processedPageCount = survivingOriginalPages.length; @@ -160,7 +182,13 @@ export class InboxPostprocessorService { let totalSections = 0; for (let i = 0; i < qrsWithIdx.length; i++) { const qr = qrsWithIdx[i]; - const tpl = templates.find(t => { try { return new RegExp(t.Regex).test(qr.value); } catch { return false; } }); + const tpl = templates.find((t) => { + try { + return new RegExp(t.Regex).test(qr.value); + } catch { + return false; + } + }); if (!tpl) continue; if (i > 0 && !tpl.SplitBefore) continue; totalSections++; @@ -177,7 +205,13 @@ export class InboxPostprocessorService { if (processedSection && processOnlyOne) break; const qr = qrsWithIdx[i]; - const tpl = templates.find(t => { try { return new RegExp(t.Regex).test(qr.value); } catch { return false; } }); + const tpl = templates.find((t) => { + try { + return new RegExp(t.Regex).test(qr.value); + } catch { + return false; + } + }); if (!tpl) continue; if (i > 0 && !tpl.SplitBefore) continue; @@ -191,14 +225,27 @@ export class InboxPostprocessorService { const tplActions = actionsByTemplate.get(tpl.Id); if (!tplActions || tplActions.length === 0) continue; - const variables = buildVariables({ doc, template: tpl, matchingQrValue: qr.value }); + const variables = buildVariables({ + doc, + template: tpl, + matchingQrValue: qr.value, + }); // Abschnitt aus der verarbeiteten PDF extrahieren const startIdx = qr.processedIdx; - const nextSplitIdx = splitPoints.find(sp => sp > startIdx); - const endIdx = nextSplitIdx !== undefined ? nextSplitIdx - 1 : processedPageCount - 1; - const pageIndices = Array.from({ length: endIdx - startIdx + 1 }, (_, i) => startIdx + i); - const sectionPdfPath = await extractSectionToTemp(processedPdfPath, pageIndices); + const nextSplitIdx = splitPoints.find((sp) => sp > startIdx); + const endIdx = + nextSplitIdx !== undefined + ? nextSplitIdx - 1 + : processedPageCount - 1; + const pageIndices = Array.from( + { length: endIdx - startIdx + 1 }, + (_, i) => startIdx + i, + ); + const sectionPdfPath = await extractSectionToTemp( + processedPdfPath, + pageIndices, + ); const defaultFilenameBase = tpl.DateinameTemplate ? applyTemplate(tpl.DateinameTemplate, variables) @@ -209,7 +256,13 @@ export class InboxPostprocessorService { if (abortProcessing) break; if (action.ActionType === 'PAPERLESS') { try { - const res = await this.runPaperless(action.Content ?? {}, sectionPdfPath, variables, defaultFilenameBase, replaceDuplicate); + const res = await this.runPaperless( + action.Content ?? {}, + sectionPdfPath, + variables, + defaultFilenameBase, + replaceDuplicate, + ); results.push({ sectionIndex: currentSectionIndex, actionId: action.Id, @@ -227,17 +280,40 @@ export class InboxPostprocessorService { this.logger.error( `Aktion PAPERLESS#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`, ); - results.push({ sectionIndex: currentSectionIndex, actionId: action.Id, actionType: action.ActionType, ok: false, message: err.message }); + results.push({ + sectionIndex: currentSectionIndex, + actionId: action.Id, + actionType: action.ActionType, + ok: false, + message: err.message, + }); } } else { try { - await this.runAction(action, sectionPdfPath, doc, variables, defaultFilenameBase); - results.push({ sectionIndex: currentSectionIndex, actionId: action.Id, actionType: action.ActionType, ok: true }); + await this.runAction( + action, + sectionPdfPath, + doc, + variables, + defaultFilenameBase, + ); + results.push({ + sectionIndex: currentSectionIndex, + actionId: action.Id, + actionType: action.ActionType, + ok: true, + }); } catch (err: any) { this.logger.error( `Aktion ${action.ActionType}#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`, ); - results.push({ sectionIndex: currentSectionIndex, actionId: action.Id, actionType: action.ActionType, ok: false, message: err.message }); + results.push({ + sectionIndex: currentSectionIndex, + actionId: action.Id, + actionType: action.ActionType, + ok: false, + message: err.message, + }); } } } @@ -265,7 +341,13 @@ export class InboxPostprocessorService { switch (action.ActionType) { case 'MAIL': - return this.runMail(content, pdfPath, doc, variables, defaultFilenameBase); + return this.runMail( + content, + pdfPath, + doc, + variables, + defaultFilenameBase, + ); case 'EXPORT': return this.runExport(content, pdfPath, variables, defaultFilenameBase); default: @@ -326,9 +408,13 @@ export class InboxPostprocessorService { ): Promise { // 1. Interne Belegnummer auflösen (Pflicht) const intNrTpl = String(content.interneBelegnummer ?? '').trim(); - if (!intNrTpl) throw new Error('Interne Belegnummer ist in der Aktion nicht konfiguriert'); + if (!intNrTpl) + throw new Error( + 'Interne Belegnummer ist in der Aktion nicht konfiguriert', + ); const interneBelegnummer = applyTemplate(intNrTpl, variables).trim(); - if (!interneBelegnummer) throw new Error('Interne Belegnummer konnte nicht aufgelöst werden'); + if (!interneBelegnummer) + throw new Error('Interne Belegnummer konnte nicht aufgelöst werden'); // 2. ASN bestimmen (vor Duplikat-Checks, damit Paperless danach gefragt werden kann) const asnTpl = String(content.asn ?? '').trim(); @@ -344,12 +430,18 @@ export class InboxPostprocessorService { } if (replaceDuplicate) { - this.logger.log(`Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`); + this.logger.log( + `Duplikat-Check übersprungen (replaceDuplicate=true) für ${interneBelegnummer}`, + ); } else { // 3. Duplikat-Check lokal (tasks-Tabelle) - const existingTask = await this.taskRepo.findOneBy({ InterneBelegnummer: interneBelegnummer }); + const existingTask = await this.taskRepo.findOneBy({ + InterneBelegnummer: interneBelegnummer, + }); if (existingTask) { - this.logger.warn(`Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`); + this.logger.warn( + `Duplikat (lokal): Belegnummer ${interneBelegnummer} bereits in tasks-Tabelle`, + ); return { skipped: true, message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`, @@ -358,9 +450,14 @@ export class InboxPostprocessorService { } // 4. Duplikat-Check Paperless API (Custom Field 7 = InterneBelegnummer) - const cf7DocId = await this.paperlessService.findDocumentIdByCustomField(7, interneBelegnummer); + const cf7DocId = await this.paperlessService.findDocumentIdByCustomField( + 7, + interneBelegnummer, + ); if (cf7DocId !== null) { - this.logger.warn(`Duplikat (Paperless CF7): Belegnummer ${interneBelegnummer} bereits in Paperless (Doc ${cf7DocId})`); + this.logger.warn( + `Duplikat (Paperless CF7): Belegnummer ${interneBelegnummer} bereits in Paperless (Doc ${cf7DocId})`, + ); return { skipped: true, message: `Duplikat – Belegnummer ${interneBelegnummer} bereits in Paperless`, @@ -370,9 +467,12 @@ export class InboxPostprocessorService { // 5. Duplikat-Check Paperless API (archive_serial_number) if (archiveSerialNumber !== undefined) { - const asnDocId = await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber); + const asnDocId = + await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber); if (asnDocId !== null) { - this.logger.warn(`Duplikat (Paperless ASN): ${archiveSerialNumber} bereits in Paperless (Doc ${asnDocId})`); + this.logger.warn( + `Duplikat (Paperless ASN): ${archiveSerialNumber} bereits in Paperless (Doc ${asnDocId})`, + ); return { skipped: true, message: `Duplikat – ASN ${archiveSerialNumber} bereits in Paperless`, @@ -384,10 +484,14 @@ export class InboxPostprocessorService { // 6. Checksum berechnen und prüfen const buffer = await fs.readFile(pdfPath); const checksum = crypto.createHash('md5').update(buffer).digest('hex'); - const checksumExists = await this.paperlessService.checksumExists(checksum); + const checksumExists = + await this.paperlessService.checksumExists(checksum); if (checksumExists) { this.logger.warn(`Duplikat (Checksum): ${checksum}`); - return { skipped: true, message: 'Duplikat (Checksum-Übereinstimmung)' }; + return { + skipped: true, + message: 'Duplikat (Checksum-Übereinstimmung)', + }; } } @@ -399,22 +503,30 @@ export class InboxPostprocessorService { : defaultFilenameBase || undefined; const tags = Array.isArray(content.tags) - ? content.tags.map((t: any) => Number(t)).filter((n: number) => Number.isFinite(n)) + ? content.tags + .map((t: any) => Number(t)) + .filter((n: number) => Number.isFinite(n)) : undefined; - const documentType = content.documentType ? Number(content.documentType) : undefined; - const correspondent = content.correspondent ? Number(content.correspondent) : undefined; - const owner = content.owner !== undefined && content.owner !== null && content.owner !== '' - ? Number(content.owner) + const documentType = content.documentType + ? Number(content.documentType) : undefined; + const correspondent = content.correspondent + ? Number(content.correspondent) + : undefined; + const owner = + content.owner !== undefined && + content.owner !== null && + content.owner !== '' + ? Number(content.owner) + : undefined; const rawCustomFields: Record | null = content.customFields && typeof content.customFields === 'object' ? Object.fromEntries( - Object.entries(content.customFields as Record).map(([k, v]) => [ - k, - applyTemplate(String(v ?? ''), variables), - ]), + Object.entries(content.customFields as Record).map( + ([k, v]) => [k, applyTemplate(String(v ?? ''), variables)], + ), ) : null; @@ -422,7 +534,9 @@ export class InboxPostprocessorService { const eingangsdatumTpl = String(content.eingangsdatum ?? '').trim(); let eingangsdatum: Date; if (eingangsdatumTpl) { - eingangsdatum = parseFlexDate(applyTemplate(eingangsdatumTpl, variables).trim()) ?? new Date(); + eingangsdatum = + parseFlexDate(applyTemplate(eingangsdatumTpl, variables).trim()) ?? + new Date(); } else if (rawCustomFields?.['9']) { const parsed = parseFlexDate(rawCustomFields['9']); if (parsed) { @@ -441,10 +555,12 @@ export class InboxPostprocessorService { // - User-konfigurierte Felder aus rawCustomFields const uploadCustomFields: Record = {}; if (rawCustomFields) { - for (const [k, v] of Object.entries(rawCustomFields)) uploadCustomFields[k] = v; + for (const [k, v] of Object.entries(rawCustomFields)) + uploadCustomFields[k] = v; } uploadCustomFields['7'] = interneBelegnummer; - uploadCustomFields['9'] = `${eingangsdatum.getFullYear()}-${String(eingangsdatum.getMonth() + 1).padStart(2, '0')}-${String(eingangsdatum.getDate()).padStart(2, '0')}`; + uploadCustomFields['9'] = + `${eingangsdatum.getFullYear()}-${String(eingangsdatum.getMonth() + 1).padStart(2, '0')}-${String(eingangsdatum.getDate()).padStart(2, '0')}`; // 6. Upload const taskIdRaw = await this.paperlessService.uploadDocument(pdfPath, { @@ -469,9 +585,10 @@ export class InboxPostprocessorService { Tags: tags && tags.length > 0 ? tags.join(',') : null, BetriebID: owner ?? null, externeBelegnummer: null, - CustomFieldsJson: rawCustomFields && Object.keys(rawCustomFields).length > 0 - ? JSON.stringify(rawCustomFields) - : null, + CustomFieldsJson: + rawCustomFields && Object.keys(rawCustomFields).length > 0 + ? JSON.stringify(rawCustomFields) + : null, Asn: asn || null, Lieferant: null, EinkaufID: null, @@ -482,7 +599,9 @@ export class InboxPostprocessorService { }); await this.taskRepo.save(task); - this.logger.log(`Dokument hochgeladen und Task angelegt: ${interneBelegnummer} → ${taskId}`); + this.logger.log( + `Dokument hochgeladen und Task angelegt: ${interneBelegnummer} → ${taskId}`, + ); return {}; } } diff --git a/paperless-backend/src/inbox-postprocessor/variable-resolver.ts b/paperless-backend/src/inbox-postprocessor/variable-resolver.ts index b6373f1..d72c4c2 100644 --- a/paperless-backend/src/inbox-postprocessor/variable-resolver.ts +++ b/paperless-backend/src/inbox-postprocessor/variable-resolver.ts @@ -58,7 +58,10 @@ export function buildVariables(ctx: ResolverContext): Record { * Ersetzt Platzhalter der Form `{name}` und `{name.group}` im Template. * Unbekannte Platzhalter bleiben unverändert. */ -export function applyTemplate(template: string, vars: Record): string { +export function applyTemplate( + template: string, + vars: Record, +): string { if (!template) return template; return template.replace(/\{([A-Za-z0-9_.]+)\}/g, (full, name: string) => { return name in vars ? vars[name] : full; diff --git a/paperless-backend/src/inbox/clients.controller.ts b/paperless-backend/src/inbox/clients.controller.ts index 1162836..8baa58b 100644 --- a/paperless-backend/src/inbox/clients.controller.ts +++ b/paperless-backend/src/inbox/clients.controller.ts @@ -10,13 +10,16 @@ export class ClientsController { constructor( @InjectRepository(Client) private readonly clientRepo: Repository, - @InjectRepository(UserClient) private readonly userClientRepo: Repository, + @InjectRepository(UserClient) + private readonly userClientRepo: Repository, ) {} @Get() async getMyClients(@Request() req: any) { const userId = req.user.userId; - const mappings = await this.userClientRepo.find({ where: { UserId: userId } }); + const mappings = await this.userClientRepo.find({ + where: { UserId: userId }, + }); const clientIds = mappings.map((m) => m.ClientId); if (clientIds.length === 0) { diff --git a/paperless-backend/src/inbox/inbox-migration.service.ts b/paperless-backend/src/inbox/inbox-migration.service.ts index e8bcf4d..737e1d2 100644 --- a/paperless-backend/src/inbox/inbox-migration.service.ts +++ b/paperless-backend/src/inbox/inbox-migration.service.ts @@ -28,17 +28,24 @@ export class InboxMigrationService implements OnApplicationBootstrap { @InjectRepository(InboxDocument) private readonly documentRepo: Repository, ) { - this.legacyRoot = this.configService.get('SCANS_DATA_DIR', '/mnt/data/scans'); + this.legacyRoot = this.configService.get( + 'SCANS_DATA_DIR', + '/mnt/data/scans', + ); } async onApplicationBootstrap(): Promise { let subdirs: string[]; try { - const entries = await fs.readdir(this.legacyRoot, { withFileTypes: true }); + const entries = await fs.readdir(this.legacyRoot, { + withFileTypes: true, + }); subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); } catch (err: any) { if (err.code !== 'ENOENT') { - this.logger.warn(`Migration: ${this.legacyRoot} nicht lesbar: ${err.message}`); + this.logger.warn( + `Migration: ${this.legacyRoot} nicht lesbar: ${err.message}`, + ); } return; } @@ -61,7 +68,9 @@ export class InboxMigrationService implements OnApplicationBootstrap { await this.migrateFile(src, subdir, name); migrated += 1; } catch (err: any) { - this.logger.error(`Migration fehlgeschlagen (${src}): ${err.message}`); + this.logger.error( + `Migration fehlgeschlagen (${src}): ${err.message}`, + ); } } @@ -75,7 +84,11 @@ export class InboxMigrationService implements OnApplicationBootstrap { } } - private async migrateFile(src: string, subdir: string, name: string): Promise { + private async migrateFile( + src: string, + subdir: string, + name: string, + ): Promise { const id = randomUUID(); const source: InboxSource = subdir === 'all' ? 'all' : 'user'; const owner = source === 'all' ? null : subdir; @@ -115,7 +128,9 @@ export class InboxMigrationService implements OnApplicationBootstrap { } } - private async loadLegacyQrCodes(oldFilePath: string): Promise { + private async loadLegacyQrCodes( + oldFilePath: string, + ): Promise { try { const rows = await this.dataSource.query( 'SELECT QrCodes FROM barcode_scans WHERE FilePath = ? LIMIT 1', diff --git a/paperless-backend/src/inbox/inbox.controller.ts b/paperless-backend/src/inbox/inbox.controller.ts index 7402f95..34a97d4 100644 --- a/paperless-backend/src/inbox/inbox.controller.ts +++ b/paperless-backend/src/inbox/inbox.controller.ts @@ -34,7 +34,8 @@ export class InboxController { @Get() async list(@Request() req: any) { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; return this.inboxService.listFiles(preferredUsername); } @@ -49,11 +50,18 @@ export class InboxController { @Request() req: any, @Res({ passthrough: true }) res: Response, ): Promise { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; - const { doc, pdfPath } = await this.inboxService.resolveDocument(id, preferredUsername); + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; + const { doc, pdfPath } = await this.inboxService.resolveDocument( + id, + preferredUsername, + ); res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `inline; filename="${doc.OriginalName}"`); + res.setHeader( + 'Content-Disposition', + `inline; filename="${doc.OriginalName}"`, + ); return new StreamableFile(createReadStream(pdfPath)); } @@ -64,8 +72,14 @@ export class InboxController { @Request() req: any, @Res({ passthrough: true }) res: Response, ): Promise { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; - const filePath = await this.inboxService.resolvePageImage(id, page, 'thumbnail', preferredUsername); + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; + const filePath = await this.inboxService.resolvePageImage( + id, + page, + 'thumbnail', + preferredUsername, + ); res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', 'private, max-age=3600'); @@ -75,7 +89,8 @@ export class InboxController { @Delete(':id') @HttpCode(204) async remove(@Param('id') id: string, @Request() req: any): Promise { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; await this.inboxService.deleteDocument(id, preferredUsername); } @@ -86,7 +101,8 @@ export class InboxController { @Param('page', ParseIntPipe) page: number, @Request() req: any, ): Promise { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; await this.inboxService.deletePage(id, page, preferredUsername); } @@ -97,14 +113,19 @@ export class InboxController { @Param('page', ParseIntPipe) page: number, @Request() req: any, ): Promise { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; await this.inboxService.toggleManualSplit(id, page, preferredUsername); } @Post(':id/reset-edits') @HttpCode(204) - async resetEdits(@Param('id') id: string, @Request() req: any): Promise { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; + async resetEdits( + @Param('id') id: string, + @Request() req: any, + ): Promise { + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; await this.inboxService.resetEdits(id, preferredUsername); } @@ -112,9 +133,15 @@ export class InboxController { async postprocess( @Param('id') id: string, @Request() req: any, - @Body() body: { sectionOffset?: number; processOnlyOne?: boolean; replaceDuplicate?: boolean }, + @Body() + body: { + sectionOffset?: number; + processOnlyOne?: boolean; + replaceDuplicate?: boolean; + }, ) { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; const { results, totalSections } = await this.postprocessor.runForDocument( id, preferredUsername, @@ -137,8 +164,14 @@ export class InboxController { if (!Number.isFinite(rotation)) { throw new BadRequestException('rotation muss eine Zahl sein'); } - const preferredUsername: string | null = req.user?.preferredUsername ?? null; - await this.inboxService.setPageRotation(id, page, rotation, preferredUsername); + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; + await this.inboxService.setPageRotation( + id, + page, + rotation, + preferredUsername, + ); } @Get(':id/pages/:page/preview') @@ -148,8 +181,14 @@ export class InboxController { @Request() req: any, @Res({ passthrough: true }) res: Response, ): Promise { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; - const filePath = await this.inboxService.resolvePageImage(id, page, 'preview', preferredUsername); + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; + const filePath = await this.inboxService.resolvePageImage( + id, + page, + 'preview', + preferredUsername, + ); res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', 'private, max-age=3600'); @@ -163,8 +202,17 @@ export class InboxController { @Body() body: { x: number; y: number; w: number; h: number }, @Request() req: any, ): Promise<{ found: string[] }> { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; - return this.inboxService.scanRegion(id, page, body.x, body.y, body.w, body.h, preferredUsername); + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; + return this.inboxService.scanRegion( + id, + page, + body.x, + body.y, + body.w, + body.h, + preferredUsername, + ); } @Post(':id/source') @@ -174,7 +222,8 @@ export class InboxController { @Body() body: { source: any }, @Request() req: any, ): Promise { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; await this.inboxService.updateSource(id, body.source, preferredUsername); } @@ -185,11 +234,19 @@ export class InboxController { @Request() req: any, @Res({ passthrough: true }) res: Response, ): Promise { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; - const { buffer, filename } = await this.inboxService.getSegmentPdfBuffer(id, preferredUsername, body.pages ?? []); + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; + const { buffer, filename } = await this.inboxService.getSegmentPdfBuffer( + id, + preferredUsername, + body.pages ?? [], + ); const { Readable } = await import('stream'); res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(filename)}"`, + ); return new StreamableFile(Readable.from(buffer)); } @@ -197,7 +254,8 @@ export class InboxController { @HttpCode(204) async sendEmail( @Param('id') id: string, - @Body() body: { + @Body() + body: { to: string; subject: string; body: string; @@ -207,10 +265,12 @@ export class InboxController { }, @Request() req: any, ): Promise { - const preferredUsername: string | null = req.user?.preferredUsername ?? null; - const smtpOverride = body.sender === 'user' - ? await this.userSettingsService.getSmtpConfig(req.user.userId) - : null; + const preferredUsername: string | null = + req.user?.preferredUsername ?? null; + const smtpOverride = + body.sender === 'user' + ? await this.userSettingsService.getSmtpConfig(req.user.userId) + : null; await this.inboxService.sendAsEmail(id, preferredUsername, { ...body, smtpOverride: smtpOverride ?? undefined, diff --git a/paperless-backend/src/inbox/inbox.service.ts b/paperless-backend/src/inbox/inbox.service.ts index 6cb23c9..cdba0ab 100644 --- a/paperless-backend/src/inbox/inbox.service.ts +++ b/paperless-backend/src/inbox/inbox.service.ts @@ -8,7 +8,10 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as fs from 'fs/promises'; -import { BarcodeScannerService, type MatchedBarcode } from '../barcode/barcode-scanner.service'; +import { + BarcodeScannerService, + type MatchedBarcode, +} from '../barcode/barcode-scanner.service'; import { PageCacheService } from '../barcode/page-cache.service'; import { InboxDocument, @@ -48,7 +51,14 @@ export class InboxService { async listFiles(preferredUsername: string | null): Promise { const where = preferredUsername - ? [{ Source: 'all' as InboxSource, IsScanned: true }, { Source: 'user' as InboxSource, OwnerUsername: preferredUsername, IsScanned: true }] + ? [ + { Source: 'all' as InboxSource, IsScanned: true }, + { + Source: 'user' as InboxSource, + OwnerUsername: preferredUsername, + IsScanned: true, + }, + ] : [{ Source: 'all' as InboxSource, IsScanned: true }]; const docs = await this.documentRepo.find({ @@ -64,7 +74,9 @@ export class InboxService { source: doc.Source, pageCount: doc.PageCount, deletedPages: [...(doc.DeletedPages ?? [])].sort((a, b) => a - b), - manualSplitPages: [...(doc.ManualSplitPages ?? [])].sort((a, b) => a - b), + manualSplitPages: [...(doc.ManualSplitPages ?? [])].sort( + (a, b) => a - b, + ), rotations: { ...(doc.Rotations ?? {}) }, barcodes: await this.barcodeScanner.getMatched(doc), createdAt: doc.CreatedAt.toISOString(), @@ -73,7 +85,10 @@ export class InboxService { return files; } - async resolveDocument(id: string, preferredUsername: string | null): Promise { + async resolveDocument( + id: string, + preferredUsername: string | null, + ): Promise { const doc = await this.documentRepo.findOne({ where: { Id: id } }); if (!doc) throw new NotFoundException('Dokument nicht gefunden'); if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) { @@ -85,7 +100,9 @@ export class InboxService { const stat = await fs.stat(pdfPath); if (!stat.isFile()) throw new Error('not a file'); } catch (err: any) { - this.logger.warn(`Datei fehlt trotz DB-Eintrag (${doc.Id}): ${err.message}`); + this.logger.warn( + `Datei fehlt trotz DB-Eintrag (${doc.Id}): ${err.message}`, + ); throw new NotFoundException('Dokument nicht gefunden'); } @@ -135,7 +152,7 @@ export class InboxService { if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) { throw new NotFoundException('Seite nicht gefunden'); } - const normalized = ((Math.round(rotation / 90) * 90) % 360 + 360) % 360; + const normalized = (((Math.round(rotation / 90) * 90) % 360) + 360) % 360; const next: Record = { ...(doc.Rotations ?? {}) }; if (normalized === 0) { delete next[String(page)]; @@ -149,7 +166,10 @@ export class InboxService { /** * Setzt alle markierten Bearbeitungen (DeletedPages, Rotations, ManualSplitPages) zurück. */ - async resetEdits(id: string, preferredUsername: string | null): Promise { + async resetEdits( + id: string, + preferredUsername: string | null, + ): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); let changed = false; if (doc.DeletedPages && doc.DeletedPages.length > 0) { @@ -170,7 +190,11 @@ export class InboxService { /** * Setzt oder entfernt einen manuellen Trennpunkt vor der angegebenen Seite. */ - async toggleManualSplit(id: string, page: number, preferredUsername: string | null): Promise { + async toggleManualSplit( + id: string, + page: number, + preferredUsername: string | null, + ): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); if (!Number.isInteger(page) || page < 2 || page > doc.PageCount) { throw new BadRequestException('Ungültige Seitennummer für Trennung'); @@ -185,14 +209,19 @@ export class InboxService { await this.documentRepo.save(doc); } - async deleteDocument(id: string, preferredUsername: string | null): Promise { + async deleteDocument( + id: string, + preferredUsername: string | null, + ): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); const dir = this.pageCache.documentDir(doc.Id); await this.documentRepo.delete(doc.Id); try { await fs.rm(dir, { recursive: true, force: true }); } catch (err: any) { - this.logger.warn(`Dokument-Ordner konnte nicht gelöscht werden (${dir}): ${err.message}`); + this.logger.warn( + `Dokument-Ordner konnte nicht gelöscht werden (${dir}): ${err.message}`, + ); } } @@ -231,7 +260,9 @@ export class InboxService { doc.OwnerUsername = null; } else { if (!preferredUsername) { - throw new BadRequestException('Benutzername erforderlich für persönlichen Scan'); + throw new BadRequestException( + 'Benutzername erforderlich für persönlichen Scan', + ); } doc.Source = 'user'; doc.OwnerUsername = preferredUsername; @@ -260,7 +291,9 @@ export class InboxService { ): Promise<{ buffer: Buffer; filename: string }> { const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername); const deleted = new Set(doc.DeletedPages ?? []); - const safePages = pages.filter((p) => p >= 1 && p <= doc.PageCount && !deleted.has(p)); + const safePages = pages.filter( + (p) => p >= 1 && p <= doc.PageCount && !deleted.has(p), + ); const buffer = await buildSegmentBuffer(doc, pdfPath, safePages); return { buffer, filename: doc.OriginalName }; } @@ -274,7 +307,14 @@ export class InboxService { body: string; html?: string; segments: { pages: number[]; filename: string }[]; - smtpOverride?: { host: string; port: number; secure: boolean; user: string; pass: string; from: string }; + smtpOverride?: { + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + from: string; + }; }, ): Promise { const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername); @@ -282,9 +322,13 @@ export class InboxService { const attachments = await Promise.all( opts.segments.map(async (seg) => { - const safePages = seg.pages.filter((p) => p >= 1 && p <= doc.PageCount && !deleted.has(p)); + const safePages = seg.pages.filter( + (p) => p >= 1 && p <= doc.PageCount && !deleted.has(p), + ); const content = await buildSegmentBuffer(doc, pdfPath, safePages); - const filename = seg.filename.endsWith('.pdf') ? seg.filename : `${seg.filename}.pdf`; + const filename = seg.filename.endsWith('.pdf') + ? seg.filename + : `${seg.filename}.pdf`; return { filename, content }; }), ); diff --git a/paperless-backend/src/kontonummern/kontonummern.controller.ts b/paperless-backend/src/kontonummern/kontonummern.controller.ts index af11747..256db68 100644 --- a/paperless-backend/src/kontonummern/kontonummern.controller.ts +++ b/paperless-backend/src/kontonummern/kontonummern.controller.ts @@ -22,4 +22,3 @@ export class KontonummernController { return this.kontonummernService.create(dto.correspondentId, dto.nummer); } } - diff --git a/paperless-backend/src/label-print-agent/label-print-agent.controller.ts b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts index 7d9d64f..47ffeff 100644 --- a/paperless-backend/src/label-print-agent/label-print-agent.controller.ts +++ b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts @@ -32,7 +32,10 @@ export class LabelPrintAgentController { @Body() body: { templateId: number; fieldValues?: Record }, @Res({ passthrough: true }) res: Response, ): Promise { - const buf = await this.service.renderPreview(body.templateId, body.fieldValues ?? {}); + const buf = await this.service.renderPreview( + body.templateId, + body.fieldValues ?? {}, + ); const { Readable } = await import('stream'); res.setHeader('Content-Type', 'image/png'); return new StreamableFile(Readable.from(buf)); @@ -45,16 +48,21 @@ export class LabelPrintAgentController { async createJob( @Body() body: { templateId: number; fieldValues?: Record }, ) { - const job = await this.service.createJob(body.templateId, body.fieldValues ?? {}); + const job = await this.service.createJob( + body.templateId, + body.fieldValues ?? {}, + ); return { jobId: String(job.Id) }; } // Agent: SSE-Stream für neue Druckaufträge @Sse('events') - sseEvents(@Res({ passthrough: true }) res: Response): Observable { + sseEvents( + @Res({ passthrough: true }) res: Response, + ): Observable { res.setHeader('X-Accel-Buffering', 'no'); return this.service.newJob$.pipe( - map(() => ({ data: { type: 'label-job-available' } } as MessageEvent)), + map(() => ({ data: { type: 'label-job-available' } }) as MessageEvent), ); } @@ -68,7 +76,9 @@ export class LabelPrintAgentController { } res.status(HttpStatus.OK).json({ jobId: String(job.Id), - labelImageBase64: job.LabelImageData ? job.LabelImageData.toString('base64') : null, + labelImageBase64: job.LabelImageData + ? job.LabelImageData.toString('base64') + : null, labelImageContentType: 'image/png', labelWidthMm: job.LabelWidthMm, labelHeightMm: job.LabelHeightMm, @@ -95,7 +105,11 @@ export class LabelPrintAgentController { @Param('id', ParseIntPipe) id: number, @Body() body: { agentId?: string; printerName?: string }, ) { - await this.service.markPrinted(id, body.agentId ?? 'unknown', body.printerName ?? ''); + await this.service.markPrinted( + id, + body.agentId ?? 'unknown', + body.printerName ?? '', + ); return { ok: true }; } @@ -104,9 +118,15 @@ export class LabelPrintAgentController { @HttpCode(HttpStatus.OK) async markError( @Param('id', ParseIntPipe) id: number, - @Body() body: { agentId?: string; printerName?: string; errorMessage?: string }, + @Body() + body: { agentId?: string; printerName?: string; errorMessage?: string }, ) { - await this.service.markError(id, body.agentId ?? 'unknown', body.printerName ?? '', body.errorMessage ?? ''); + await this.service.markError( + id, + body.agentId ?? 'unknown', + body.printerName ?? '', + body.errorMessage ?? '', + ); return { ok: true }; } } diff --git a/paperless-backend/src/label-print-agent/label-print-agent.service.ts b/paperless-backend/src/label-print-agent/label-print-agent.service.ts index 800ecdf..617bba2 100644 --- a/paperless-backend/src/label-print-agent/label-print-agent.service.ts +++ b/paperless-backend/src/label-print-agent/label-print-agent.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan, IsNull } from 'typeorm'; import { Subject, Observable } from 'rxjs'; @@ -56,9 +61,14 @@ export class LabelPrintAgentService { templateId: number, fieldValues: Record, ): Promise { - const template = await this.templateRepo.findOne({ where: { Id: templateId } }); + const template = await this.templateRepo.findOne({ + where: { Id: templateId }, + }); if (!template) throw new NotFoundException('Template nicht gefunden'); - if (!template.LabelEnabled) throw new BadRequestException('Etikett-Druck für dieses Template nicht aktiviert'); + if (!template.LabelEnabled) + throw new BadRequestException( + 'Etikett-Druck für dieses Template nicht aktiviert', + ); // Variablen aufbauen const vars: Record = { ...fieldValues }; @@ -127,7 +137,9 @@ export class LabelPrintAgentService { // Lazy render if (!candidate.LabelImageData) { - const template = await this.templateRepo.findOne({ where: { Id: candidate.BarcodeTemplateId ?? undefined } }); + const template = await this.templateRepo.findOne({ + where: { Id: candidate.BarcodeTemplateId ?? undefined }, + }); if (template?.LabelLayout?.length) { try { candidate.LabelImageData = await this.renderer.render( @@ -138,7 +150,9 @@ export class LabelPrintAgentService { ); await this.jobRepo.save(candidate); } catch (err: any) { - this.logger.error(`Label-Rendering fehlgeschlagen für Job ${candidate.Id}: ${err.message}`); + this.logger.error( + `Label-Rendering fehlgeschlagen für Job ${candidate.Id}: ${err.message}`, + ); } } } @@ -151,7 +165,11 @@ export class LabelPrintAgentService { return job?.LabelImageData ?? null; } - async markPrinted(jobId: number, agentId: string, printerName: string): Promise { + async markPrinted( + jobId: number, + agentId: string, + printerName: string, + ): Promise { const job = await this.jobRepo.findOne({ where: { Id: jobId } }); if (!job) throw new NotFoundException('Job nicht gefunden'); @@ -164,7 +182,12 @@ export class LabelPrintAgentService { await this.callUrl('PRINTED', job); } - async markError(jobId: number, agentId: string, printerName: string, errorMessage: string): Promise { + async markError( + jobId: number, + agentId: string, + printerName: string, + errorMessage: string, + ): Promise { const job = await this.jobRepo.findOne({ where: { Id: jobId } }); if (!job) throw new NotFoundException('Job nicht gefunden'); @@ -177,10 +200,16 @@ export class LabelPrintAgentService { await this.callUrl('RELEASE', job); } - async renderPreview(templateId: number, fieldValues: Record): Promise { - const template = await this.templateRepo.findOne({ where: { Id: templateId } }); + async renderPreview( + templateId: number, + fieldValues: Record, + ): Promise { + const template = await this.templateRepo.findOne({ + where: { Id: templateId }, + }); if (!template) throw new NotFoundException('Template nicht gefunden'); - if (!template.LabelLayout?.length) throw new BadRequestException('Kein Layout definiert'); + if (!template.LabelLayout?.length) + throw new BadRequestException('Kein Layout definiert'); const vars: Record = { ...fieldValues }; for (const field of template.LabelInputFields ?? []) { @@ -204,18 +233,26 @@ export class LabelPrintAgentService { ); } - private async callUrl(type: 'PRINTED' | 'RELEASE', job: LabelPrintJob): Promise { + private async callUrl( + type: 'PRINTED' | 'RELEASE', + job: LabelPrintJob, + ): Promise { const template = job.BarcodeTemplateId - ? await this.templateRepo.findOne({ where: { Id: job.BarcodeTemplateId } }) + ? await this.templateRepo.findOne({ + where: { Id: job.BarcodeTemplateId }, + }) : null; if (!template) return; - const urlTemplate = type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl; + const urlTemplate = + type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl; if (!urlTemplate) return; const url = applyVars(urlTemplate, job.LabelVariables ?? {}); if (!isSafeUrl(url)) { - this.logger.warn(`${type}-URL übersprungen (ungültiges Protokoll): ${url}`); + this.logger.warn( + `${type}-URL übersprungen (ungültiges Protokoll): ${url}`, + ); return; } try { diff --git a/paperless-backend/src/label-print-agent/label-renderer.service.ts b/paperless-backend/src/label-print-agent/label-renderer.service.ts index 4ca8071..4dba455 100644 --- a/paperless-backend/src/label-print-agent/label-renderer.service.ts +++ b/paperless-backend/src/label-print-agent/label-renderer.service.ts @@ -10,7 +10,11 @@ function mm(v: number): number { } function escape(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); } function applyVars(template: string, vars: Record): string { @@ -49,8 +53,15 @@ export class LabelRendererService { const fontSize = mm(el.fontSize); const content = escape(applyVars(el.content, variables)); const fontWeight = el.bold ? 'bold' : 'normal'; - const textAnchor = el.align === 'center' ? 'middle' : el.align === 'right' ? 'end' : 'start'; - const maxWidthAttr = el.maxWidth ? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"` : ''; + const textAnchor = + el.align === 'center' + ? 'middle' + : el.align === 'right' + ? 'end' + : 'start'; + const maxWidthAttr = el.maxWidth + ? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"` + : ''; // librsvg (used by sharp) ignores dominant-baseline; add fontSize to y so that // the stored coordinate is the top of the text, not the baseline. const yBaseline = y + fontSize; @@ -70,9 +81,13 @@ export class LabelRendererService { errorCorrectionLevel: 'M', }); const b64 = qrBuffer.toString('base64'); - parts.push(``); + parts.push( + ``, + ); } catch (err: any) { - this.logger.warn(`QR-Code-Rendering fehlgeschlagen für "${content}": ${err.message}`); + this.logger.warn( + `QR-Code-Rendering fehlgeschlagen für "${content}": ${err.message}`, + ); } } else if (el.type === 'line') { const x1 = mm(el.x1); @@ -80,7 +95,9 @@ export class LabelRendererService { const x2 = mm(el.x2); const y2 = mm(el.y2); const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1; - parts.push(``); + parts.push( + ``, + ); } } diff --git a/paperless-backend/src/main.ts b/paperless-backend/src/main.ts index 57e4274..4b7cc00 100644 --- a/paperless-backend/src/main.ts +++ b/paperless-backend/src/main.ts @@ -7,7 +7,9 @@ async function bootstrap(): Promise { const port = process.env.PORT ?? 3100; app.enableCors({ - origin: process.env.CORS_ORIGIN ?? (process.env.NODE_ENV === 'production' ? false : '*'), + origin: + process.env.CORS_ORIGIN ?? + (process.env.NODE_ENV === 'production' ? false : '*'), methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', credentials: true, }); diff --git a/paperless-backend/src/paperless/paperless-processor.service.ts b/paperless-backend/src/paperless/paperless-processor.service.ts index abe415a..9a54b6f 100644 --- a/paperless-backend/src/paperless/paperless-processor.service.ts +++ b/paperless-backend/src/paperless/paperless-processor.service.ts @@ -16,32 +16,48 @@ export class PaperlessProcessorService { private readonly configService: ConfigService, private readonly paperlessService: PaperlessService, private readonly postprocessingService: PostprocessingService, - @InjectRepository(DocumentType) private readonly docTypeRepo: Repository, - @InjectRepository(DocumentField) private readonly docFieldRepo: Repository, + @InjectRepository(DocumentType) + private readonly docTypeRepo: Repository, + @InjectRepository(DocumentField) + private readonly docFieldRepo: Repository, ) {} @Cron(process.env.PAPERLESS_PROCESSOR_CRON || '0 * * * * *') async processDocuments() { try { - const response = await this.paperlessService.getDocuments({ tags__id__all: 16, page_size: 9999 }); - const documents: any[] = Array.isArray(response) ? response : (response?.results ?? []); + const response = await this.paperlessService.getDocuments({ + tags__id__all: 16, + page_size: 9999, + }); + const documents: any[] = Array.isArray(response) + ? response + : (response?.results ?? []); if (documents.length === 0) return; const customFields = await this.paperlessService.getCustomFields(); const validFieldIds = new Set(customFields.map((f: any) => f.id)); - this.logger.log(`Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: 16).`); + this.logger.log( + `Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: 16).`, + ); for (const doc of documents) { try { - const updatedDoc = await this.processSingleDocument(doc, validFieldIds); - // Postprocessing nach dem Speichern evaluieren - await this.postprocessingService.evaluate(updatedDoc || doc); + const updatedDoc = await this.processSingleDocument( + doc, + validFieldIds, + ); + // Postprocessing nach dem Speichern evaluieren + await this.postprocessingService.evaluate(updatedDoc || doc); } catch (innerErr: any) { - this.logger.error(`Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`); - if (innerErr.response?.data) { - this.logger.error(`Paperless API Response: ${JSON.stringify(innerErr.response.data)}`); - } + this.logger.error( + `Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`, + ); + if (innerErr.response?.data) { + this.logger.error( + `Paperless API Response: ${JSON.stringify(innerErr.response.data)}`, + ); + } } } } catch (err) { @@ -49,17 +65,24 @@ export class PaperlessProcessorService { } } - private async processSingleDocument(doc: any, validFieldIds: Set): Promise { + private async processSingleDocument( + doc: any, + validFieldIds: Set, + ): Promise { this.logger.log(`Verarbeite Dokument ID: ${doc.id}`); if (!doc.document_type) { - this.logger.warn(`Dokument ${doc.id} hat keinen Dokumenten-Typen – setze Tag 17.`); + this.logger.warn( + `Dokument ${doc.id} hat keinen Dokumenten-Typen – setze Tag 17.`, + ); const tagsSet = new Set(doc.tags || []); tagsSet.add(17); if (!tagsSet.has(1)) { tagsSet.add(6); } - const updated = await this.paperlessService.updateDocument(doc.id, { tags: Array.from(tagsSet) }); + const updated = await this.paperlessService.updateDocument(doc.id, { + tags: Array.from(tagsSet), + }); return updated; } @@ -68,7 +91,9 @@ export class PaperlessProcessorService { }); if (!docTypeConfig) { - this.logger.warn(`Konfiguration für DocumentType ${doc.document_type} nicht in der Datenbank gefunden.`); + this.logger.warn( + `Konfiguration für DocumentType ${doc.document_type} nicht in der Datenbank gefunden.`, + ); return null; } @@ -77,9 +102,13 @@ export class PaperlessProcessorService { }); if (fieldsConfig.length === 0) { - this.logger.log(`Dokument ${doc.id} (Typ ${doc.document_type}) hat keine Dokument-Felder in der DB konfiguriert.`); + this.logger.log( + `Dokument ${doc.id} (Typ ${doc.document_type}) hat keine Dokument-Felder in der DB konfiguriert.`, + ); const newTagsNoFields = Array.from(new Set([...(doc.tags || []), 17])); - const updated = await this.paperlessService.updateDocument(doc.id, { tags: newTagsNoFields }); + const updated = await this.paperlessService.updateDocument(doc.id, { + tags: newTagsNoFields, + }); return updated; } @@ -91,15 +120,19 @@ export class PaperlessProcessorService { if (fieldConf.Type === 4) { const customFieldId = fieldConf.TypeIndex; if (!customFieldId) continue; - + if (!validFieldIds.has(customFieldId)) { - this.logger.warn(`Überspringe ungültiges Custom Field (TypeIndex: ${customFieldId}) für Dokument ${doc.id} - in Paperless nicht vorhanden.`); + this.logger.warn( + `Überspringe ungültiges Custom Field (TypeIndex: ${customFieldId}) für Dokument ${doc.id} - in Paperless nicht vorhanden.`, + ); continue; } - const existingField = newCustomFields.find(f => f.field === customFieldId); + const existingField = newCustomFields.find( + (f) => f.field === customFieldId, + ); let isFilled = false; - + if (existingField) { isFilled = existingField.value !== null && existingField.value !== ''; } else { @@ -114,13 +147,16 @@ export class PaperlessProcessorService { let isFilled = false; switch (fieldConf.Type) { case 1: - isFilled = doc.correspondent !== null && doc.correspondent !== undefined; + isFilled = + doc.correspondent !== null && doc.correspondent !== undefined; break; case 2: isFilled = !!doc.created || !!doc.created_date; break; case 3: - isFilled = doc.archive_serial_number !== null && doc.archive_serial_number !== undefined; + isFilled = + doc.archive_serial_number !== null && + doc.archive_serial_number !== undefined; break; case 5: isFilled = !!doc.title; @@ -136,13 +172,13 @@ export class PaperlessProcessorService { } const tagsSet = new Set(doc.tags || []); - + if (isAllRequiredFilled) { - if (docTypeConfig.TagReady) tagsSet.add(docTypeConfig.TagReady); - if (docTypeConfig.TagNotReady) tagsSet.delete(docTypeConfig.TagNotReady); + if (docTypeConfig.TagReady) tagsSet.add(docTypeConfig.TagReady); + if (docTypeConfig.TagNotReady) tagsSet.delete(docTypeConfig.TagNotReady); } else { - if (docTypeConfig.TagNotReady) tagsSet.add(docTypeConfig.TagNotReady); - if (docTypeConfig.TagReady) tagsSet.delete(docTypeConfig.TagReady); + if (docTypeConfig.TagNotReady) tagsSet.add(docTypeConfig.TagNotReady); + if (docTypeConfig.TagReady) tagsSet.delete(docTypeConfig.TagReady); } tagsSet.add(17); @@ -163,7 +199,7 @@ export class PaperlessProcessorService { while ((match = placeholderRegex.exec(title)) !== null) { const fieldId = parseInt(match[1], 10); - const cf = newCustomFields.find(f => f.field === fieldId); + const cf = newCustomFields.find((f) => f.field === fieldId); if (!cf || cf.value == null || cf.value === '') { allFilled = false; break; @@ -174,7 +210,10 @@ export class PaperlessProcessorService { for (const cf of newCustomFields) { const placeholder = `{{CUSTOM[${cf.field}]}}`; if (title.includes(placeholder)) { - title = title.replaceAll(placeholder, cf.value != null ? String(cf.value) : ''); + title = title.replaceAll( + placeholder, + cf.value != null ? String(cf.value) : '', + ); } } @@ -192,13 +231,20 @@ export class PaperlessProcessorService { updatePayload.title = title; } else { - this.logger.log(`Dokument ${doc.id}: Titel-Template nicht angewendet – nicht alle referenzierten Custom Fields ausgefüllt.`); + this.logger.log( + `Dokument ${doc.id}: Titel-Template nicht angewendet – nicht alle referenzierten Custom Fields ausgefüllt.`, + ); } } - const updated = await this.paperlessService.updateDocument(doc.id, updatePayload); + const updated = await this.paperlessService.updateDocument( + doc.id, + updatePayload, + ); - this.logger.log(`Dokument ${doc.id} erfolgreich aktualisiert (Alle Pflichtfelder vorhanden: ${isAllRequiredFilled}).`); + this.logger.log( + `Dokument ${doc.id} erfolgreich aktualisiert (Alle Pflichtfelder vorhanden: ${isAllRequiredFilled}).`, + ); return updated; } } diff --git a/paperless-backend/src/paperless/paperless-task-processor.service.ts b/paperless-backend/src/paperless/paperless-task-processor.service.ts index 8646e28..81cc97c 100644 --- a/paperless-backend/src/paperless/paperless-task-processor.service.ts +++ b/paperless-backend/src/paperless/paperless-task-processor.service.ts @@ -26,10 +26,7 @@ export class PaperlessTaskProcessorService { try { // Fetch tasks that are not finished const tasks = await this.taskRepo.find({ - where: [ - { Fertig: IsNull() }, - { Fertig: 0 }, - ], + where: [{ Fertig: IsNull() }, { Fertig: 0 }], take: 10, }); @@ -61,13 +58,17 @@ export class PaperlessTaskProcessorService { // Fetch task status from Paperless const paperlessTasks = await this.paperlessService.getTask(t.TaskId); - const apiResponseTask = Array.isArray(paperlessTasks) ? paperlessTasks[0] : null; + const apiResponseTask = Array.isArray(paperlessTasks) + ? paperlessTasks[0] + : null; if (apiResponseTask) { if (apiResponseTask.status === 'SUCCESS') { - const dateDone = apiResponseTask.date_done ? new Date(apiResponseTask.date_done) : new Date(); + const dateDone = apiResponseTask.date_done + ? new Date(apiResponseTask.date_done) + : new Date(); const now = new Date(); - + // Add 10 seconds buffer as in C# if (dateDone.getTime() + 10000 < now.getTime()) { await this.processSuccessfulTask(t, apiResponseTask, parentTask); @@ -87,38 +88,61 @@ export class PaperlessTaskProcessorService { this.logger.log(`${toDelete.length} Tasks gelöscht`); } } catch (error) { - this.logger.error(`Fehler bei der Task-Verarbeitung: ${error.message}`, error.stack); + this.logger.error( + `Fehler bei der Task-Verarbeitung: ${error.message}`, + error.stack, + ); } } - private async processSuccessfulTask(t: Task, apiTask: any, parentTask: Task | null) { + private async processSuccessfulTask( + t: Task, + apiTask: any, + parentTask: Task | null, + ) { const documentId = apiTask.related_document; - this.logger.log(`[Postprocessing] Task ${t.TaskId} gestartet. DocumentID: ${documentId ?? 'nicht vorhanden'}`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} gestartet. DocumentID: ${documentId ?? 'nicht vorhanden'}`, + ); if (!documentId) { - this.logger.error(`Kein Dokument für Task ${t.TaskId} gefunden.`); - return; + this.logger.error(`Kein Dokument für Task ${t.TaskId} gefunden.`); + return; } try { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Lade Dokument ${documentId} aus Paperless`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Lade Dokument ${documentId} aus Paperless`, + ); const document = await this.paperlessService.getDocument(documentId); if (!document) { - this.logger.warn(`Dokument mit ID ${documentId} nicht in Paperless gefunden.`); + this.logger.warn( + `Dokument mit ID ${documentId} nicht in Paperless gefunden.`, + ); return; } - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Dokument geladen: ID=${document.id}, Titel="${document.title}"`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Dokument geladen: ID=${document.id}, Titel="${document.title}"`, + ); // Handle Duplicate Link if (t.DuplikatZU) { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Verknüpfe Duplikat zu Dokument ${t.DuplikatZU}`); - const duplikatDoc = await this.paperlessService.getDocument(t.DuplikatZU); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Verknüpfe Duplikat zu Dokument ${t.DuplikatZU}`, + ); + const duplikatDoc = await this.paperlessService.getDocument( + t.DuplikatZU, + ); if (duplikatDoc) { // Update duplikatDoc metadata (Field 8 is for linked documents) - let duplikatCustomFields = Array.isArray(duplikatDoc.custom_fields) ? [...duplikatDoc.custom_fields] : []; + let duplikatCustomFields = Array.isArray(duplikatDoc.custom_fields) + ? [...duplikatDoc.custom_fields] + : []; // Remove field 4 as in C# - duplikatCustomFields = duplikatCustomFields.filter((f: any) => f.field !== 4); + duplikatCustomFields = duplikatCustomFields.filter( + (f: any) => f.field !== 4, + ); const field8 = duplikatCustomFields.find((f: any) => f.field === 8); if (field8) { @@ -136,13 +160,21 @@ export class PaperlessTaskProcessorService { document_type: 11, custom_fields: duplikatCustomFields, }); - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Duplikat-Dokument ${duplikatDoc.id} aktualisiert`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Duplikat-Dokument ${duplikatDoc.id} aktualisiert`, + ); // Update current document as well - const currentCustomFields = Array.isArray(document.custom_fields) ? [...document.custom_fields] : []; - const currentField8 = currentCustomFields.find((f: any) => f.field === 8); + const currentCustomFields = Array.isArray(document.custom_fields) + ? [...document.custom_fields] + : []; + const currentField8 = currentCustomFields.find( + (f: any) => f.field === 8, + ); if (currentField8) { - const values = Array.isArray(currentField8.value) ? currentField8.value : []; + const values = Array.isArray(currentField8.value) + ? currentField8.value + : []; if (!values.includes(duplikatDoc.id)) { values.push(duplikatDoc.id); currentField8.value = values; @@ -152,70 +184,108 @@ export class PaperlessTaskProcessorService { } document.custom_fields = currentCustomFields; } else { - this.logger.warn(`[Postprocessing] Task ${t.TaskId} - Duplikat-Dokument ${t.DuplikatZU} nicht gefunden`); + this.logger.warn( + `[Postprocessing] Task ${t.TaskId} - Duplikat-Dokument ${t.DuplikatZU} nicht gefunden`, + ); } } // Enrich Document - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Reichere Dokument-Metadaten an`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Reichere Dokument-Metadaten an`, + ); const updateData: any = { - custom_fields: Array.isArray(document.custom_fields) ? [...document.custom_fields] : [], + custom_fields: Array.isArray(document.custom_fields) + ? [...document.custom_fields] + : [], }; // CustomFieldsJson als Basis zuerst anwenden – dedizierte Felder weiter unten überschreiben diese if (t.CustomFieldsJson) { try { - const extra = JSON.parse(t.CustomFieldsJson) as Record; + const extra = JSON.parse(t.CustomFieldsJson) as Record< + string, + string + >; for (const [k, v] of Object.entries(extra)) { const fieldId = parseInt(k, 10); if (!Number.isFinite(fieldId)) continue; - const idx = updateData.custom_fields.findIndex((f: any) => f.field === fieldId); + const idx = updateData.custom_fields.findIndex( + (f: any) => f.field === fieldId, + ); if (idx !== -1) updateData.custom_fields[idx].value = v; else updateData.custom_fields.push({ field: fieldId, value: v }); } - } catch { /* JSON-Parse-Fehler ignorieren */ } + } catch { + /* JSON-Parse-Fehler ignorieren */ + } } if (t.Asn) { const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10); if (!isNaN(asnNum)) { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze ASN (explizit): ${asnNum}`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Setze ASN (explizit): ${asnNum}`, + ); updateData.archive_serial_number = asnNum; } } if (t.InterneBelegnummer) { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze InterneBelegnummer: ${t.InterneBelegnummer}`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Setze InterneBelegnummer: ${t.InterneBelegnummer}`, + ); if (!t.Asn) { - const asnFromBelegnummer = parseInt(t.InterneBelegnummer.replace(/-/g, ''), 10); + const asnFromBelegnummer = parseInt( + t.InterneBelegnummer.replace(/-/g, ''), + 10, + ); if (!isNaN(asnFromBelegnummer)) { updateData.archive_serial_number = asnFromBelegnummer; } else { - this.logger.warn(`[Postprocessing] Task ${t.TaskId} - ASN aus InterneBelegnummer konnte nicht geparst werden: ${t.InterneBelegnummer}`); + this.logger.warn( + `[Postprocessing] Task ${t.TaskId} - ASN aus InterneBelegnummer konnte nicht geparst werden: ${t.InterneBelegnummer}`, + ); } } - const existingField7 = updateData.custom_fields.find((f: any) => f.field === 7); + const existingField7 = updateData.custom_fields.find( + (f: any) => f.field === 7, + ); if (existingField7) { existingField7.value = t.InterneBelegnummer; } else { - updateData.custom_fields.push({ field: 7, value: t.InterneBelegnummer }); + updateData.custom_fields.push({ + field: 7, + value: t.InterneBelegnummer, + }); } } if (t.externeBelegnummer) { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze externeBelegnummer: ${t.externeBelegnummer}`); - const existingField3 = updateData.custom_fields.find((f: any) => f.field === 3); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Setze externeBelegnummer: ${t.externeBelegnummer}`, + ); + const existingField3 = updateData.custom_fields.find( + (f: any) => f.field === 3, + ); if (existingField3) { existingField3.value = t.externeBelegnummer; } else { - updateData.custom_fields.push({ field: 3, value: t.externeBelegnummer }); + updateData.custom_fields.push({ + field: 3, + value: t.externeBelegnummer, + }); } } if (t.Eingangsdatum) { const dateValue = new Date(t.Eingangsdatum).toISOString().split('T')[0]; - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Eingangsdatum: ${dateValue}`); - const existingField9 = updateData.custom_fields.find((f: any) => f.field === 9); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Setze Eingangsdatum: ${dateValue}`, + ); + const existingField9 = updateData.custom_fields.find( + (f: any) => f.field === 9, + ); if (existingField9) { existingField9.value = dateValue; } else { @@ -224,24 +294,38 @@ export class PaperlessTaskProcessorService { } if (t.DocumentType) { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze DocumentType: ${t.DocumentType}`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Setze DocumentType: ${t.DocumentType}`, + ); updateData.document_type = t.DocumentType; } // Parent Task / Attachment logic if (parentTask) { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Verarbeite als Anlage zu ParentTask ${parentTask.TaskId}`); - const parentPaperlessTasks = await this.paperlessService.getTask(parentTask.TaskId); - const apiParentTask = Array.isArray(parentPaperlessTasks) ? parentPaperlessTasks[0] : null; + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Verarbeite als Anlage zu ParentTask ${parentTask.TaskId}`, + ); + const parentPaperlessTasks = await this.paperlessService.getTask( + parentTask.TaskId, + ); + const apiParentTask = Array.isArray(parentPaperlessTasks) + ? parentPaperlessTasks[0] + : null; if (apiParentTask && apiParentTask.related_document) { - const parentDoc = await this.paperlessService.getDocument(apiParentTask.related_document); + const parentDoc = await this.paperlessService.getDocument( + apiParentTask.related_document, + ); if (parentDoc) { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Elterndokument ${parentDoc.id} gefunden, setze Anlage-Typ`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Elterndokument ${parentDoc.id} gefunden, setze Anlage-Typ`, + ); updateData.document_type = 5; // Anlage updateData.title = `Anlage zu ${parentTask.InterneBelegnummer}`; - const field8 = updateData.custom_fields.find((f: any) => f.field === 8); + const field8 = updateData.custom_fields.find( + (f: any) => f.field === 8, + ); if (field8) { const values = Array.isArray(field8.value) ? field8.value : []; if (!values.includes(parentDoc.id)) { @@ -249,33 +333,50 @@ export class PaperlessTaskProcessorService { field8.value = values; } } else { - updateData.custom_fields.push({ field: 8, value: [parentDoc.id] }); + updateData.custom_fields.push({ + field: 8, + value: [parentDoc.id], + }); } } else { - this.logger.warn(`[Postprocessing] Task ${t.TaskId} - Elterndokument nicht gefunden`); + this.logger.warn( + `[Postprocessing] Task ${t.TaskId} - Elterndokument nicht gefunden`, + ); } } else { - this.logger.warn(`[Postprocessing] Task ${t.TaskId} - ParentTask hat kein related_document`); + this.logger.warn( + `[Postprocessing] Task ${t.TaskId} - ParentTask hat kein related_document`, + ); } } if (t.Belegdatum) { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Belegdatum: ${t.Belegdatum.toISOString()}`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Setze Belegdatum: ${t.Belegdatum.toISOString()}`, + ); updateData.created = t.Belegdatum.toISOString(); } if (t.BetriebID) { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Owner: ${t.BetriebID}`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Setze Owner: ${t.BetriebID}`, + ); updateData.owner = t.BetriebID; } else { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`, + ); updateData.owner = null; } // Tags if (t.Tags) { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Tags: ${t.Tags}`); - const tagIds = t.Tags.split(',').map(id => parseInt(id.trim(), 10)).filter(id => !isNaN(id)); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Setze Tags: ${t.Tags}`, + ); + const tagIds = t.Tags.split(',') + .map((id) => parseInt(id.trim(), 10)) + .filter((id) => !isNaN(id)); const currentTags = document.tags || []; const newTags = Array.from(new Set([...currentTags, ...tagIds])); updateData.tags = newTags; @@ -284,46 +385,78 @@ export class PaperlessTaskProcessorService { // Agrarmonitor Link (Skip API call for now, but save the link if needed) if (t.EinkaufID) { const link = `https://admin7.agrarmonitor.de/rechnungen/detail/${t.EinkaufID}`; - this.logger.log(`Skipping Agrarmonitor details for EinkaufID ${t.EinkaufID}. Link: ${link}`); + this.logger.log( + `Skipping Agrarmonitor details for EinkaufID ${t.EinkaufID}. Link: ${link}`, + ); } if (t.Lieferant) { - this.logger.log(`Skipping Correspondent lookup/creation for Lieferant ID ${t.Lieferant} (Agrarmonitor part deferred).`); + this.logger.log( + `Skipping Correspondent lookup/creation for Lieferant ID ${t.Lieferant} (Agrarmonitor part deferred).`, + ); } // Update Document in Paperless - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Aktualisiere Dokument ${document.id} in Paperless. Payload: ${JSON.stringify(updateData)}`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Aktualisiere Dokument ${document.id} in Paperless. Payload: ${JSON.stringify(updateData)}`, + ); await this.paperlessService.updateDocument(document.id, updateData); - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Dokument ${document.id} erfolgreich aktualisiert`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Dokument ${document.id} erfolgreich aktualisiert`, + ); // Add Notes this.logger.log(`[Postprocessing] Task ${t.TaskId} - Füge Notizen hinzu`); - await this.paperlessService.addNote(document.id, `Task bearbeitet: ${new Date().toLocaleString('de-DE')}`); + await this.paperlessService.addNote( + document.id, + `Task bearbeitet: ${new Date().toLocaleString('de-DE')}`, + ); if (t.SourceAttachmentID) { - const attachment = await this.attachmentRepo.findOne({ where: { Id: t.SourceAttachmentID }, relations: ['EmailMessage'] }); + const attachment = await this.attachmentRepo.findOne({ + where: { Id: t.SourceAttachmentID }, + relations: ['EmailMessage'], + }); if (attachment) { - const rangePart = t.SourceAttachmentRange && t.SourceAttachmentRange !== 'full' - ? ` | Seiten: ${t.SourceAttachmentRange}` - : ''; - const messageId = attachment.EmailMessage?.MessageId ?? String(attachment.EmailMessageId); - await this.paperlessService.addNote(document.id, `E-Mail-ID: ${messageId} | Datei: ${attachment.FileName}${rangePart}`); - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Herkunfts-Notiz hinzugefügt`); + const rangePart = + t.SourceAttachmentRange && t.SourceAttachmentRange !== 'full' + ? ` | Seiten: ${t.SourceAttachmentRange}` + : ''; + const messageId = + attachment.EmailMessage?.MessageId ?? + String(attachment.EmailMessageId); + await this.paperlessService.addNote( + document.id, + `E-Mail-ID: ${messageId} | Datei: ${attachment.FileName}${rangePart}`, + ); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Herkunfts-Notiz hinzugefügt`, + ); } } if (t.BarcodeJson) { await this.paperlessService.addNote(document.id, t.BarcodeJson); - this.logger.log(`[Postprocessing] Task ${t.TaskId} - BarcodeJson-Notiz hinzugefügt`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - BarcodeJson-Notiz hinzugefügt`, + ); } // Sync local Documents table - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Synchronisiere lokale Documents-Tabelle`); - const metadata = await this.paperlessService.getDocumentMetadata(document.id); - let localDoc = await this.documentRepo.findOne({ where: { documentId: document.id } }); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Synchronisiere lokale Documents-Tabelle`, + ); + const metadata = await this.paperlessService.getDocumentMetadata( + document.id, + ); + let localDoc = await this.documentRepo.findOne({ + where: { documentId: document.id }, + }); if (!localDoc) { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Erstelle neuen lokalen Dokument-Eintrag`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Erstelle neuen lokalen Dokument-Eintrag`, + ); localDoc = this.documentRepo.create({ documentId: document.id, checksum: metadata.original_checksum, @@ -331,34 +464,46 @@ export class PaperlessTaskProcessorService { }); await this.documentRepo.save(localDoc); } else { - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Aktualisiere bestehenden lokalen Dokument-Eintrag`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Aktualisiere bestehenden lokalen Dokument-Eintrag`, + ); localDoc.checksum = metadata.original_checksum; localDoc.filename = metadata.original_filename; await this.documentRepo.save(localDoc); } // Update Task status - this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Task-Status auf Fertig`); + this.logger.log( + `[Postprocessing] Task ${t.TaskId} - Setze Task-Status auf Fertig`, + ); t.Fertig = 1; t.PaperlessDocumentID = document.id; await this.taskRepo.save(t); // Update source attachment if linked if (t.SourceAttachmentID && t.SourceAttachmentRange) { - const attachment = await this.attachmentRepo.findOne({ where: { Id: t.SourceAttachmentID } }); + const attachment = await this.attachmentRepo.findOne({ + where: { Id: t.SourceAttachmentID }, + }); if (attachment) { const ids = attachment.PaperlessDocumentIds || {}; ids[t.SourceAttachmentRange] = document.id; attachment.PaperlessDocumentIds = ids; await this.attachmentRepo.save(attachment); - this.logger.log(`[Postprocessing] Anhang ${attachment.Id} mit PaperlessID ${document.id} (${t.SourceAttachmentRange}) aktualisiert`); + this.logger.log( + `[Postprocessing] Anhang ${attachment.Id} mit PaperlessID ${document.id} (${t.SourceAttachmentRange}) aktualisiert`, + ); } } - this.logger.log(`[Postprocessing] Task ${t.TaskId} erfolgreich abgeschlossen. Dokument ID: ${document.id}`); - + this.logger.log( + `[Postprocessing] Task ${t.TaskId} erfolgreich abgeschlossen. Dokument ID: ${document.id}`, + ); } catch (error) { - this.logger.error(`Fehler bei der Verarbeitung von Task ${t.TaskId}: ${error.message}`, error.stack); + this.logger.error( + `Fehler bei der Verarbeitung von Task ${t.TaskId}: ${error.message}`, + error.stack, + ); } } } diff --git a/paperless-backend/src/paperless/paperless.controller.ts b/paperless-backend/src/paperless/paperless.controller.ts index e129f42..2cc83c5 100644 --- a/paperless-backend/src/paperless/paperless.controller.ts +++ b/paperless-backend/src/paperless/paperless.controller.ts @@ -1,4 +1,20 @@ -import { Controller, Get, Param, Post, Put, Delete, UseGuards, UseInterceptors, UploadedFile, Body, Logger, HttpException, HttpStatus, Res, Query } from '@nestjs/common'; +import { + Controller, + Get, + Param, + Post, + Put, + Delete, + UseGuards, + UseInterceptors, + UploadedFile, + Body, + Logger, + HttpException, + HttpStatus, + Res, + Query, +} from '@nestjs/common'; import type { Response } from 'express'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @@ -14,7 +30,6 @@ import { Document } from '../database/entities/document.entity'; import { DocumentField } from '../database/entities/document-field.entity'; import { DocumentType } from '../database/entities/document-type.entity'; - @Controller('api/paperless') export class PaperlessController { private readonly logger = new Logger(PaperlessController.name); @@ -36,7 +51,7 @@ export class PaperlessController { const exists = await this.paperlessService.checksumExists(checksum); return { exists }; } - + @Get('documents') async getDocuments( @Query('search') search?: string, @@ -67,7 +82,7 @@ export class PaperlessController { async getTag(@Param('id') id: string) { // If the service doesn't have getTag(id), I should add it or just fetch all and find const tags = await this.paperlessService.getTags(); - return tags.find(t => t.id === parseInt(id, 10)); + return tags.find((t) => t.id === parseInt(id, 10)); } @Get('document-types') @@ -107,7 +122,11 @@ export class PaperlessController { const documents = await this.paperlessService.getInboxDocuments(); // In old C# logic: only return docs where archive_serial_number is not null return documents - .filter((doc: any) => doc.archive_serial_number !== null && doc.archive_serial_number !== undefined) + .filter( + (doc: any) => + doc.archive_serial_number !== null && + doc.archive_serial_number !== undefined, + ) .map((doc: any) => ({ id: doc.id, title: doc.title, @@ -127,7 +146,11 @@ export class PaperlessController { async getManuellList() { const documents = await this.paperlessService.getManuellDocuments(); return documents - .filter((doc: any) => doc.archive_serial_number !== null && doc.archive_serial_number !== undefined) + .filter( + (doc: any) => + doc.archive_serial_number !== null && + doc.archive_serial_number !== undefined, + ) .map((doc: any) => ({ id: doc.id, title: doc.title, @@ -146,16 +169,24 @@ export class PaperlessController { @Get('inbox/preview/:id') async getInboxPreview(@Param('id') id: string, @Res() res: Response) { try { - const stream = await this.paperlessService.getDocumentPreviewStream(parseInt(id, 10)); + const stream = await this.paperlessService.getDocumentPreviewStream( + parseInt(id, 10), + ); res.set({ 'Content-Type': 'image/png', 'Content-Disposition': `inline; filename="${id}preview.png"`, }); - stream.on('error', () => { if (!res.headersSent) res.status(HttpStatus.INTERNAL_SERVER_ERROR).end(); else res.end(); }); + stream.on('error', () => { + if (!res.headersSent) + res.status(HttpStatus.INTERNAL_SERVER_ERROR).end(); + else res.end(); + }); stream.pipe(res); } catch (error) { if (!res.headersSent) { - res.status(HttpStatus.INTERNAL_SERVER_ERROR).send('Error fetching preview'); + res + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .send('Error fetching preview'); } } } @@ -164,12 +195,18 @@ export class PaperlessController { @Get('inbox/pdf/:id') async getInboxPdf(@Param('id') id: string, @Res() res: Response) { try { - const stream = await this.paperlessService.getDocumentPdfStream(parseInt(id, 10)); + const stream = await this.paperlessService.getDocumentPdfStream( + parseInt(id, 10), + ); res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${id}.pdf"`, }); - stream.on('error', () => { if (!res.headersSent) res.status(HttpStatus.INTERNAL_SERVER_ERROR).end(); else res.end(); }); + stream.on('error', () => { + if (!res.headersSent) + res.status(HttpStatus.INTERNAL_SERVER_ERROR).end(); + else res.end(); + }); stream.pipe(res); } catch (error) { if (!res.headersSent) { @@ -179,25 +216,28 @@ export class PaperlessController { } @Get('requirements/:id') - async getRequirements(@Param('id') id: string, @Query('Posteingang') posteingang: string) { + async getRequirements( + @Param('id') id: string, + @Query('Posteingang') posteingang: string, + ) { const documentTypeId = parseInt(id, 10); const isPosteingang = posteingang === '1'; const requirements = await this.documentFieldRepo.find({ where: { DocumentType: documentTypeId }, }); - + // Custom fields fetching inside here could be slow, but this is the simplest translation of the old API // Actually, getting all CFs doesn't take too long in Paperless API. const customFields = await this.paperlessService.getCustomFields(); - + const retVal: any[] = []; - + for (const req of requirements) { if (isPosteingang && !req.VisiblePosteingang) { continue; } - + const tmp: any = { id: req.Id, feldId: req.Type + (req.TypeIndex !== null ? '-' + req.TypeIndex : ''), @@ -214,7 +254,7 @@ export class PaperlessController { if (cf) { tmp.feldName = cf.name; tmp.feldTyp = cf.id === 12 ? 'int' : cf.data_type; - + if (cf.extra_data && cf.extra_data.select_options) { tmp.fieldOptions = cf.extra_data.select_options .filter((o: any) => o !== null) @@ -227,9 +267,14 @@ export class PaperlessController { } else if (req.Type === 1) { tmp.feldName = 'Absender'; tmp.feldTyp = 'select'; - const response = await this.paperlessService.getCorrespondents({ page_size: 9999 }); + const response = await this.paperlessService.getCorrespondents({ + page_size: 9999, + }); const correspondents = response.results; - tmp.fieldOptions = correspondents.map((c: any) => ({ id: c.id.toString(), label: c.name })); + tmp.fieldOptions = correspondents.map((c: any) => ({ + id: c.id.toString(), + label: c.name, + })); } else if (req.Type === 2) { tmp.feldName = 'Belegdatum'; tmp.feldTyp = 'date'; @@ -246,7 +291,7 @@ export class PaperlessController { retVal.push(tmp); } - + return retVal; } @@ -265,34 +310,49 @@ export class PaperlessController { if (body.date) { let docDate = new Date(body.date); if (docDate.getHours() > 22) { - docDate = new Date(docDate.getTime() + 24 * 60 * 60 * 1000 - docDate.getHours() * 60 * 60 * 1000); + docDate = new Date( + docDate.getTime() + + 24 * 60 * 60 * 1000 - + docDate.getHours() * 60 * 60 * 1000, + ); } oldDocument.created_date = docDate.toISOString().split('T')[0]; } - + const cfDefinitions = await this.paperlessService.getCustomFields(); - + // update custom fields if (body.customFields) { for (const [key, value] of Object.entries(body.customFields)) { const fieldId = parseInt(key, 10); const cfDef = cfDefinitions.find((c: any) => c.id === fieldId); - + let processedValue = value; - if (cfDef?.data_type === 'documentlink' && value !== null && value !== '' && !Array.isArray(value)) { + if ( + cfDef?.data_type === 'documentlink' && + value !== null && + value !== '' && + !Array.isArray(value) + ) { processedValue = [value]; } - const existingFieldIndex = oldDocument.custom_fields.findIndex((f: any) => f.field === fieldId); - + const existingFieldIndex = oldDocument.custom_fields.findIndex( + (f: any) => f.field === fieldId, + ); + if (existingFieldIndex !== -1) { if (processedValue === null || processedValue === '') { oldDocument.custom_fields.splice(existingFieldIndex, 1); } else { - oldDocument.custom_fields[existingFieldIndex].value = processedValue; + oldDocument.custom_fields[existingFieldIndex].value = + processedValue; } } else if (processedValue !== null && processedValue !== '') { - oldDocument.custom_fields.push({ field: fieldId, value: processedValue }); + oldDocument.custom_fields.push({ + field: fieldId, + value: processedValue, + }); } } } @@ -301,7 +361,7 @@ export class PaperlessController { const reqs = await this.documentFieldRepo.find({ where: { DocumentType: oldDocument.document_type }, }); - + let isReady = true; let isReadyPosteingang = true; @@ -309,12 +369,19 @@ export class PaperlessController { let isFieldValid = false; if (req.Type === 1) isFieldValid = oldDocument.correspondent !== null; if (req.Type === 2) isFieldValid = oldDocument.created_date !== null; - if (req.Type === 3) isFieldValid = oldDocument.archive_serial_number !== null; - if (req.Type === 4) isFieldValid = !!oldDocument.custom_fields.find((cf: any) => cf.field === req.TypeIndex && cf.value !== null && cf.value !== ''); - if (req.Type === 5) isFieldValid = oldDocument.title !== null && oldDocument.title !== ''; + if (req.Type === 3) + isFieldValid = oldDocument.archive_serial_number !== null; + if (req.Type === 4) + isFieldValid = !!oldDocument.custom_fields.find( + (cf: any) => + cf.field === req.TypeIndex && cf.value !== null && cf.value !== '', + ); + if (req.Type === 5) + isFieldValid = oldDocument.title !== null && oldDocument.title !== ''; if (req.IsRequired && !isFieldValid) isReady = false; - if (req.IsRequiredPosteingang && !isFieldValid) isReadyPosteingang = false; + if (req.IsRequiredPosteingang && !isFieldValid) + isReadyPosteingang = false; } const docType = await this.documentTypeRepo.findOne({ @@ -325,7 +392,10 @@ export class PaperlessController { if (isReady) { oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1); - if (docType?.TagNotReady) oldDocument.tags = oldDocument.tags.filter((t: number) => t !== docType.TagNotReady); + if (docType?.TagNotReady) + oldDocument.tags = oldDocument.tags.filter( + (t: number) => t !== docType.TagNotReady, + ); if (docType?.TagReady && !oldDocument.tags.includes(docType.TagReady)) { oldDocument.tags.push(docType.TagReady); } @@ -335,17 +405,24 @@ export class PaperlessController { for (const cf of oldDocument.custom_fields) { const placeholder = `{{CUSTOM[${cf.field}]}}`; if (titleTemplate.includes(placeholder)) { - titleTemplate = titleTemplate.replace(placeholder, cf.value?.toString() ?? ''); + titleTemplate = titleTemplate.replace( + placeholder, + cf.value?.toString() ?? '', + ); } } - titleTemplate = titleTemplate.replace('{{DATE}}', oldDocument.created_date); + titleTemplate = titleTemplate.replace( + '{{DATE}}', + oldDocument.created_date, + ); oldDocument.title = titleTemplate; } } else { if (docType?.TagNotReady) { if (isReadyPosteingang) { oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1); - if (!oldDocument.tags.includes(docType.TagNotReady)) oldDocument.tags.push(docType.TagNotReady); + if (!oldDocument.tags.includes(docType.TagNotReady)) + oldDocument.tags.push(docType.TagNotReady); } else { if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1); } @@ -353,7 +430,9 @@ export class PaperlessController { if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1); } if (docType?.TagReady) { - oldDocument.tags = oldDocument.tags.filter((t: number) => t !== docType.TagReady); + oldDocument.tags = oldDocument.tags.filter( + (t: number) => t !== docType.TagReady, + ); } } @@ -391,16 +470,21 @@ export class PaperlessController { @UploadedFile() file: Express.Multer.File, @Body() dto: UploadExternalDto, ) { - this.logger.log(`Externer Upload gestartet: ${dto.interneBelegnummer} (${file?.originalname})`); + this.logger.log( + `Externer Upload gestartet: ${dto.interneBelegnummer} (${file?.originalname})`, + ); try { // 0. Check if ASN already exists await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer); // 1. Forward to Paperless - const paperlessTaskId = await this.paperlessService.uploadDocument(file.path, { - title: `Beleg ${dto.interneBelegnummer}`, - }); + const paperlessTaskId = await this.paperlessService.uploadDocument( + file.path, + { + title: `Beleg ${dto.interneBelegnummer}`, + }, + ); // 2. Create local Task const task = this.taskRepo.create({ @@ -422,10 +506,15 @@ export class PaperlessController { await this.taskRepo.save(task); - this.logger.log(`Externer Upload erfolgreich: ${task.TaskId} für Beleg ${dto.interneBelegnummer}`); + this.logger.log( + `Externer Upload erfolgreich: ${task.TaskId} für Beleg ${dto.interneBelegnummer}`, + ); return task.TaskId; } catch (err) { - this.logger.error(`Fehler beim externen Upload für Beleg ${dto.interneBelegnummer}: ${err.message}`, err.stack); + this.logger.error( + `Fehler beim externen Upload für Beleg ${dto.interneBelegnummer}: ${err.message}`, + err.stack, + ); throw new HttpException( `Fehler beim Verarbeiten des Dokumenten-Uploads: ${err.message}`, HttpStatus.INTERNAL_SERVER_ERROR, diff --git a/paperless-backend/src/paperless/paperless.module.ts b/paperless-backend/src/paperless/paperless.module.ts index 91926ad..7b655cd 100644 --- a/paperless-backend/src/paperless/paperless.module.ts +++ b/paperless-backend/src/paperless/paperless.module.ts @@ -14,12 +14,22 @@ import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ - TypeOrmModule.forFeature([DocumentType, DocumentField, Task, Document, Attachment]), + TypeOrmModule.forFeature([ + DocumentType, + DocumentField, + Task, + Document, + Attachment, + ]), forwardRef(() => PostprocessingModule), AuthModule, ], controllers: [PaperlessController], - providers: [PaperlessService, PaperlessProcessorService, PaperlessTaskProcessorService], + providers: [ + PaperlessService, + PaperlessProcessorService, + PaperlessTaskProcessorService, + ], exports: [PaperlessService], }) export class PaperlessModule {} diff --git a/paperless-backend/src/paperless/paperless.service.ts b/paperless-backend/src/paperless/paperless.service.ts index 9ec06c4..4a7b33d 100644 --- a/paperless-backend/src/paperless/paperless.service.ts +++ b/paperless-backend/src/paperless/paperless.service.ts @@ -11,7 +11,10 @@ export class PaperlessService { private readonly client: AxiosInstance; constructor(private readonly configService: ConfigService) { - const baseURL = this.configService.get('PAPERLESS_URL', 'http://localhost:8000'); + const baseURL = this.configService.get( + 'PAPERLESS_URL', + 'http://localhost:8000', + ); const token = this.configService.get('PAPERLESS_TOKEN', ''); this.client = axios.create({ @@ -49,16 +52,22 @@ export class PaperlessService { if (options?.title) form.append('title', options.title); if (options?.created) form.append('created', options.created); - if (options?.documentType) form.append('document_type', String(options.documentType)); - if (options?.correspondent) form.append('correspondent', String(options.correspondent)); - if (options?.storagePath) form.append('storage_path', String(options.storagePath)); + if (options?.documentType) + form.append('document_type', String(options.documentType)); + if (options?.correspondent) + form.append('correspondent', String(options.correspondent)); + if (options?.storagePath) + form.append('storage_path', String(options.storagePath)); if (options?.owner !== undefined && options.owner !== null) { form.append('owner', String(options.owner)); } if (options?.tags) { options.tags.forEach((tag) => form.append('tags', String(tag))); } - if (options?.archiveSerialNumber !== undefined && !Number.isNaN(options.archiveSerialNumber)) { + if ( + options?.archiveSerialNumber !== undefined && + !Number.isNaN(options.archiveSerialNumber) + ) { form.append('archive_serial_number', String(options.archiveSerialNumber)); } if (options?.customFields && Object.keys(options.customFields).length > 0) { @@ -92,27 +101,30 @@ export class PaperlessService { async getInboxDocuments(): Promise { // API pagination to get large amount of inbox documents (assuming max 9999 like C# app) const response = await this.client.get('/documents/', { - params: { - page: 1, - page_size: 9999, - ordering: '-added', - truncate_content: true, - tags__id__all: 1 - } + params: { + page: 1, + page_size: 9999, + ordering: '-added', + truncate_content: true, + tags__id__all: 1, + }, }); return response.data.results; } async getManuellDocuments(): Promise { - const errorTag = this.configService.get('MANUELL_BEARBEITEN_TAG', 6); + const errorTag = this.configService.get( + 'MANUELL_BEARBEITEN_TAG', + 6, + ); const response = await this.client.get('/documents/', { - params: { - page: 1, - page_size: 9999, - ordering: '-added', - truncate_content: true, - tags__id__all: errorTag - } + params: { + page: 1, + page_size: 9999, + ordering: '-added', + truncate_content: true, + tags__id__all: errorTag, + }, }); return response.data.results; } @@ -124,7 +136,9 @@ export class PaperlessService { } catch (err: any) { const body = err?.response?.data; if (body) { - this.logger.error(`Paperless updateDocument(${id}) Fehlerdetails: ${JSON.stringify(body)}`); + this.logger.error( + `Paperless updateDocument(${id}) Fehlerdetails: ${JSON.stringify(body)}`, + ); } throw err; } @@ -132,14 +146,14 @@ export class PaperlessService { async getDocumentTypes(): Promise { const response = await this.client.get('/document_types/', { - params: { page_size: 9999 } + params: { page_size: 9999 }, }); return response.data.results; } async getTags(): Promise { const response = await this.client.get('/tags/', { - params: { page_size: 9999 } + params: { page_size: 9999 }, }); return response.data.results; } @@ -156,7 +170,7 @@ export class PaperlessService { async getCustomFields(): Promise { const response = await this.client.get('/custom_fields/', { - params: { page_size: 9999 } + params: { page_size: 9999 }, }); return response.data.results; } @@ -184,10 +198,14 @@ export class PaperlessService { await this.client.delete(`/correspondents/${id}/`); } - async downloadDocument(id: number, type: 'original' | 'archive' = 'archive'): Promise { - const endpoint = type === 'original' - ? `/documents/${id}/download/` - : `/documents/${id}/download/`; + async downloadDocument( + id: number, + type: 'original' | 'archive' = 'archive', + ): Promise { + const endpoint = + type === 'original' + ? `/documents/${id}/download/` + : `/documents/${id}/download/`; const response = await this.client.get(endpoint, { responseType: 'arraybuffer', params: type === 'original' ? { original: true } : {}, @@ -204,10 +222,14 @@ export class PaperlessService { return response.data; } - async getDocumentPdfStream(id: number, type: 'original' | 'archive' = 'archive'): Promise { - const endpoint = type === 'original' - ? `/documents/${id}/download/` - : `/documents/${id}/download/`; + async getDocumentPdfStream( + id: number, + type: 'original' | 'archive' = 'archive', + ): Promise { + const endpoint = + type === 'original' + ? `/documents/${id}/download/` + : `/documents/${id}/download/`; const response = await this.client.get(endpoint, { responseType: 'stream', params: type === 'original' ? { original: true } : {}, @@ -222,7 +244,9 @@ export class PaperlessService { } async addNote(id: number, note: string): Promise { - const response = await this.client.post(`/documents/${id}/notes/`, { note }); + const response = await this.client.post(`/documents/${id}/notes/`, { + note, + }); return response.data; } @@ -248,7 +272,7 @@ export class PaperlessService { */ async validateAsnNotExists(interneBelegnummer: string): Promise { if (!interneBelegnummer) return; - + // Logic like in PaperlessTaskProcessorService const asnNum = parseInt(interneBelegnummer.replace(/-/g, ''), 10); if (isNaN(asnNum)) return; @@ -271,7 +295,7 @@ export class PaperlessService { params: { archive_serial_number: asn, page_size: 5 }, }); if ((response.data.count ?? 0) === 0) return null; - const match = (response.data.results as any[] ?? []).find( + const match = ((response.data.results as any[]) ?? []).find( (doc: any) => Number(doc.archive_serial_number) === asn, ); return match ? Number(match.id) : null; @@ -281,7 +305,10 @@ export class PaperlessService { * Liefert die Paperless-Doc-ID des passenden Dokuments oder null. * Paperless kann den custom_fields-Filter ignorieren — daher manuell verifizieren. */ - async findDocumentIdByCustomField(fieldId: number, value: string): Promise { + async findDocumentIdByCustomField( + fieldId: number, + value: string, + ): Promise { const response = await this.client.get('/documents/', { params: { [`custom_fields__${fieldId}__value__iexact`]: value, @@ -291,9 +318,14 @@ export class PaperlessService { }); if ((response.data.count ?? 0) === 0) return null; const valueLower = value.toLowerCase(); - const match = (response.data.results as any[] ?? []).find((doc: any) => - (Array.isArray(doc.custom_fields) ? doc.custom_fields as any[] : []).some( - (cf: any) => cf.field === fieldId && String(cf.value ?? '').toLowerCase() === valueLower, + const match = ((response.data.results as any[]) ?? []).find((doc: any) => + (Array.isArray(doc.custom_fields) + ? (doc.custom_fields as any[]) + : [] + ).some( + (cf: any) => + cf.field === fieldId && + String(cf.value ?? '').toLowerCase() === valueLower, ), ); return match ? Number(match.id) : null; diff --git a/paperless-backend/src/postprocessing/export.service.ts b/paperless-backend/src/postprocessing/export.service.ts index b737b5a..2216677 100644 --- a/paperless-backend/src/postprocessing/export.service.ts +++ b/paperless-backend/src/postprocessing/export.service.ts @@ -10,10 +10,15 @@ export class ExportService { private readonly logger = new Logger(ExportService.name); constructor( - @InjectRepository(ExportTarget) private readonly targetRepo: Repository, + @InjectRepository(ExportTarget) + private readonly targetRepo: Repository, ) {} - async exportFile(targetId: number, filename: string, content: Buffer): Promise { + async exportFile( + targetId: number, + filename: string, + content: Buffer, + ): Promise { const target = await this.targetRepo.findOneByOrFail({ Id: targetId }); if (!target.IsActive) { @@ -32,7 +37,9 @@ export class ExportService { } } - async testConnection(targetId: number): Promise<{ success: boolean; message: string }> { + async testConnection( + targetId: number, + ): Promise<{ success: boolean; message: string }> { const target = await this.targetRepo.findOneByOrFail({ Id: targetId }); try { @@ -44,7 +51,10 @@ export class ExportService { await this.testWebDav(target); break; default: - return { success: false, message: `Unbekanntes Protokoll: ${target.Protocol}` }; + return { + success: false, + message: `Unbekanntes Protokoll: ${target.Protocol}`, + }; } return { success: true, message: 'Verbindung erfolgreich.' }; } catch (err: any) { @@ -52,7 +62,11 @@ export class ExportService { } } - private async uploadFtp(target: ExportTarget, filename: string, content: Buffer): Promise { + private async uploadFtp( + target: ExportTarget, + filename: string, + content: Buffer, + ): Promise { const client = new ftp.Client(); try { await client.access({ @@ -89,7 +103,11 @@ export class ExportService { } } - private async uploadWebDav(target: ExportTarget, filename: string, content: Buffer): Promise { + private async uploadWebDav( + target: ExportTarget, + filename: string, + content: Buffer, + ): Promise { const client = this.createWebDavClient(target); const remotePath = `${target.RemotePath || '/'}/${filename}`; await client.putFileContents(remotePath, content); diff --git a/paperless-backend/src/postprocessing/mail.service.ts b/paperless-backend/src/postprocessing/mail.service.ts index 3acc530..e2c27be 100644 --- a/paperless-backend/src/postprocessing/mail.service.ts +++ b/paperless-backend/src/postprocessing/mail.service.ts @@ -25,12 +25,24 @@ export class MailService { body: string; html?: string; attachments?: { filename: string; content: Buffer }[]; - smtpOverride?: { host: string; port: number; secure: boolean; user: string; pass: string; from: string }; + smtpOverride?: { + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + from: string; + }; }): Promise { let transporter = this.transporter; - const globalFromEmail = this.configService.get('SMTP_FROM', 'paperless@localhost'); + const globalFromEmail = this.configService.get( + 'SMTP_FROM', + 'paperless@localhost', + ); const globalFromName = this.configService.get('SMTP_FROM_NAME', ''); - let from = globalFromName ? `"${globalFromName}" <${globalFromEmail}>` : globalFromEmail; + let from = globalFromName + ? `"${globalFromName}" <${globalFromEmail}>` + : globalFromEmail; if (options.smtpOverride) { const o = options.smtpOverride; @@ -53,7 +65,7 @@ export class MailService { subject: options.subject, text: options.body, html: options.html, - attachments: options.attachments?.map(a => ({ + attachments: options.attachments?.map((a) => ({ filename: a.filename, content: a.content, })), diff --git a/paperless-backend/src/postprocessing/postprocessing.module.ts b/paperless-backend/src/postprocessing/postprocessing.module.ts index f6b70e5..d4f77a9 100644 --- a/paperless-backend/src/postprocessing/postprocessing.module.ts +++ b/paperless-backend/src/postprocessing/postprocessing.module.ts @@ -11,7 +11,12 @@ import { PaperlessModule } from '../paperless/paperless.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Postprocessing, PostprocessingAction, PostprocessingLog, ExportTarget]), + TypeOrmModule.forFeature([ + Postprocessing, + PostprocessingAction, + PostprocessingLog, + ExportTarget, + ]), forwardRef(() => PaperlessModule), ], providers: [PostprocessingService, MailService, ExportService], diff --git a/paperless-backend/src/postprocessing/postprocessing.service.spec.ts b/paperless-backend/src/postprocessing/postprocessing.service.spec.ts index 4a6ac18..a386cf7 100644 --- a/paperless-backend/src/postprocessing/postprocessing.service.spec.ts +++ b/paperless-backend/src/postprocessing/postprocessing.service.spec.ts @@ -6,12 +6,39 @@ import { PostprocessingAction } from '../database/entities/postprocessing-action import { PaperlessService } from '../paperless/paperless.service'; const mockRules: Partial[] = [ - { 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 }, + { + 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[] = [ - { Id: 1, PostprocessingId: 1, ActionType: 2, Content: '99', Order: 1, IsActive: true }, + { + Id: 1, + PostprocessingId: 1, + ActionType: 2, + Content: '99', + Order: 1, + IsActive: true, + }, ]; describe('PostprocessingService', () => { @@ -29,7 +56,10 @@ describe('PostprocessingService', () => { providers: [ PostprocessingService, { provide: getRepositoryToken(Postprocessing), useValue: ppRepo }, - { provide: getRepositoryToken(PostprocessingAction), useValue: ppActionRepo }, + { + provide: getRepositoryToken(PostprocessingAction), + useValue: ppActionRepo, + }, { provide: PaperlessService, useValue: paperlessService }, ], }).compile(); @@ -58,7 +88,9 @@ describe('PostprocessingService', () => { where: { PostprocessingId: 1, IsActive: true }, order: { Order: 'ASC' }, }); - expect(paperlessService.updateDocument).toHaveBeenCalledWith(100, { tags: [99] }); + expect(paperlessService.updateDocument).toHaveBeenCalledWith(100, { + tags: [99], + }); }); it('evaluate stops at NoFurther rule', async () => { @@ -72,7 +104,11 @@ describe('PostprocessingService', () => { 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: [] }); + await service.evaluate({ + documentId: 100, + documentTypeId: 999, + tagIds: [], + }); // Rule1 skipped, Rule2 matched → only 1 action lookup expect(ppActionRepo.find).toHaveBeenCalledTimes(1); diff --git a/paperless-backend/src/postprocessing/postprocessing.service.ts b/paperless-backend/src/postprocessing/postprocessing.service.ts index 5fe8689..5c8d6b0 100644 --- a/paperless-backend/src/postprocessing/postprocessing.service.ts +++ b/paperless-backend/src/postprocessing/postprocessing.service.ts @@ -2,7 +2,11 @@ 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 { + 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'; @@ -20,15 +24,22 @@ export class PostprocessingService { private documentTypesCache: { data: any[]; expires: number } | null = null; constructor( - @InjectRepository(Postprocessing) private readonly ppRepo: Repository, - @InjectRepository(PostprocessingAction) private readonly actionRepo: Repository, - @InjectRepository(PostprocessingLog) private readonly logRepo: Repository, + @InjectRepository(Postprocessing) + private readonly ppRepo: Repository, + @InjectRepository(PostprocessingAction) + private readonly actionRepo: Repository, + @InjectRepository(PostprocessingLog) + private readonly logRepo: Repository, private readonly configService: ConfigService, - @Inject(forwardRef(() => PaperlessService)) private readonly paperlessService: PaperlessService, + @Inject(forwardRef(() => PaperlessService)) + private readonly paperlessService: PaperlessService, private readonly mailService: MailService, private readonly exportService: ExportService, ) { - this.errorTagId = this.configService.get('POSTPROCESSING_ERROR_TAG', 0); + this.errorTagId = this.configService.get( + 'POSTPROCESSING_ERROR_TAG', + 0, + ); } async evaluate(doc: any): Promise { @@ -40,19 +51,29 @@ export class PostprocessingService { // 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`); + 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.`); + 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}`); + 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'}`); + 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}`); + 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 }, @@ -63,10 +84,18 @@ export class PostprocessingService { 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`); + 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}`); + 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); } } @@ -75,9 +104,13 @@ export class PostprocessingService { try { const currentTags = new Set(doc.tags || []); currentTags.add(this.errorTagId); - await this.paperlessService.updateDocument(doc.id, { tags: Array.from(currentTags) }); + 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}`); + this.logger.error( + `Konnte Error-Tag ${this.errorTagId} nicht setzen für Dokument ${doc.id}: ${tagErr.message}`, + ); } } @@ -92,8 +125,8 @@ export class PostprocessingService { 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 filter.rules.some((rule) => { + if ('combinator' in rule) return this.hasConditions(rule); return true; }); } @@ -101,11 +134,11 @@ export class PostprocessingService { private matchesFilter(filter: FilterGroup, doc: any): boolean { if (!filter || !filter.rules || filter.rules.length === 0) return false; - const results = filter.rules.map(rule => { + const results = filter.rules.map((rule) => { if ('combinator' in rule) { - return this.matchesFilter(rule as FilterGroup, doc); + return this.matchesFilter(rule, doc); } - return this.evaluateCondition(rule as FilterCondition, doc); + return this.evaluateCondition(rule, doc); }); return filter.combinator === 'AND' @@ -139,7 +172,9 @@ export class PostprocessingService { if (cond.field === 'tag') { result = Array.isArray(actual) && actual.includes(Number(expected)); } else { - result = String(actual ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase()); + result = String(actual ?? '') + .toLowerCase() + .includes(String(expected ?? '').toLowerCase()); } break; @@ -147,7 +182,9 @@ export class PostprocessingService { if (cond.field === 'tag') { result = !Array.isArray(actual) || !actual.includes(Number(expected)); } else { - result = !String(actual ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase()); + result = !String(actual ?? '') + .toLowerCase() + .includes(String(expected ?? '').toLowerCase()); } break; @@ -173,7 +210,7 @@ export class PostprocessingService { } this.logger.debug( - `[Postprocessing] Bedingung: ${cond.field} ${cond.operator} "${expected}" | Istwert: "${Array.isArray(actual) ? actual.join(',') : actual}" → ${result ? 'WAHR' : 'FALSCH'}` + `[Postprocessing] Bedingung: ${cond.field} ${cond.operator} "${expected}" | Istwert: "${Array.isArray(actual) ? actual.join(',') : actual}" → ${result ? 'WAHR' : 'FALSCH'}`, ); return result; } @@ -196,7 +233,9 @@ export class PostprocessingService { // Custom field: "custom_field_" if (field.startsWith('custom_field_')) { const fieldId = parseInt(field.replace('custom_field_', ''), 10); - const cf = (doc.custom_fields || []).find((f: any) => f.field === fieldId); + const cf = (doc.custom_fields || []).find( + (f: any) => f.field === fieldId, + ); return cf?.value ?? null; } return null; @@ -205,7 +244,10 @@ export class PostprocessingService { // ── Action Execution ───────────────────────────────────────────── - private async executeAction(action: PostprocessingAction, doc: any): Promise { + private async executeAction( + action: PostprocessingAction, + doc: any, + ): Promise { const content = action.Content; switch (action.ActionType) { @@ -233,15 +275,26 @@ export class PostprocessingService { } private async getCachedCorrespondents(): Promise { - if (!this.correspondentsCache || Date.now() > this.correspondentsCache.expires) { - const response = await this.paperlessService.getCorrespondents({ page_size: 9999 }); - this.correspondentsCache = { data: response.results, expires: Date.now() + CACHE_TTL_MS }; + if ( + !this.correspondentsCache || + Date.now() > this.correspondentsCache.expires + ) { + const response = await this.paperlessService.getCorrespondents({ + page_size: 9999, + }); + this.correspondentsCache = { + data: response.results, + expires: Date.now() + CACHE_TTL_MS, + }; } return this.correspondentsCache.data; } private async getCachedDocumentTypes(): Promise { - if (!this.documentTypesCache || Date.now() > this.documentTypesCache.expires) { + if ( + !this.documentTypesCache || + Date.now() > this.documentTypesCache.expires + ) { const data = await this.paperlessService.getDocumentTypes(); this.documentTypesCache = { data, expires: Date.now() + CACHE_TTL_MS }; } @@ -272,13 +325,18 @@ export class PostprocessingService { '{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 ?? ''), + '{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 ?? ''), + '{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')}` : '', + '{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') : '', @@ -289,7 +347,7 @@ export class PostprocessingService { }; // Custom Fields: {custom_field_} - for (const cf of (doc.custom_fields || [])) { + for (const cf of doc.custom_fields || []) { replacements[`{custom_field_${cf.field}}`] = String(cf.value ?? ''); } @@ -310,16 +368,32 @@ export class PostprocessingService { return `${doc.title || `document_${doc.id}`}.pdf`; } - private async handleExport(content: Record, doc: any): Promise { + private async handleExport( + content: Record, + doc: any, + ): Promise { const fileType = content.fileType || 'archive'; - const buffer = await this.paperlessService.downloadDocument(doc.id, fileType); + const buffer = await this.paperlessService.downloadDocument( + doc.id, + fileType, + ); const filename = this.buildFilename(content.filenameTemplate, doc); - await this.exportService.exportFile(content.exportTargetId, filename, buffer); + await this.exportService.exportFile( + content.exportTargetId, + filename, + buffer, + ); } - private async handleMail(content: Record, doc: any): Promise { + private async handleMail( + content: Record, + doc: any, + ): Promise { const fileType = content.fileType || 'archive'; - const buffer = await this.paperlessService.downloadDocument(doc.id, fileType); + const buffer = await this.paperlessService.downloadDocument( + doc.id, + fileType, + ); const filename = this.buildFilename(content.filenameTemplate, doc); const subject = content.subject @@ -337,18 +411,26 @@ export class PostprocessingService { }); } - private async handleTags(content: Record, doc: any): Promise { + private async handleTags( + content: Record, + doc: any, + ): Promise { const currentTags = new Set(doc.tags || []); const addTags: number[] = content.addTags || []; const removeTags: number[] = content.removeTags || []; - addTags.forEach(t => currentTags.add(t)); - removeTags.forEach(t => currentTags.delete(t)); + addTags.forEach((t) => currentTags.add(t)); + removeTags.forEach((t) => currentTags.delete(t)); - await this.paperlessService.updateDocument(doc.id, { tags: Array.from(currentTags) }); + await this.paperlessService.updateDocument(doc.id, { + tags: Array.from(currentTags), + }); } - private async handleCustomField(content: Record, doc: any): Promise { + private async handleCustomField( + content: Record, + doc: any, + ): Promise { const customFields = [...(doc.custom_fields || [])]; const existing = customFields.find((f: any) => f.field === content.fieldId); @@ -358,13 +440,23 @@ export class PostprocessingService { customFields.push({ field: content.fieldId, value: content.value }); } - await this.paperlessService.updateDocument(doc.id, { custom_fields: customFields }); + await this.paperlessService.updateDocument(doc.id, { + custom_fields: customFields, + }); } - private async handleWebhook(content: Record, doc: any): Promise { + private async handleWebhook( + content: Record, + doc: any, + ): Promise { const method = (content.method || 'POST').toUpperCase(); const headers = content.headers || {}; - const body = { documentId: doc.id, title: doc.title, tags: doc.tags, ...(content.body || {}) }; + const body = { + documentId: doc.id, + title: doc.title, + tags: doc.tags, + ...(content.body || {}), + }; await axios({ method, @@ -378,7 +470,10 @@ export class PostprocessingService { this.logger.log(`Webhook ${method} → ${content.url}`); } - private async handleNote(content: Record, doc: any): Promise { + private async handleNote( + content: Record, + doc: any, + ): Promise { if (!content.note) return; const resolvedNote = this.resolveTemplate(content.note, doc); await this.paperlessService.addNote(doc.id, resolvedNote); @@ -386,7 +481,13 @@ export class PostprocessingService { // ── Logging ────────────────────────────────────────────────────── - private async log(ppId: number, actionId: number | null, docId: number, status: string, message: string): Promise { + private async log( + ppId: number, + actionId: number | null, + docId: number, + status: string, + message: string, + ): Promise { const entry = this.logRepo.create({ PostprocessingId: ppId, ActionId: actionId, diff --git a/paperless-backend/src/preprocessing/document-pipeline.service.ts b/paperless-backend/src/preprocessing/document-pipeline.service.ts index 28a02eb..5912309 100644 --- a/paperless-backend/src/preprocessing/document-pipeline.service.ts +++ b/paperless-backend/src/preprocessing/document-pipeline.service.ts @@ -51,18 +51,22 @@ export class DocumentPipelineService { // 2. QR-Code auf erster Seite scannen const firstPageBuffer = await fs.readFile(images[0]); - const qrResults = await this.qrCodeService.extractFromImage(firstPageBuffer); + const qrResults = + await this.qrCodeService.extractFromImage(firstPageBuffer); let barcodeData: Record | null = null; if (qrResults.length > 0) { barcodeData = this.qrCodeService.parseBarcode(qrResults[0].data); if (barcodeData) { - this.logger.log(`QR-Code erkannt und validiert: ${JSON.stringify(barcodeData)}`); + this.logger.log( + `QR-Code erkannt und validiert: ${JSON.stringify(barcodeData)}`, + ); } } // 3. OCR auf erster Seite - const ocrMarkdown = await this.ocrService.extractTextAsMarkdown(firstPageBuffer); + const ocrMarkdown = + await this.ocrService.extractTextAsMarkdown(firstPageBuffer); // 4. Task in DB erstellen const year = new Date().getFullYear(); diff --git a/paperless-backend/src/preprocessing/ocr.service.ts b/paperless-backend/src/preprocessing/ocr.service.ts index 2400573..6eb2822 100644 --- a/paperless-backend/src/preprocessing/ocr.service.ts +++ b/paperless-backend/src/preprocessing/ocr.service.ts @@ -9,7 +9,10 @@ export class OcrService { private readonly ollamaModel: string; constructor(private readonly configService: ConfigService) { - this.ollamaUrl = this.configService.get('OLLAMA_URL', 'http://localhost:11434'); + this.ollamaUrl = this.configService.get( + 'OLLAMA_URL', + 'http://localhost:11434', + ); this.ollamaModel = this.configService.get('OLLAMA_MODEL', 'llava'); } @@ -39,7 +42,9 @@ Antworte nur mit dem extrahierten Markdown-Text, keine Erklärungen.`; ); const markdown = response.data.response?.trim() ?? ''; - this.logger.log(`OCR abgeschlossen: ${markdown.length} Zeichen extrahiert`); + this.logger.log( + `OCR abgeschlossen: ${markdown.length} Zeichen extrahiert`, + ); return markdown; } catch (error: any) { this.logger.error(`Ollama OCR fehlgeschlagen: ${error.message}`); diff --git a/paperless-backend/src/preprocessing/pdf.service.ts b/paperless-backend/src/preprocessing/pdf.service.ts index 11633d7..2baf0f0 100644 --- a/paperless-backend/src/preprocessing/pdf.service.ts +++ b/paperless-backend/src/preprocessing/pdf.service.ts @@ -60,18 +60,22 @@ export class PdfService { const entries = await fs.readdir(tmpDir); const images = entries - .filter(f => f.endsWith('.png')) + .filter((f) => f.endsWith('.png')) .sort((a, b) => { const numA = parseInt(a.match(/\d+/)?.[0] ?? '0', 10); const numB = parseInt(b.match(/\d+/)?.[0] ?? '0', 10); return numA - numB; }) - .map(f => path.join(tmpDir, f)); + .map((f) => path.join(tmpDir, f)); if (images.length === 0) { - this.logger.warn(`Ghostscript hat keine Seiten erstellt: ${pdfPath} — Verzeichnisinhalt: [${entries.join(', ')}]`); + this.logger.warn( + `Ghostscript hat keine Seiten erstellt: ${pdfPath} — Verzeichnisinhalt: [${entries.join(', ')}]`, + ); } else { - this.logger.debug(`PDF konvertiert: ${images.length} Seite(n) in ${tmpDir}`); + this.logger.debug( + `PDF konvertiert: ${images.length} Seite(n) in ${tmpDir}`, + ); } return images; @@ -114,7 +118,9 @@ export class PdfService { async cleanup(imagePaths: string[]): Promise { const dirs = new Set(); for (const imgPath of imagePaths) { - try { await fs.unlink(imgPath); } catch {} + try { + await fs.unlink(imgPath); + } catch {} dirs.add(path.dirname(imgPath)); } for (const dir of dirs) { diff --git a/paperless-backend/src/scanner/scanner-watcher.service.ts b/paperless-backend/src/scanner/scanner-watcher.service.ts index 9f296b3..fb59965 100644 --- a/paperless-backend/src/scanner/scanner-watcher.service.ts +++ b/paperless-backend/src/scanner/scanner-watcher.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { + Injectable, + Logger, + OnModuleInit, + OnModuleDestroy, +} from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; @@ -31,7 +36,10 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { @InjectRepository(InboxDocument) private readonly documentRepo: Repository, ) { - this.sourceRoot = this.configService.get('SCANNER_WATCH_DIR', '/mnt/scans'); + this.sourceRoot = this.configService.get( + 'SCANNER_WATCH_DIR', + '/mnt/scans', + ); } onModuleInit(): void { @@ -67,7 +75,9 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { this.watcher .on('add', (filePath: string) => this.handleNewFile(filePath)) - .on('error', (error: Error) => this.logger.error(`Watcher Fehler: ${error.message}`)); + .on('error', (error: Error) => + this.logger.error(`Watcher Fehler: ${error.message}`), + ); this.logger.log('Scanner-Watcher aktiv'); } @@ -82,11 +92,15 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { private async initialScan(silent = false): Promise { let subdirs: string[]; try { - const entries = await fs.readdir(this.sourceRoot, { withFileTypes: true }); + const entries = await fs.readdir(this.sourceRoot, { + withFileTypes: true, + }); subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); } catch (err: any) { if (!silent) { - this.logger.warn(`Scanner-Check: Quellverzeichnis nicht lesbar (${this.sourceRoot}): ${err.message}`); + this.logger.warn( + `Scanner-Check: Quellverzeichnis nicht lesbar (${this.sourceRoot}): ${err.message}`, + ); } return; } @@ -99,7 +113,9 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { files = await fs.readdir(dir); } catch (err: any) { if (!silent) { - this.logger.warn(`Scanner-Check: ${dir} nicht lesbar: ${err.message}`); + this.logger.warn( + `Scanner-Check: ${dir} nicht lesbar: ${err.message}`, + ); } continue; } @@ -110,7 +126,9 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { if (!(await this.isStable(full))) { if (!silent) { - this.logger.debug(`Scanner-Check: ${full} noch nicht stabil – Watcher übernimmt`); + this.logger.debug( + `Scanner-Check: ${full} noch nicht stabil – Watcher übernimmt`, + ); } continue; } @@ -204,10 +222,14 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { try { await this.barcodeScanner.scanAndMatch(doc); } catch (err: any) { - this.logger.warn(`Barcode-Scan nach Move fehlgeschlagen (${id}): ${err.message}`); + this.logger.warn( + `Barcode-Scan nach Move fehlgeschlagen (${id}): ${err.message}`, + ); } } catch (err: any) { - this.logger.error(`Übernahme fehlgeschlagen für ${filePath}: ${err.message}`); + this.logger.error( + `Übernahme fehlgeschlagen für ${filePath}: ${err.message}`, + ); } finally { this.processing.delete(filePath); } diff --git a/paperless-backend/src/settings/settings.controller.spec.ts b/paperless-backend/src/settings/settings.controller.spec.ts index b378e7e..9c8d0c5 100644 --- a/paperless-backend/src/settings/settings.controller.spec.ts +++ b/paperless-backend/src/settings/settings.controller.spec.ts @@ -35,10 +35,16 @@ describe('SettingsController', () => { providers: [ { provide: getRepositoryToken(DocumentType), useValue: docTypeRepo }, { provide: getRepositoryToken(Postprocessing), useValue: ppRepo }, - { provide: getRepositoryToken(PostprocessingAction), useValue: ppActionRepo }, + { + provide: getRepositoryToken(PostprocessingAction), + useValue: ppActionRepo, + }, { provide: getRepositoryToken(UserClient), useValue: userClientRepo }, { provide: getRepositoryToken(Client), useValue: makeRepo() }, - { provide: getRepositoryToken(Setting), useValue: makeRepo([{ ID: 1, Typ: 1, Wert: 'v' }]) }, + { + provide: getRepositoryToken(Setting), + useValue: makeRepo([{ ID: 1, Typ: 1, Wert: 'v' }]), + }, ], }).compile(); @@ -58,7 +64,9 @@ describe('SettingsController', () => { it('updateDocumentType calls update + findOneByOrFail', async () => { await controller.updateDocumentType('1', { TitelTemplate: 'New' }); - expect(docTypeRepo.update).toHaveBeenCalledWith(1, { TitelTemplate: 'New' }); + expect(docTypeRepo.update).toHaveBeenCalledWith(1, { + TitelTemplate: 'New', + }); expect(docTypeRepo.findOneByOrFail).toHaveBeenCalledWith({ Id: 1 }); }); @@ -70,7 +78,9 @@ describe('SettingsController', () => { }); it('createPostprocessingRule creates and saves', async () => { - const result = await controller.createPostprocessingRule({ Name: 'New' } as any); + const result = await controller.createPostprocessingRule({ + Name: 'New', + } as any); expect(ppRepo.create).toHaveBeenCalledWith({ Name: 'New' }); expect(ppRepo.save).toHaveBeenCalled(); expect(result).toHaveProperty('Id', 99); @@ -89,7 +99,10 @@ describe('SettingsController', () => { }); it('createUserClient creates', async () => { - const result = await controller.createUserClient({ UserId: 'u2', ClientId: 3 } as any); + const result = await controller.createUserClient({ + UserId: 'u2', + ClientId: 3, + } as any); expect(userClientRepo.create).toHaveBeenCalled(); expect(result).toHaveProperty('Id', 99); }); diff --git a/paperless-backend/src/settings/settings.controller.ts b/paperless-backend/src/settings/settings.controller.ts index a2f7e26..01d6115 100644 --- a/paperless-backend/src/settings/settings.controller.ts +++ b/paperless-backend/src/settings/settings.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, Query, Logger } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + Logger, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { DocumentType } from '../database/entities/document-type.entity'; @@ -12,7 +22,10 @@ import { PaperlessService } from '../paperless/paperless.service'; import { Client } from '../database/entities/client.entity'; import { Setting } from '../database/entities/setting.entity'; import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity'; -import { InboxPostprocessingAction, type InboxActionType } from '../database/entities/inbox-postprocessing-action.entity'; +import { + InboxPostprocessingAction, + type InboxActionType, +} from '../database/entities/inbox-postprocessing-action.entity'; import { ExportService } from '../postprocessing/export.service'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @@ -23,17 +36,27 @@ export class SettingsController { private readonly logger = new Logger(SettingsController.name); constructor( - @InjectRepository(DocumentType) private readonly docTypeRepo: Repository, - @InjectRepository(Postprocessing) private readonly ppRepo: Repository, - @InjectRepository(PostprocessingAction) private readonly ppActionRepo: Repository, - @InjectRepository(PostprocessingLog) private readonly ppLogRepo: Repository, - @InjectRepository(ExportTarget) private readonly exportTargetRepo: Repository, - @InjectRepository(UserClient) private readonly userClientRepo: Repository, + @InjectRepository(DocumentType) + private readonly docTypeRepo: Repository, + @InjectRepository(Postprocessing) + private readonly ppRepo: Repository, + @InjectRepository(PostprocessingAction) + private readonly ppActionRepo: Repository, + @InjectRepository(PostprocessingLog) + private readonly ppLogRepo: Repository, + @InjectRepository(ExportTarget) + private readonly exportTargetRepo: Repository, + @InjectRepository(UserClient) + private readonly userClientRepo: Repository, @InjectRepository(Client) private readonly clientRepo: Repository, - @InjectRepository(Setting) private readonly settingRepo: Repository, - @InjectRepository(DocumentField) private readonly docFieldRepo: Repository, - @InjectRepository(CorrespondentSetting) private readonly corrSettingRepo: Repository, - @InjectRepository(InboxPostprocessingAction) private readonly inboxActionRepo: Repository, + @InjectRepository(Setting) + private readonly settingRepo: Repository, + @InjectRepository(DocumentField) + private readonly docFieldRepo: Repository, + @InjectRepository(CorrespondentSetting) + private readonly corrSettingRepo: Repository, + @InjectRepository(InboxPostprocessingAction) + private readonly inboxActionRepo: Repository, private readonly paperlessService: PaperlessService, private readonly exportService: ExportService, ) {} @@ -44,7 +67,7 @@ export class SettingsController { try { const paperlessTypes = await this.paperlessService.getDocumentTypes(); const existing = await this.docTypeRepo.find(); - const existingIds = new Set(existing.map(d => d.DocumentTypeId)); + const existingIds = new Set(existing.map((d) => d.DocumentTypeId)); for (const pt of paperlessTypes) { if (!existingIds.has(pt.id)) { @@ -58,14 +81,20 @@ export class SettingsController { } } } catch (err) { - this.logger.error('Fehler beim Synchronisieren der Dokumenttypen von Paperless', err); + this.logger.error( + 'Fehler beim Synchronisieren der Dokumenttypen von Paperless', + err, + ); } return this.docTypeRepo.find({ order: { Id: 'ASC' } }); } @Put('document-types/:id') - async updateDocumentType(@Param('id') id: string, @Body() body: Partial) { + async updateDocumentType( + @Param('id') id: string, + @Body() body: Partial, + ) { await this.docTypeRepo.update(parseInt(id, 10), body); return this.docTypeRepo.findOneByOrFail({ Id: parseInt(id, 10) }); } @@ -80,13 +109,22 @@ export class SettingsController { } @Post('document-types/:id/fields') - async createDocumentField(@Param('id') id: string, @Body() body: Partial) { - const field = this.docFieldRepo.create({ ...body, DocumentType: parseInt(id, 10) }); + async createDocumentField( + @Param('id') id: string, + @Body() body: Partial, + ) { + const field = this.docFieldRepo.create({ + ...body, + DocumentType: parseInt(id, 10), + }); return this.docFieldRepo.save(field); } @Put('document-fields/:fieldId') - async updateDocumentField(@Param('fieldId') fieldId: string, @Body() body: Partial) { + async updateDocumentField( + @Param('fieldId') fieldId: string, + @Body() body: Partial, + ) { await this.docFieldRepo.update(parseInt(fieldId, 10), body); return this.docFieldRepo.findOneByOrFail({ Id: parseInt(fieldId, 10) }); } @@ -110,7 +148,10 @@ export class SettingsController { } @Put('postprocessing/:id') - async updatePostprocessingRule(@Param('id') id: string, @Body() body: Partial) { + async updatePostprocessingRule( + @Param('id') id: string, + @Body() body: Partial, + ) { await this.ppRepo.update(parseInt(id, 10), body); return this.ppRepo.findOneByOrFail({ Id: parseInt(id, 10) }); } @@ -123,16 +164,26 @@ export class SettingsController { @Post('postprocessing/:id/duplicate') async duplicatePostprocessingRule(@Param('id') id: string) { - const original = await this.ppRepo.findOneByOrFail({ Id: parseInt(id, 10) }); - const actions = await this.ppActionRepo.find({ where: { PostprocessingId: original.Id } }); + const original = await this.ppRepo.findOneByOrFail({ + Id: parseInt(id, 10), + }); + const actions = await this.ppActionRepo.find({ + where: { PostprocessingId: original.Id }, + }); const { Id: _, ...ruleData } = original; - const clone = this.ppRepo.create({ ...ruleData, Name: `${original.Name} (Kopie)` }); + const clone = this.ppRepo.create({ + ...ruleData, + Name: `${original.Name} (Kopie)`, + }); const saved = await this.ppRepo.save(clone); for (const action of actions) { const { Id: __, ...actionData } = action; - const clonedAction = this.ppActionRepo.create({ ...actionData, PostprocessingId: saved.Id }); + const clonedAction = this.ppActionRepo.create({ + ...actionData, + PostprocessingId: saved.Id, + }); await this.ppActionRepo.save(clonedAction); } @@ -149,13 +200,22 @@ export class SettingsController { } @Post('postprocessing/:id/actions') - async createAction(@Param('id') id: string, @Body() body: Partial) { - const action = this.ppActionRepo.create({ ...body, PostprocessingId: parseInt(id, 10) }); + async createAction( + @Param('id') id: string, + @Body() body: Partial, + ) { + const action = this.ppActionRepo.create({ + ...body, + PostprocessingId: parseInt(id, 10), + }); return this.ppActionRepo.save(action); } @Put('postprocessing-actions/:actionId') - async updateAction(@Param('actionId') actionId: string, @Body() body: Partial) { + async updateAction( + @Param('actionId') actionId: string, + @Body() body: Partial, + ) { await this.ppActionRepo.update(parseInt(actionId, 10), body); return this.ppActionRepo.findOneByOrFail({ Id: parseInt(actionId, 10) }); } @@ -184,7 +244,7 @@ export class SettingsController { throw new Error('ActionType ist erforderlich'); } const entity = this.inboxActionRepo.create({ - ActionType: body.ActionType as InboxActionType, + ActionType: body.ActionType, Content: body.Content ?? {}, Order: body.Order ?? 0, IsActive: body.IsActive ?? true, @@ -205,7 +265,7 @@ export class SettingsController { throw new Error('ActionType ist erforderlich'); } const entity = this.inboxActionRepo.create({ - ActionType: body.ActionType as InboxActionType, + ActionType: body.ActionType, Content: body.Content ?? {}, Order: body.Order ?? 0, IsActive: body.IsActive ?? true, @@ -214,10 +274,13 @@ export class SettingsController { } @Put('inbox-actions/:id') - async updateInboxAction(@Param('id') id: string, @Body() body: Partial) { + async updateInboxAction( + @Param('id') id: string, + @Body() body: Partial, + ) { const numId = parseInt(id, 10); const existing = await this.inboxActionRepo.findOneByOrFail({ Id: numId }); - if (body.ActionType !== undefined) existing.ActionType = body.ActionType as InboxActionType; + if (body.ActionType !== undefined) existing.ActionType = body.ActionType; if (body.Content !== undefined) existing.Content = body.Content; if (body.Order !== undefined) existing.Order = body.Order; if (body.IsActive !== undefined) existing.IsActive = body.IsActive; @@ -243,7 +306,10 @@ export class SettingsController { } @Put('export-targets/:id') - async updateExportTarget(@Param('id') id: string, @Body() body: Partial) { + async updateExportTarget( + @Param('id') id: string, + @Body() body: Partial, + ) { await this.exportTargetRepo.update(parseInt(id, 10), body); return this.exportTargetRepo.findOneByOrFail({ Id: parseInt(id, 10) }); } @@ -300,7 +366,10 @@ export class SettingsController { } @Put('general/:id') - async updateSetting(@Param('id') id: string, @Body() body: { value: string }) { + async updateSetting( + @Param('id') id: string, + @Body() body: { value: string }, + ) { await this.settingRepo.update(parseInt(id, 10), { Wert: body.value }); return this.settingRepo.findOneByOrFail({ ID: parseInt(id, 10) }); } @@ -323,13 +392,18 @@ export class SettingsController { const response = await this.paperlessService.getCorrespondents(params); const paperlessCorrs = response.results; - + const settings = await this.corrSettingRepo.find(); - const settingsMap = new Map(settings.map(s => [s.CorrespondentId, s])); + const settingsMap = new Map(settings.map((s) => [s.CorrespondentId, s])); const newSettings = paperlessCorrs .filter((pc: any) => !settingsMap.has(pc.id)) - .map((pc: any) => this.corrSettingRepo.create({ CorrespondentId: pc.id, AgrarmonitorId: null })); + .map((pc: any) => + this.corrSettingRepo.create({ + CorrespondentId: pc.id, + AgrarmonitorId: null, + }), + ); if (newSettings.length > 0) { await this.corrSettingRepo.insert(newSettings); } @@ -338,7 +412,9 @@ export class SettingsController { const finalSettings = await this.corrSettingRepo.find(); const merged = paperlessCorrs.map((pc: any) => ({ ...pc, - agrarmonitorId: finalSettings.find(s => s.CorrespondentId === pc.id)?.AgrarmonitorId || null, + agrarmonitorId: + finalSettings.find((s) => s.CorrespondentId === pc.id) + ?.AgrarmonitorId || null, })); return { @@ -346,7 +422,10 @@ export class SettingsController { total: response.count, }; } catch (err) { - this.logger.error('Fehler beim Synchronisieren der Korrespondenten von Paperless', err); + this.logger.error( + 'Fehler beim Synchronisieren der Korrespondenten von Paperless', + err, + ); return { data: [], total: 0 }; } } @@ -361,24 +440,32 @@ export class SettingsController { owner: null, // User said 0, but null is standard for public in Paperless. I'll use null. }; const created = await this.paperlessService.addCorrespondent(data); - + // Also ensure setting entry exists const newSetting = this.corrSettingRepo.create({ CorrespondentId: created.id, AgrarmonitorId: null, }); await this.corrSettingRepo.save(newSetting); - + return { ...created, agrarmonitorId: null }; } @Put('correspondents/:id') - async updateCorrespondentSetting(@Param('id') id: string, @Body() body: { agrarmonitorId: number | null }) { + async updateCorrespondentSetting( + @Param('id') id: string, + @Body() body: { agrarmonitorId: number | null }, + ) { const corrId = parseInt(id, 10); - let setting = await this.corrSettingRepo.findOneBy({ CorrespondentId: corrId }); + let setting = await this.corrSettingRepo.findOneBy({ + CorrespondentId: corrId, + }); if (!setting) { - setting = this.corrSettingRepo.create({ CorrespondentId: corrId, AgrarmonitorId: body.agrarmonitorId }); + setting = this.corrSettingRepo.create({ + CorrespondentId: corrId, + AgrarmonitorId: body.agrarmonitorId, + }); } else { setting.AgrarmonitorId = body.agrarmonitorId; } @@ -393,9 +480,14 @@ export class SettingsController { } @Put('clients/:id') - async updateClient(@Param('id') id: string, @Body() body: { AgrarmonitorBetriebId: number | null }) { + async updateClient( + @Param('id') id: string, + @Body() body: { AgrarmonitorBetriebId: number | null }, + ) { const clientId = parseInt(id, 10); - await this.clientRepo.update(clientId, { AgrarmonitorBetriebId: body.AgrarmonitorBetriebId ?? null }); + await this.clientRepo.update(clientId, { + AgrarmonitorBetriebId: body.AgrarmonitorBetriebId ?? null, + }); return this.clientRepo.findOneByOrFail({ Id: clientId }); } } diff --git a/paperless-backend/src/stats/stats.module.ts b/paperless-backend/src/stats/stats.module.ts index 0fecb5e..a6fde36 100644 --- a/paperless-backend/src/stats/stats.module.ts +++ b/paperless-backend/src/stats/stats.module.ts @@ -7,11 +7,7 @@ import { InboxModule } from '../inbox/inbox.module'; import { PaperlessModule } from '../paperless/paperless.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([Email]), - InboxModule, - PaperlessModule, - ], + imports: [TypeOrmModule.forFeature([Email]), InboxModule, PaperlessModule], controllers: [StatsController], providers: [StatsService], exports: [StatsService], diff --git a/paperless-backend/src/stats/stats.service.ts b/paperless-backend/src/stats/stats.service.ts index 9b792f8..fbc3de8 100644 --- a/paperless-backend/src/stats/stats.service.ts +++ b/paperless-backend/src/stats/stats.service.ts @@ -25,7 +25,9 @@ export class StatsService { private readonly configService: ConfigService, ) {} - async getDashboardCounts(preferredUsername?: string): Promise { + async getDashboardCounts( + preferredUsername?: string, + ): Promise { let inboxCount = 0; let posteingangCount = 0; let manuellCount = 0; @@ -33,25 +35,42 @@ export class StatsService { let agrarmonitorCount = 0; try { - const files = await this.inboxService.listFiles(preferredUsername ?? null); + const files = await this.inboxService.listFiles( + preferredUsername ?? null, + ); inboxCount = files.length; } catch (err) { this.logger.error('Fehler beim Abrufen der Inbox-Stats: ' + err.message); } try { - const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: 1 }); + const response = await this.paperlessService.getDocuments({ + page: 1, + page_size: 1, + tags__id__all: 1, + }); posteingangCount = response.count || 0; } catch (err) { - this.logger.error('Fehler beim Abrufen der Posteingang-Stats: ' + err.message); + this.logger.error( + 'Fehler beim Abrufen der Posteingang-Stats: ' + err.message, + ); } try { - const errorTag = this.configService.get('MANUELL_BEARBEITEN_TAG', 6); - const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: errorTag }); + const errorTag = this.configService.get( + 'MANUELL_BEARBEITEN_TAG', + 6, + ); + const response = await this.paperlessService.getDocuments({ + page: 1, + page_size: 1, + tags__id__all: errorTag, + }); manuellCount = response.count || 0; } catch (err) { - this.logger.error('Fehler beim Abrufen der Manuell-Stats: ' + err.message); + this.logger.error( + 'Fehler beim Abrufen der Manuell-Stats: ' + err.message, + ); } try { @@ -61,12 +80,24 @@ export class StatsService { } try { - const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: 3 }); + const response = await this.paperlessService.getDocuments({ + page: 1, + page_size: 1, + tags__id__all: 3, + }); agrarmonitorCount = response.count || 0; } catch (err) { - this.logger.error('Fehler beim Abrufen der Agrarmonitor-Stats: ' + err.message); + this.logger.error( + 'Fehler beim Abrufen der Agrarmonitor-Stats: ' + err.message, + ); } - return { inbox: inboxCount, posteingang: posteingangCount, manuell: manuellCount, mailpostfach: mailpostfachCount, agrarmonitor: agrarmonitorCount }; + return { + inbox: inboxCount, + posteingang: posteingangCount, + manuell: manuellCount, + mailpostfach: mailpostfachCount, + agrarmonitor: agrarmonitorCount, + }; } } diff --git a/paperless-backend/src/user-settings/user-settings.controller.ts b/paperless-backend/src/user-settings/user-settings.controller.ts index cc4b90a..fb5105b 100644 --- a/paperless-backend/src/user-settings/user-settings.controller.ts +++ b/paperless-backend/src/user-settings/user-settings.controller.ts @@ -1,4 +1,12 @@ -import { Body, Controller, Get, HttpCode, Post, Put, Request } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + Post, + Put, + Request, +} from '@nestjs/common'; import { UserSettingsService } from './user-settings.service'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @@ -9,12 +17,23 @@ export class UserSettingsController { @Get() async getSettings(@Request() req: any) { - return this.userSettingsService.getSettings(req.user.userId, req.user.email, req.user.preferredUsername, req.user.groups); + return this.userSettingsService.getSettings( + req.user.userId, + req.user.email, + req.user.preferredUsername, + req.user.groups, + ); } @Put() async updateSettings(@Request() req: any, @Body() body: any) { - return this.userSettingsService.updateSettings(req.user.userId, body, req.user.email, req.user.preferredUsername, req.user.groups); + return this.userSettingsService.updateSettings( + req.user.userId, + body, + req.user.email, + req.user.preferredUsername, + req.user.groups, + ); } @Get('senders') @@ -25,7 +44,16 @@ export class UserSettingsController { @Post('test-smtp') @HttpCode(200) @RequirePermissions(Permission.MANAGE_SETTINGS) - async testSmtp(@Body() body: { host: string; port: number; secure: boolean; user: string; pass: string }) { + async testSmtp( + @Body() + body: { + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + }, + ) { return this.userSettingsService.testSmtp(body); } } diff --git a/paperless-backend/src/user-settings/user-settings.service.ts b/paperless-backend/src/user-settings/user-settings.service.ts index f4f1b66..8afd147 100644 --- a/paperless-backend/src/user-settings/user-settings.service.ts +++ b/paperless-backend/src/user-settings/user-settings.service.ts @@ -47,7 +47,10 @@ export class UserSettingsService { if (!this.encKey) return plaintext; const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(ALGORITHM, this.encKey, iv); - const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); const authTag = cipher.getAuthTag(); return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; } @@ -62,10 +65,18 @@ export class UserSettingsService { const encrypted = Buffer.from(encryptedHex, 'hex'); const decipher = crypto.createDecipheriv(ALGORITHM, this.encKey, iv); decipher.setAuthTag(authTag); - return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); + return Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]).toString('utf8'); } - async getSettings(userId: string, email?: string, preferredUsername?: string, groups?: string[]): Promise { + async getSettings( + userId: string, + email?: string, + preferredUsername?: string, + groups?: string[], + ): Promise { let entity = await this.repo.findOne({ where: { UserId: userId } }); if (email || preferredUsername || groups) { if (!entity) { @@ -107,15 +118,24 @@ export class UserSettingsService { if (data.smtpPort !== undefined) entity.SmtpPort = data.smtpPort; if (data.smtpSecure !== undefined) entity.SmtpSecure = data.smtpSecure; if (data.smtpUser !== undefined) entity.SmtpUser = data.smtpUser; - if (data.smtpPass !== undefined && data.smtpPass !== null && data.smtpPass !== '') { + if ( + data.smtpPass !== undefined && + data.smtpPass !== null && + data.smtpPass !== '' + ) { entity.SmtpPass = this.encrypt(data.smtpPass); } if (data.smtpFrom !== undefined) entity.SmtpFrom = data.smtpFrom; - if (data.smtpFromName !== undefined) entity.SmtpFromName = data.smtpFromName; - if (data.mailSignatureHtml !== undefined) entity.MailSignatureHtml = data.mailSignatureHtml; - if (data.defaultLabelTemplateId !== undefined) entity.DefaultLabelTemplateId = data.defaultLabelTemplateId; - if (data.emailRecipientHistory !== undefined) entity.EmailRecipientHistory = data.emailRecipientHistory; - if (data.dailyDigestEnabled !== undefined) entity.DailyDigestEnabled = data.dailyDigestEnabled; + if (data.smtpFromName !== undefined) + entity.SmtpFromName = data.smtpFromName; + if (data.mailSignatureHtml !== undefined) + entity.MailSignatureHtml = data.mailSignatureHtml; + if (data.defaultLabelTemplateId !== undefined) + entity.DefaultLabelTemplateId = data.defaultLabelTemplateId; + if (data.emailRecipientHistory !== undefined) + entity.EmailRecipientHistory = data.emailRecipientHistory; + if (data.dailyDigestEnabled !== undefined) + entity.DailyDigestEnabled = data.dailyDigestEnabled; if (email) entity.UserEmail = email; if (preferredUsername) entity.UserPreferredUsername = preferredUsername; if (groups) entity.UserGroups = groups; @@ -146,12 +166,19 @@ export class UserSettingsService { } async getSmtpConfig(userId: string): Promise<{ - host: string; port: number; secure: boolean; user: string; pass: string; from: string; + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + from: string; } | null> { const entity = await this.repo.findOne({ where: { UserId: userId } }); if (!entity?.SmtpHost || !entity?.SmtpPass) return null; const fromEmail = entity.SmtpFrom ?? entity.SmtpUser ?? ''; - const from = entity.SmtpFromName ? `"${entity.SmtpFromName}" <${fromEmail}>` : fromEmail; + const from = entity.SmtpFromName + ? `"${entity.SmtpFromName}" <${fromEmail}>` + : fromEmail; return { host: entity.SmtpHost, port: entity.SmtpPort ?? 587, @@ -163,21 +190,34 @@ export class UserSettingsService { } async findAllDigestSubscribers(): Promise { - return this.repo.find({ - where: { DailyDigestEnabled: true }, - }).then(rows => rows.filter(r => !!r.UserEmail)); + return this.repo + .find({ + where: { DailyDigestEnabled: true }, + }) + .then((rows) => rows.filter((r) => !!r.UserEmail)); } - async getAvailableSenders(userId: string): Promise<{ id: string; label: string }[]> { - const defaultEmail = this.configService.get('SMTP_FROM', 'paperless@localhost'); + async getAvailableSenders( + userId: string, + ): Promise<{ id: string; label: string }[]> { + const defaultEmail = this.configService.get( + 'SMTP_FROM', + 'paperless@localhost', + ); const defaultName = this.configService.get('SMTP_FROM_NAME', ''); - const defaultLabel = defaultName ? `${defaultName} <${defaultEmail}>` : defaultEmail; - const senders: { id: string; label: string }[] = [{ id: 'default', label: defaultLabel }]; + const defaultLabel = defaultName + ? `${defaultName} <${defaultEmail}>` + : defaultEmail; + const senders: { id: string; label: string }[] = [ + { id: 'default', label: defaultLabel }, + ]; const entity = await this.repo.findOne({ where: { UserId: userId } }); if (entity?.SmtpHost && entity?.SmtpPass) { const userEmail = entity.SmtpFrom ?? entity.SmtpUser ?? ''; - const userLabel = entity.SmtpFromName ? `${entity.SmtpFromName} <${userEmail}>` : userEmail; + const userLabel = entity.SmtpFromName + ? `${entity.SmtpFromName} <${userEmail}>` + : userEmail; senders.push({ id: 'user', label: userLabel }); } @@ -190,7 +230,7 @@ export class UserSettingsService { smtpPort: entity?.SmtpPort ?? null, smtpSecure: entity?.SmtpSecure ?? false, smtpUser: entity?.SmtpUser ?? null, - smtpPassSet: !!(entity?.SmtpPass), + smtpPassSet: !!entity?.SmtpPass, smtpFrom: entity?.SmtpFrom ?? null, smtpFromName: entity?.SmtpFromName ?? null, mailSignatureHtml: entity?.MailSignatureHtml ?? null, diff --git a/paperless-backend/src/webhook/webhook.controller.ts b/paperless-backend/src/webhook/webhook.controller.ts index 0df895e..8154711 100644 --- a/paperless-backend/src/webhook/webhook.controller.ts +++ b/paperless-backend/src/webhook/webhook.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Post, Body, Logger, HttpCode, HttpStatus } from '@nestjs/common'; +import { + Controller, + Post, + Body, + Logger, + HttpCode, + HttpStatus, +} from '@nestjs/common'; import { Public } from '../auth/public.decorator'; export interface PaperlessWebhookPayload { document_id: number; @@ -13,8 +20,12 @@ export class WebhookController { @Public() @Post('paperless') @HttpCode(HttpStatus.OK) - async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload): Promise<{ status: string }> { - this.logger.log(`Webhook empfangen: action=${payload.action}, document=${payload.document_id}`); + async handlePaperlessWebhook( + @Body() payload: PaperlessWebhookPayload, + ): Promise<{ status: string }> { + this.logger.log( + `Webhook empfangen: action=${payload.action}, document=${payload.document_id}`, + ); // TODO: Business-Logik für verschiedene Webhook-Events // - document_updated → Felder prüfen, Postprocessing auslösen