24 Commits

Author SHA1 Message Date
bjoernpoettker c665451abf Merge pull request 'Freigabe' (#4) from Freigabe into main
Build and Push Multi-Platform Images / build-and-push (push) Successful in 9s
Reviewed-on: #4
2026-06-16 14:49:23 +00:00
bjoernpoettker ef7813f9f9 ci: add manual build workflow with custom image tag
Build and Push Multi-Platform Images / build-and-push (push) Successful in 11s
New workflow_dispatch workflow to build & push backend/frontend images
with a manually chosen tag and service selection (both/backend/frontend).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:48:04 +02:00
bjoernpoettker 66aeab282c Revert "fix: resolve all ESLint errors in backend and frontend"
Build and Push Multi-Platform Images / build-and-push (push) Successful in 19s
This reverts commit 07dfd7e840.
2026-06-16 16:19:11 +02:00
bjoernpoettker 14c11bf718 Revert "feat: auto-move imported emails to IMAP folder and add 90-day cleanup"
This reverts commit b1b30fe1dd.
2026-06-16 16:19:11 +02:00
bjoernpoettker b1b30fe1dd feat: auto-move imported emails to IMAP folder and add 90-day cleanup
Build and Push Multi-Platform Images / build-and-push (push) Successful in 41s
- New ImapFolderService moves emails to configurable "importiert" folder
  after successful import, creating the folder if it doesn't exist
- Daily cron at 03:00 moves emails older than 90 days to trash and empties it
- Extract createImapClient() helper in EmailDownloadService
- Add ensurePageCache() with in-flight deduplication to BarcodeScannerService
- InboxService regenerates page cache on-demand when image file is missing
- IMAP_IMPORTED_FOLDER and IMAP_TRASH_FOLDER added to .env.example and docker-compose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 13:53:56 +02:00
bjoernpoettker 07dfd7e840 fix: resolve all ESLint errors in backend and frontend
Backend 958→0 errors, frontend 98→0 errors. Builds and tsc clean.

Echte Fixes:
- Auth: AuthenticatedUser/AuthenticatedRequest, JwtStrategy + alle 5
  Controller von `@Request() req: any` auf typisierten Request umgestellt
- Error-Handling: neuer getErrorMessage/Stack/Code/getResponseData-Helper;
  alle 50 `catch (err: any)`-Blöcke auf `unknown` + Helper umgestellt
- 24 echte Bugs: require-await, require-imports→ES-Imports, useless-escape,
  misused-promises, tote Imports/Vars, leere catch-Blöcke kommentiert
- document-pipeline: OCR-Ergebnis wird nicht gespeichert (als TODO markiert)

Pragmatisch auf warn herabgestuft (untypisierte Paperless-NGX-API):
no-unsafe-*, restrict-template-expressions, no-base-to-string,
no-explicit-any (FE), react-refresh/only-export-components

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:33:37 +02:00
bjoernpoettker d96e06e86d feat: add Steuertags concept to separate workflow from content tags
Build and Push Multi-Platform Images / build-and-push (push) Successful in 38s
- New steuertag_ids setting to mark tags as workflow-only (not editable)
- DocumentEditModal shows only content tags (non-Steuertags) as editable chips
- Backend preserves Steuertags when saving document tag changes
- ManuellBearbeitenPage renders content tag chips under document title
- New Steuertags settings tab with multi-select and color preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:46:39 +02:00
bjoernpoettker dad0136365 chore: apply ESLint auto-fix across entire backend
Build and Push Multi-Platform Images / build-and-push (push) Successful in 41s
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 <noreply@anthropic.com>
2026-06-08 09:02:02 +02:00
bjoernpoettker 4c75a1ded2 feat: filter digest tiles by user permissions and add import progress status
Build and Push Multi-Platform Images / build-and-push (push) Successful in 42s
- Store UserGroups from OIDC in UserSettings entity, sync on each request
- Filter daily digest tiles based on user's permission groups
- Add in-memory job status tracking to EmailImportService
- Poll import job status in MailImportWizard and show progress in Spin tip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:29:56 +02:00
bjoernpoettker 2747b0046a feat: redesign daily digest email with card layout and timezone fix
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
- Replace table layout with modern card-based design per dashboard area
- Add icon, color accent, badge and "Öffnen" link per card
- Show summary bar with total open items count
- Fix cron timezone to Europe/Berlin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:00:20 +02:00
bjoernpoettker 15e06bd60f fix: strip trailing slashes from APP_URL and AGRARMONITOR_BASE_URL
Build and Push Multi-Platform Images / build-and-push (push) Successful in 54s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 20:34:35 +02:00
bjoernpoettker 184ac3f5cc feat: add clickable links to daily digest emails via APP_URL
Build and Push Multi-Platform Images / build-and-push (push) Successful in 34s
- Read APP_URL and AGRARMONITOR_BASE_URL from config
- Render dashboard entries as clickable links in HTML digest email
- Add APP_URL and DAILY_DIGEST_CRON to .env.example and docker-compose.yml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 18:12:02 +02:00
bjoernpoettker 52438ee11f feat: add daily digest email notification module
Build and Push Multi-Platform Images / build-and-push (push) Successful in 50s
- New DailyDigestModule with scheduled summary email for open dashboard items
- Extract StatsService from StatsController for reuse in digest
- Add DailyDigestEnabled, UserEmail, UserPreferredUsername to UserSettings entity
- Sync email/username from OIDC token on each get/update call
- Add dailyDigestEnabled to UserSettingsDto and update API
- Notifications tab in UserSettingsPage with enable toggle and "Jetzt senden" button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:57:10 +02:00
bjoernpoettker 029d5b351f fix: also set tag 19 (Von AM zurück) when marking document as manual
Build and Push Multi-Platform Images / build-and-push (push) Successful in 32s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:39:16 +02:00
bjoernpoettker 2444821c9e refactor: rename tagPosteingang to tagManuell for missing AM entries
Build and Push Multi-Platform Images / build-and-push (push) Successful in 34s
- Renamed setting agrarmonitor_tag_posteingang → agrarmonitor_tag_manuell
- Documents not found in AM are now tagged as "Manuell bearbeiten"
  instead of being moved back to Posteingang

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:17:01 +02:00
bjoernpoettker 55b30f1f39 feat: skip documents still in Agrarmonitor Dateieingang during upload check
Build and Push Multi-Platform Images / build-and-push (push) Successful in 31s
- Before moving a document back to Posteingang, check if it's still
  waiting in the Agrarmonitor Dateieingang
- If yes: skip silently (upload is pending processing)
- If no: move to Posteingang tag as before
- Handle 401/403 by clearing the session and aborting the check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:13:34 +02:00
bjoernpoettker e6436b2b9c feat: tag documents as Posteingang when AM entry is missing during upload check
Build and Push Multi-Platform Images / build-and-push (push) Successful in 37s
- Add agrarmonitor_tag_posteingang setting (default empty)
- When a document is not found in Agrarmonitor, move it back to Posteingang
  tag instead of skipping (if tagPosteingang is configured)
- Expose tagPosteingang in polling config API and settings UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:45:58 +02:00
bjoernpoettker 1698eba968 fix: correct polling conditions for eingangsDatum and buchungsDatum
Build and Push Multi-Platform Images / build-and-push (push) Successful in 38s
- Only set eingangsDatum when belegNummer is present
- Import documents when buchungsDatum is set (revert inverted condition)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 14:05:11 +02:00
bjoernpoettker b4dd959b4a fix: load all correspondents instead of first 100 in Paperless API
Build and Push Multi-Platform Images / build-and-push (push) Successful in 31s
Raised page_size from 100 to 9999 on GET /api/paperless/correspondents
so the FreigabePage can resolve all correspondent IDs to names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:56:19 +02:00
bjoernpoettker 036d135109 fix: import documents without buchungsDatum instead of skipping them
Build and Push Multi-Platform Images / build-and-push (push) Successful in 32s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:28:27 +02:00
bjoernpoettker 4016802c1e fix: use manual res.json() in getNextJob to prevent double-response on 204
Build and Push Multi-Platform Images / build-and-push (push) Successful in 31s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:43:03 +02:00
bjoernpoettker d5bc1bcee0 fix: handle object-format select_options from Paperless for Freigabe field
Build and Push Multi-Platform Images / build-and-push (push) Successful in 36s
Paperless may return extra_data.select_options as an array of objects
{id, label} instead of plain strings. This caused React error #31
when Ant Design tried to render an object as a child in the Select and
Table components.

- Backend: coerce option items to {id: string, label: string} regardless
  of whether Paperless returns strings or objects
- Frontend: normalize cf.value to a plain string before rendering or
  storing in state, guarding against object-typed values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 07:01:39 +02:00
bjoernpoettker a0d67c7d1b Merge remote-tracking branch 'origin/main' into Freigabe
Build and Push Multi-Platform Images / build-and-push (push) Successful in 34s
2026-05-25 21:58:46 +02:00
bjoernpoettker 37ffc6c13b feat: implement Freigabesystem for payment approval workflow
Adds a dedicated approval view for PM_Freigabe users to release documents
for payment by setting Paperless custom field 15 to a predefined value.

- Backend: VIEW_FREIGABE permission mapped to PM_Freigabe OIDC group
- Backend: FreigabeErforderlich flag on DocumentType entity (auto-migrated)
- Backend: FreigabeModule with endpoints to list documents, fetch field
  options dynamically from Paperless, and set the approval custom field
- Frontend: /freigabe route with filter (default: nicht freigegeben),
  paginated table, and modal to select approval value
- Frontend: Settings checkbox to mark document types as requiring approval
- Frontend: Freigabe menu item visible only to PM_Freigabe/PM_Admin users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:54:09 +02:00
95 changed files with 5238 additions and 1070 deletions
+6
View File
@@ -61,3 +61,9 @@ AGRARMONITOR_COOKIE_PATH=./data/agrarmonitor-cookies.json
AGRARMONITOR_ENCRYPTION_KEY= # optional, 16+ Zeichen für Cookie-Verschlüsselung AGRARMONITOR_ENCRYPTION_KEY= # optional, 16+ Zeichen für Cookie-Verschlüsselung
AGRARMONITOR_POLLING_CRON=0 */30 * * * * # Polling-Intervall (Standard: alle 30 Minuten); leer lassen zum Deaktivieren AGRARMONITOR_POLLING_CRON=0 */30 * * * * # Polling-Intervall (Standard: alle 30 Minuten); leer lassen zum Deaktivieren
AGRARMONITOR_UPLOAD_CHECK_CRON=0 * * * * * # Upload-Check-Intervall (Standard: einmal pro Minute); leer lassen zum Deaktivieren AGRARMONITOR_UPLOAD_CHECK_CRON=0 * * * * * # Upload-Check-Intervall (Standard: einmal pro Minute); leer lassen zum Deaktivieren
# --- Täglicher Digest ---
# Basis-URL der App für klickbare Links in Digest-E-Mails (z.B. https://paperless.example.com)
# Leer lassen: E-Mails werden ohne Links versendet
APP_URL=
DAILY_DIGEST_CRON= # Standard: 0 7 * * * (täglich 07:00 Uhr Europe/Berlin)
+53
View File
@@ -0,0 +1,53 @@
name: Manual Build and Push (custom tag)
on:
workflow_dispatch:
inputs:
tag:
description: "Container-Tag (z. B. stable, v1.2.3, hotfix)"
required: true
default: "stable"
service:
description: "Welche Images bauen?"
type: choice
required: true
default: "both"
options:
- both
- backend
- frontend
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Show selected inputs
run: |
echo "Tag: ${{ inputs.tag }}"
echo "Service: ${{ inputs.service }}"
echo "Ref: ${{ github.ref_name }}"
- name: Login to Gitea Container Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.poettker-cloud.de -u "${{ gitea.actor }}" --password-stdin
- name: Build and Push Backend
if: ${{ inputs.service == 'both' || inputs.service == 'backend' }}
env:
TAG: ${{ inputs.tag }}
run: |
IMAGE="gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-backend:${TAG}"
docker build -t "$IMAGE" ./paperless-backend
docker push "$IMAGE"
- name: Build and Push Frontend
if: ${{ inputs.service == 'both' || inputs.service == 'frontend' }}
env:
TAG: ${{ inputs.tag }}
run: |
IMAGE="gitea.poettker-cloud.de/bjoernpoettker/paperlessmanager-frontend:${TAG}"
docker build -t "$IMAGE" ./paperless-frontend
docker push "$IMAGE"
+2
View File
@@ -47,6 +47,8 @@ services:
- AGRARMONITOR_ENCRYPTION_KEY=${AGRARMONITOR_ENCRYPTION_KEY:-} - AGRARMONITOR_ENCRYPTION_KEY=${AGRARMONITOR_ENCRYPTION_KEY:-}
- AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-} - AGRARMONITOR_POLLING_CRON=${AGRARMONITOR_POLLING_CRON:-}
- AGRARMONITOR_UPLOAD_CHECK_CRON=${AGRARMONITOR_UPLOAD_CHECK_CRON:-} - AGRARMONITOR_UPLOAD_CHECK_CRON=${AGRARMONITOR_UPLOAD_CHECK_CRON:-}
- APP_URL=${APP_URL:-}
- DAILY_DIGEST_CRON=${DAILY_DIGEST_CRON:-}
volumes: volumes:
- /mnt/scans:/mnt/scans - /mnt/scans:/mnt/scans
- /mnt/paperlessmanager:/mnt/data - /mnt/paperlessmanager:/mnt/data
+988
View File
@@ -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<string, string> | 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<string, string> }
Response: image/png (Buffer)
```
#### `POST /jobs` — Druckjob erstellen
```
Request: { templateId: number, fieldValues?: Record<string, string> }
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Variablen-Substitution: {varName} oder {varName:6} (zero-padded auf 6 Stellen)
function applyVars(template: string, vars: Record<string, string>): 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<string, string>,
): Promise<Buffer> {
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(
`<text x="${x}" y="${yBaseline}" ` +
`font-family="Arial,Helvetica,sans-serif" font-size="${fontSize}" ` +
`font-weight="${fontWeight}" text-anchor="${textAnchor}"${maxWidthAttr}>${content}</text>`,
);
} 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(
`<image href="data:image/png;base64,${b64}" ` +
`x="${mm(el.x)}" y="${mm(el.y)}" width="${size}" height="${size}"/>`,
);
} else if (el.type === 'line') {
const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1;
parts.push(
`<line x1="${mm(el.x1)}" y1="${mm(el.y1)}" ` +
`x2="${mm(el.x2)}" y2="${mm(el.y2)}" ` +
`stroke="black" stroke-width="${strokeWidth}"/>`,
);
}
}
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`,
` width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`,
` <rect width="${W}" height="${H}" fill="white"/>`,
` ${parts.join('\n ')}`,
`</svg>`,
].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, string>): 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<string, string>): Promise<LabelPrintJob> {
const template = await this.templateRepo.findOne({ where: { Id: templateId } });
if (!template) throw new NotFoundException('Template nicht gefunden');
const vars: Record<string, string> = { ...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<LabelPrintJob | null> {
// 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<void>(); // im Service
// Controller:
@Sse('events')
sseEvents(@Res({ passthrough: true }) res: Response): Observable<MessageEvent> {
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<BarcodeTemplate[]>([]);
const [editing, setEditing] = useState<BarcodeTemplate | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(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<string, string> = {};
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
<Modal title="Etikett-Vorschau" width={520}>
<div style={{ background: '#e0e0e0', padding: 24, display: 'flex', justifyContent: 'center' }}>
<img src={previewUrl} style={{ maxWidth: '100%', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }} />
</div>
</Modal>
```
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
<Button icon={<PrinterOutlined />} onClick={openPrintDialog}>
Etikett drucken
</Button>
```
### 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<string, string> {
const today = dayjs().format('YYYY-MM-DD');
const values: Record<string, string> = {};
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<string, string>) =>
api.post('/api/label-print-agent/jobs', { templateId, fieldValues }),
// Vorschau-PNG als Object-URL (von SettingsPage)
previewLabel: async (templateId: number, fieldValues: Record<string, string>): Promise<string> => {
const res = await api.post('/api/label-print-agent/preview',
{ templateId, fieldValues },
{ responseType: 'blob' },
);
return URL.createObjectURL(res.data); // muss nach Verwendung revoked werden
},
};
```
+2 -2
View File
@@ -20,7 +20,7 @@
"@types/form-data": "^2.2.1", "@types/form-data": "^2.2.1",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/uuid": "^10.0.0", "@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", "axios": "^1.14.0",
"basic-ftp": "^5.2.1", "basic-ftp": "^5.2.1",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@@ -4862,7 +4862,7 @@
}, },
"node_modules/agrarmonitor-connector": { "node_modules/agrarmonitor-connector": {
"version": "0.1.0", "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", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.7.9", "axios": "^1.7.9",
@@ -43,9 +43,11 @@ export class AgrarmonitorPollingService implements OnModuleInit {
constructor( constructor(
private readonly agrarmonitorService: AgrarmonitorService, private readonly agrarmonitorService: AgrarmonitorService,
private readonly paperlessService: PaperlessService, private readonly paperlessService: PaperlessService,
@InjectRepository(Setting) private readonly settingRepo: Repository<Setting>, @InjectRepository(Setting)
private readonly settingRepo: Repository<Setting>,
@InjectRepository(Client) private readonly clientRepo: Repository<Client>, @InjectRepository(Client) private readonly clientRepo: Repository<Client>,
@InjectRepository(CorrespondentSetting) private readonly corrSettingRepo: Repository<CorrespondentSetting>, @InjectRepository(CorrespondentSetting)
private readonly corrSettingRepo: Repository<CorrespondentSetting>,
) {} ) {}
async onModuleInit() { async onModuleInit() {
@@ -53,32 +55,46 @@ export class AgrarmonitorPollingService implements OnModuleInit {
await this.upsertSetting('agrarmonitor_tag_verbucht', '9'); await this.upsertSetting('agrarmonitor_tag_verbucht', '9');
await this.upsertSetting('agrarmonitor_tag_hochgeladen', ''); await this.upsertSetting('agrarmonitor_tag_hochgeladen', '');
await this.upsertSetting('agrarmonitor_link_field', ''); await this.upsertSetting('agrarmonitor_link_field', '');
await this.upsertSetting('agrarmonitor_tag_manuell', '');
} }
@Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *') @Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *')
async scheduledPolling() { async scheduledPolling() {
if (!process.env['AGRARMONITOR_POLLING_CRON']) return; 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 * * * * *') @Cron(process.env['AGRARMONITOR_UPLOAD_CHECK_CRON'] || '0 * * * * *')
async scheduledUploadCheck() { async scheduledUploadCheck() {
if (!process.env['AGRARMONITOR_UPLOAD_CHECK_CRON']) return; 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 }> { async getPollingConfig(): Promise<{
const [fertig, verbucht, hochgeladen, linkField] = await Promise.all([ tagFertig: string;
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), tagVerbucht: string;
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), tagHochgeladen: string;
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), linkField: string;
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), 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 { return {
tagFertig: fertig?.Wert ?? '4', tagFertig: fertig?.Wert ?? '4',
tagVerbucht: verbucht?.Wert ?? '9', tagVerbucht: verbucht?.Wert ?? '9',
tagHochgeladen: hochgeladen?.Wert ?? '', tagHochgeladen: hochgeladen?.Wert ?? '',
linkField: linkField?.Wert ?? '', linkField: linkField?.Wert ?? '',
tagManuell: manuell?.Wert ?? '',
}; };
} }
@@ -87,24 +103,57 @@ export class AgrarmonitorPollingService implements OnModuleInit {
tagVerbucht: string, tagVerbucht: string,
tagHochgeladen: string, tagHochgeladen: string,
linkField: string, linkField: 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([ await Promise.all([
this.settingRepo.update({ Tag: 'agrarmonitor_tag_fertig' }, { Wert: tagFertig }), this.settingRepo.update(
this.settingRepo.update({ Tag: 'agrarmonitor_tag_verbucht' }, { Wert: tagVerbucht }), { Tag: 'agrarmonitor_tag_fertig' },
this.settingRepo.update({ Tag: 'agrarmonitor_tag_hochgeladen' }, { Wert: tagHochgeladen }), { Wert: tagFertig },
this.settingRepo.update({ Tag: 'agrarmonitor_link_field' }, { Wert: linkField }), ),
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 }; return { tagFertig, tagVerbucht, tagHochgeladen, linkField, tagManuell };
} }
async runPolling(): Promise<PollingResult> { async runPolling(): Promise<PollingResult> {
if (this.pollingRunning) { if (this.pollingRunning) {
this.logger.warn('Polling läuft bereits, überspringe'); 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; 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'); this.logger.log('Starte Agrarmonitor-Polling');
try { try {
@@ -121,7 +170,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
return { ...result, errors: [msg] }; return { ...result, errors: [msg] };
} }
let amClient: Awaited<ReturnType<typeof this.agrarmonitorService.getClient>>; let amClient: Awaited<
ReturnType<typeof this.agrarmonitorService.getClient>
>;
try { try {
amClient = await this.agrarmonitorService.getClient(); amClient = await this.agrarmonitorService.getClient();
} catch (err: unknown) { } catch (err: unknown) {
@@ -145,7 +196,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
try { try {
await this.getOrCreateCorrespondent(customer, Number(customer.id)); await this.getOrCreateCorrespondent(customer, Number(customer.id));
} catch (err: unknown) { } 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}`,
);
} }
} }
@@ -157,7 +210,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
}); });
const docs: any[] = docsResponse?.results ?? []; const docs: any[] = docsResponse?.results ?? [];
if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) { 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`); this.logger.log(`${docs.length} Dokumente fertig in Agrarmonitor`);
@@ -165,20 +220,25 @@ export class AgrarmonitorPollingService implements OnModuleInit {
result.processed++; result.processed++;
const interneBelegnummer = const interneBelegnummer =
((doc.custom_fields as any[]) ?? []).find( (((doc.custom_fields as any[]) ?? []).find(
(cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID, (cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID,
)?.value as string ?? ''; )?.value as string) ?? '';
if (!interneBelegnummer) { 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++; result.skipped++;
await this.delay(500); await this.delay(500);
continue; continue;
} }
let amResults: Awaited<ReturnType<typeof amClient.eingangsrechnungenLivesearch>>; let amResults: Awaited<
ReturnType<typeof amClient.eingangsrechnungenLivesearch>
>;
try { try {
amResults = await amClient.eingangsrechnungenLivesearch(interneBelegnummer); amResults =
await amClient.eingangsrechnungenLivesearch(interneBelegnummer);
} catch (err: unknown) { } catch (err: unknown) {
const status = (err as any)?.response?.status; const status = (err as any)?.response?.status;
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
@@ -189,14 +249,18 @@ export class AgrarmonitorPollingService implements OnModuleInit {
break; break;
} }
const msg = `${interneBelegnummer}: Livesearch-Fehler`; 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); result.errors.push(msg);
await this.delay(500); await this.delay(500);
continue; continue;
} }
if (amResults.length === 0) { if (amResults.length === 0) {
this.logger.log(`${interneBelegnummer} nicht in Agrarmonitor gefunden`); this.logger.log(
`${interneBelegnummer} nicht in Agrarmonitor gefunden`,
);
result.skipped++; result.skipped++;
await this.delay(500); await this.delay(500);
continue; continue;
@@ -214,13 +278,18 @@ export class AgrarmonitorPollingService implements OnModuleInit {
if (!amDoc.interneBelegNummer && interneBelegnummer) { if (!amDoc.interneBelegNummer && interneBelegnummer) {
try { try {
await amClient.setLieferscheinNummer(amDoc.eingangId, interneBelegnummer); await amClient.setLieferscheinNummer(
amDoc.eingangId,
interneBelegnummer,
);
} catch (err: unknown) { } 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}`,
);
} }
} }
if (!amDoc.eingangsDatum) { if (!amDoc.eingangsDatum && amDoc.belegNummer) {
const eingangsdatumField = ((doc.custom_fields as any[]) ?? []).find( const eingangsdatumField = ((doc.custom_fields as any[]) ?? []).find(
(cf: any) => cf.field === EINGANGSDATUM_FIELD_ID, (cf: any) => cf.field === EINGANGSDATUM_FIELD_ID,
); );
@@ -228,16 +297,24 @@ export class AgrarmonitorPollingService implements OnModuleInit {
const eingangsdatum = new Date(eingangsdatumField.value as string); const eingangsdatum = new Date(eingangsdatumField.value as string);
if (!isNaN(eingangsdatum.getTime())) { if (!isNaN(eingangsdatum.getTime())) {
await amClient.setEingangsdatum(amDoc.eingangId, eingangsdatum); await amClient.setEingangsdatum(amDoc.eingangId, eingangsdatum);
this.logger.log(`Eingangsdatum für ${interneBelegnummer} gesetzt`); this.logger.log(
`Eingangsdatum für ${interneBelegnummer} gesetzt`,
);
} }
} }
result.skipped++; }
} else if (amDoc.buchungsDatum) {
if (amDoc.buchungsDatum) {
try { try {
let correspondentId: number | undefined; 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) { 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; if (corr) correspondentId = corr.id as number;
} }
@@ -248,22 +325,32 @@ export class AgrarmonitorPollingService implements OnModuleInit {
if (matchedClient) ownerId = matchedClient.PaperlessUserId; if (matchedClient) ownerId = matchedClient.PaperlessUserId;
const currentTags: number[] = (doc.tags as number[]) ?? []; 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<string, any> = { tags: newTags }; const updateData: Record<string, any> = { tags: newTags };
if (correspondentId !== undefined) updateData.correspondent = correspondentId; if (correspondentId !== undefined)
updateData.correspondent = correspondentId;
if (ownerId !== undefined) updateData.owner = ownerId; 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`); this.logger.log(`Beleg ${interneBelegnummer} gebucht`);
result.updated++; result.updated++;
} catch (err: unknown) { } catch (err: unknown) {
const msg = `${interneBelegnummer}: Update-Fehler`; 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); result.errors.push(msg);
} }
} else {
result.skipped++;
} }
await this.delay(500); await this.delay(500);
@@ -271,7 +358,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
this.logger.log( this.logger.log(
`Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ` + `Polling abgeschlossen: ${result.processed} verarbeitet, ${result.updated} aktualisiert, ` +
`${result.skipped} übersprungen, ${result.errors.length} Fehler`, `${result.skipped} übersprungen, ${result.errors.length} Fehler`,
); );
} finally { } finally {
this.pollingRunning = false; this.pollingRunning = false;
@@ -283,30 +370,51 @@ export class AgrarmonitorPollingService implements OnModuleInit {
async processVerarbeiteteDocuments(): Promise<PollingResult> { async processVerarbeiteteDocuments(): Promise<PollingResult> {
if (this.uploadCheckRunning) { if (this.uploadCheckRunning) {
this.logger.warn('Upload-Check läuft bereits, überspringe'); 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; 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'); this.logger.log('Starte Upload-Check');
try { try {
const [hochgeladenSetting, fertigSetting, linkFieldSetting] = await Promise.all([ const [
hochgeladenSetting,
fertigSetting,
linkFieldSetting,
manuellSetting,
] = await Promise.all([
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
]); ]);
const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10); const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10);
const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10); const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10);
const linkFieldId = parseInt(linkFieldSetting?.Wert ?? '', 10); const linkFieldId = parseInt(linkFieldSetting?.Wert ?? '', 10);
const tagManuellId = parseInt(manuellSetting?.Wert ?? '', 10);
if (isNaN(tagHochgeladenId)) { 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'] }; return { ...result, errors: ['Tag "hochgeladen" nicht konfiguriert'] };
} }
let amClient: Awaited<ReturnType<typeof this.agrarmonitorService.getClient>>; let amClient: Awaited<
ReturnType<typeof this.agrarmonitorService.getClient>
>;
try { try {
amClient = await this.agrarmonitorService.getClient(); amClient = await this.agrarmonitorService.getClient();
} catch (err: unknown) { } catch (err: unknown) {
@@ -323,20 +431,26 @@ export class AgrarmonitorPollingService implements OnModuleInit {
}); });
const docs: any[] = docsResponse?.results ?? []; const docs: any[] = docsResponse?.results ?? [];
if ((docsResponse?.count ?? 0) > DOCS_PAGE_SIZE) { 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) { for (const doc of docs) {
result.processed++; result.processed++;
const interneBelegnummer = const interneBelegnummer =
((doc.custom_fields as any[]) ?? []).find( (((doc.custom_fields as any[]) ?? []).find(
(cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID, (cf: any) => cf.field === INTERN_BELEGNUMMER_FIELD_ID,
)?.value as string ?? ''; )?.value as string) ?? '';
if (!interneBelegnummer) { 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++; result.skipped++;
await this.delay(500); await this.delay(500);
continue; continue;
@@ -344,7 +458,8 @@ export class AgrarmonitorPollingService implements OnModuleInit {
let vorhanden: boolean; let vorhanden: boolean;
try { try {
vorhanden = await amClient.eingangsrechnungVorhanden(interneBelegnummer); vorhanden =
await amClient.eingangsrechnungVorhanden(interneBelegnummer);
} catch (err: unknown) { } catch (err: unknown) {
const status = (err as any)?.response?.status; const status = (err as any)?.response?.status;
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
@@ -355,23 +470,82 @@ export class AgrarmonitorPollingService implements OnModuleInit {
break; break;
} }
const msg = `${interneBelegnummer}: Vorhanden-Check fehlgeschlagen`; 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); result.errors.push(msg);
await this.delay(500); await this.delay(500);
continue; continue;
} }
if (!vorhanden) { if (!vorhanden) {
result.skipped++; // Prüfen ob Beleg noch im Dateieingang von Agrarmonitor liegt
let imDateieingang: boolean;
try {
imDateieingang =
await amClient.eingangsrechnungImDateieingangVorhanden(
interneBelegnummer,
);
} catch (err: unknown) {
const status = (err as any)?.response?.status;
if (status === 401 || status === 403) {
this.agrarmonitorService.clearClient();
const msg = `Session abgelaufen (${status}) — Upload-Check abgebrochen`;
this.logger.warn(msg);
result.errors.push(msg);
break;
}
// Bei Fehler vorsichtig: nicht verschieben
const msg = `${interneBelegnummer}: Dateieingang-Check fehlgeschlagen`;
this.logger.error(
`${msg}: ${err instanceof Error ? err.message : err}`,
);
result.errors.push(msg);
await this.delay(500);
continue;
}
if (imDateieingang) {
// Noch im Dateieingang — wartet auf Verarbeitung, nichts tun
result.skipped++;
await this.delay(500);
continue;
}
// 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`,
);
result.updated++;
} else {
result.skipped++;
}
await this.delay(500); await this.delay(500);
continue; continue;
} }
this.logger.log(`Dokument ${interneBelegnummer} ist bereits verarbeitet, aktualisiere Paperless`); this.logger.log(
`Dokument ${interneBelegnummer} ist bereits verarbeitet, aktualisiere Paperless`,
);
let amResults: Awaited<ReturnType<typeof amClient.eingangsrechnungenLivesearch>>; let amResults: Awaited<
ReturnType<typeof amClient.eingangsrechnungenLivesearch>
>;
try { try {
amResults = await amClient.eingangsrechnungenLivesearch(interneBelegnummer); amResults =
await amClient.eingangsrechnungenLivesearch(interneBelegnummer);
} catch (err: unknown) { } catch (err: unknown) {
const status = (err as any)?.response?.status; const status = (err as any)?.response?.status;
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
@@ -382,14 +556,18 @@ export class AgrarmonitorPollingService implements OnModuleInit {
break; break;
} }
const msg = `${interneBelegnummer}: Livesearch-Fehler`; 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); result.errors.push(msg);
await this.delay(500); await this.delay(500);
continue; continue;
} }
if (amResults.length > 1) { if (amResults.length > 1) {
this.logger.log(`Dokument ${interneBelegnummer} ist doppelt vorhanden`); this.logger.log(
`Dokument ${interneBelegnummer} ist doppelt vorhanden`,
);
result.skipped++; result.skipped++;
await this.delay(500); await this.delay(500);
continue; continue;
@@ -400,29 +578,49 @@ export class AgrarmonitorPollingService implements OnModuleInit {
try { try {
// Kundendaten abrufen // Kundendaten abrufen
const customer = await amClient.getCustomerById(amDoc.kundenId); const customer = await amClient.getCustomerById(amDoc.kundenId);
const lieferantennummer = (customer['lieferantennummer'] as string) ?? ''; const lieferantennummer =
(customer['lieferantennummer'] as string) ?? '';
if (!lieferantennummer) { 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++; result.skipped++;
await this.delay(500); await this.delay(500);
continue; continue;
} }
// Korrespondent ermitteln oder anlegen // Korrespondent ermitteln oder anlegen
const corr = await this.getOrCreateCorrespondent(customer, amDoc.kundenId); const corr = await this.getOrCreateCorrespondent(
customer,
amDoc.kundenId,
);
// Owner aus Client-Tabelle // Owner aus Client-Tabelle
let ownerId: number | undefined; 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; if (matchedClient) ownerId = matchedClient.PaperlessUserId;
// Tags: hochgeladen entfernen, fertig hinzufügen // Tags: hochgeladen entfernen, fertig hinzufügen
const currentTags: number[] = (doc.tags as number[]) ?? []; 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 // Custom fields aufbauen: bestehende behalten, extern + link setzen
const existingFields: any[] = ((doc.custom_fields as any[]) ?? []).map((f: any) => ({ ...f })); const existingFields: any[] = (
this.setCustomField(existingFields, EXTERN_BELEGNUMMER_FIELD_ID, amDoc.belegNummer); (doc.custom_fields as any[]) ?? []
).map((f: any) => ({ ...f }));
this.setCustomField(
existingFields,
EXTERN_BELEGNUMMER_FIELD_ID,
amDoc.belegNummer,
);
if (!isNaN(linkFieldId)) { if (!isNaN(linkFieldId)) {
this.setCustomField( this.setCustomField(
existingFields, existingFields,
@@ -432,16 +630,21 @@ export class AgrarmonitorPollingService implements OnModuleInit {
} }
const updateData: Record<string, any> = { const updateData: Record<string, any> = {
title: (amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer, title:
(amDoc.dokumentTyp === 0 ? 'ERG ' : 'EGU ') + amDoc.belegNummer,
document_type: amDoc.dokumentTyp === 0 ? 1 : 2, document_type: amDoc.dokumentTyp === 0 ? 1 : 2,
tags: newTags, tags: newTags,
custom_fields: existingFields, 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 (corr) updateData.correspondent = corr.id as number;
if (ownerId !== undefined) updateData.owner = ownerId; 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( await this.paperlessService.addNote(
doc.id as number, doc.id as number,
`Beleg in Agrarmonitor verarbeitet: ${new Date().toLocaleString('de-DE')}`, `Beleg in Agrarmonitor verarbeitet: ${new Date().toLocaleString('de-DE')}`,
@@ -450,7 +653,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
result.updated++; result.updated++;
} catch (err: unknown) { } catch (err: unknown) {
const msg = `${interneBelegnummer}: Update-Fehler`; 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); result.errors.push(msg);
} }
@@ -459,7 +664,7 @@ export class AgrarmonitorPollingService implements OnModuleInit {
this.logger.log( this.logger.log(
`Upload-Check abgeschlossen: ${result.processed} geprüft, ${result.updated} aktualisiert, ` + `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 { } finally {
this.uploadCheckRunning = false; this.uploadCheckRunning = false;
@@ -478,16 +683,20 @@ export class AgrarmonitorPollingService implements OnModuleInit {
} }
async syncCorrespondentIds(): Promise<SyncCorrespondentsResult> { async syncCorrespondentIds(): Promise<SyncCorrespondentsResult> {
let amClient: Awaited<ReturnType<typeof this.agrarmonitorService.getClient>>; let amClient: Awaited<
ReturnType<typeof this.agrarmonitorService.getClient>
>;
try { try {
amClient = await this.agrarmonitorService.getClient(); amClient = await this.agrarmonitorService.getClient();
} catch (err: unknown) { } 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 customers = await amClient.fetchCustomers();
const lieferantMap = new Map<string, number>(); // lieferantennummer → AM-ID const lieferantMap = new Map<string, number>(); // lieferantennummer → AM-ID
const kundenMap = new Map<string, number>(); // kundennummer → AM-ID const kundenMap = new Map<string, number>(); // kundennummer → AM-ID
for (const c of customers) { for (const c of customers) {
const liefNr = String(c['lieferantennummer'] ?? '').trim(); const liefNr = String(c['lieferantennummer'] ?? '').trim();
if (liefNr) lieferantMap.set(liefNr, Number(c.id)); if (liefNr) lieferantMap.set(liefNr, Number(c.id));
@@ -498,14 +707,17 @@ export class AgrarmonitorPollingService implements OnModuleInit {
const allCorrespondents: any[] = []; const allCorrespondents: any[] = [];
let page = 1; let page = 1;
while (true) { 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 ?? [])); allCorrespondents.push(...(resp.results ?? []));
if (!resp.next) break; if (!resp.next) break;
page++; page++;
} }
const lieferantRegex = /\((\d+)\)$/; // reine Zahl → Lieferantennummer const lieferantRegex = /\((\d+)\)$/; // reine Zahl → Lieferantennummer
const kundenRegex = /\(KD(\d+)\)$/; // KD-Prefix → Kundennummer const kundenRegex = /\(KD(\d+)\)$/; // KD-Prefix → Kundennummer
let matched = 0; let matched = 0;
let unmatched = 0; let unmatched = 0;
@@ -521,11 +733,19 @@ export class AgrarmonitorPollingService implements OnModuleInit {
if (liefMatch) amId = lieferantMap.get(liefMatch[1]); 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) { 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 { } else {
setting.AgrarmonitorId = amId; setting.AgrarmonitorId = amId;
} }
@@ -551,29 +771,47 @@ export class AgrarmonitorPollingService implements OnModuleInit {
for (const [amId, corrIds] of byAmId) { for (const [amId, corrIds] of byAmId) {
if (corrIds.length <= 1) continue; 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)); const uniqueNames = new Set(corrs.map((c: any) => c.name as string));
if (uniqueNames.size === 1) { if (uniqueNames.size === 1) {
// Gleicher Name — automatisch zusammenführen // 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); const withDocs = corrs.filter((c: any) => Number(c.document_count) > 0);
if (withoutDocs.length > 0) { if (withoutDocs.length > 0) {
for (const toDelete of withoutDocs) { for (const toDelete of withoutDocs) {
await this.paperlessService.deleteCorrespondent(toDelete.id as number); await this.paperlessService.deleteCorrespondent(
await this.corrSettingRepo.delete({ CorrespondentId: toDelete.id as number }); toDelete.id as number,
);
await this.corrSettingRepo.delete({
CorrespondentId: toDelete.id as number,
});
autoMerged++; 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 { } else {
// Alle haben Dokumente — in den mit den meisten Dokumenten zusammenführen // 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 sorted = [...withDocs].sort(
const keep = sorted[0] as any; (a: any, b: any) =>
Number(b.document_count) - Number(a.document_count),
);
const keep = sorted[0];
for (const toMerge of sorted.slice(1)) { 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++; 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 { } else {
@@ -591,12 +829,21 @@ export class AgrarmonitorPollingService implements OnModuleInit {
this.logger.log( this.logger.log(
`Korrespondenten-Abgleich: ${matched} zugeordnet, ${unmatched} ohne Treffer, ` + `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 mergedDocuments = 0;
let page = 1; let page = 1;
while (true) { while (true) {
@@ -608,7 +855,9 @@ export class AgrarmonitorPollingService implements OnModuleInit {
}); });
const docs: any[] = resp?.results ?? []; const docs: any[] = resp?.results ?? [];
for (const doc of docs) { 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++; mergedDocuments++;
} }
if (!resp?.next) break; if (!resp?.next) break;
@@ -616,14 +865,21 @@ export class AgrarmonitorPollingService implements OnModuleInit {
} }
await this.paperlessService.deleteCorrespondent(deleteId); await this.paperlessService.deleteCorrespondent(deleteId);
await this.corrSettingRepo.delete({ CorrespondentId: 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 }; return { mergedDocuments };
} }
private async getOrCreateCorrespondent(customer: Record<string, unknown>, kundenId?: number): Promise<any> { private async getOrCreateCorrespondent(
customer: Record<string, unknown>,
kundenId?: number,
): Promise<any> {
// Direkter Lookup über gespeicherte Agrarmonitor-ID // Direkter Lookup über gespeicherte Agrarmonitor-ID
if (kundenId !== undefined) { if (kundenId !== undefined) {
const setting = await this.corrSettingRepo.findOneBy({ AgrarmonitorId: kundenId }); const setting = await this.corrSettingRepo.findOneBy({
AgrarmonitorId: kundenId,
});
if (setting) { if (setting) {
return { id: setting.CorrespondentId }; return { id: setting.CorrespondentId };
} }
@@ -645,9 +901,14 @@ export class AgrarmonitorPollingService implements OnModuleInit {
// Link für künftige Läufe speichern // Link für künftige Läufe speichern
if (corr && kundenId !== undefined) { 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) { 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 { } else {
setting.AgrarmonitorId = kundenId; setting.AgrarmonitorId = kundenId;
} }
@@ -657,11 +918,14 @@ export class AgrarmonitorPollingService implements OnModuleInit {
return corr; return corr;
} }
private buildCustomerName(customer: Record<string, unknown>, nummer: string): string { private buildCustomerName(
customer: Record<string, unknown>,
nummer: string,
): string {
const firma = (customer['firma'] as string) ?? ''; const firma = (customer['firma'] as string) ?? '';
const nachname = (customer['nachname'] as string) ?? ''; const nachname = (customer['nachname'] as string) ?? '';
const vorname = (customer['vorname'] as string) ?? ''; const vorname = (customer['vorname'] as string) ?? '';
const name = firma || (nachname + (vorname ? ', ' + vorname : '')); const name = firma || nachname + (vorname ? ', ' + vorname : '');
return `${name} (${nummer})`; return `${name} (${nummer})`;
} }
@@ -669,7 +933,10 @@ export class AgrarmonitorPollingService implements OnModuleInit {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
private async upsertSetting(tag: string, defaultValue: string): Promise<void> { private async upsertSetting(
tag: string,
defaultValue: string,
): Promise<void> {
const existing = await this.settingRepo.findOneBy({ Tag: tag }); const existing = await this.settingRepo.findOneBy({ Tag: tag });
if (!existing) { if (!existing) {
await this.settingRepo.save( await this.settingRepo.save(
@@ -20,7 +20,9 @@ export class AgrarmonitorController {
@Post('register') @Post('register')
@HttpCode(200) @HttpCode(200)
@RequirePermissions(Permission.MANAGE_SETTINGS) @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); return this.service.registerDevice(body.pcName, body.agrarmonitorId);
} }
@@ -32,8 +34,23 @@ export class AgrarmonitorController {
@Put('polling-config') @Put('polling-config')
@RequirePermissions(Permission.MANAGE_SETTINGS) @RequirePermissions(Permission.MANAGE_SETTINGS)
async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string }) { async updatePollingConfig(
return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht, body.tagHochgeladen, body.linkField); @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') @Post('run-polling')
@@ -60,7 +77,9 @@ export class AgrarmonitorController {
@Post('merge-correspondents') @Post('merge-correspondents')
@HttpCode(200) @HttpCode(200)
@RequirePermissions(Permission.MANAGE_SETTINGS) @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); return this.pollingService.mergeCorrespondents(body.keepId, body.deleteId);
} }
} }
@@ -29,16 +29,38 @@ export class AgrarmonitorService {
async getClient(): Promise<AgrarmonitorConnectorResult> { async getClient(): Promise<AgrarmonitorConnectorResult> {
if (this.client) return this.client; if (this.client) return this.client;
const username = this.configService.get<string>('AGRARMONITOR_USERNAME', ''); const username = this.configService.get<string>(
const password = this.configService.get<string>('AGRARMONITOR_PASSWORD', ''); 'AGRARMONITOR_USERNAME',
const baseUrl = this.configService.get<string>('AGRARMONITOR_BASE_URL', 'https://admin7.agrarmonitor.de'); '',
const apiBaseUrl = this.configService.get<string>('AGRARMONITOR_API_BASE_URL', 'https://api.agrarmonitor.de'); );
const password = this.configService.get<string>(
'AGRARMONITOR_PASSWORD',
'',
);
const baseUrl = this.configService.get<string>(
'AGRARMONITOR_BASE_URL',
'https://admin7.agrarmonitor.de',
);
const apiBaseUrl = this.configService.get<string>(
'AGRARMONITOR_API_BASE_URL',
'https://api.agrarmonitor.de',
);
const apiToken = this.configService.get<string>('AGRARMONITOR_API_TOKEN'); const apiToken = this.configService.get<string>('AGRARMONITOR_API_TOKEN');
const cookiePath = this.configService.get<string>('AGRARMONITOR_COOKIE_PATH', './data/agrarmonitor-cookies.json'); const cookiePath = this.configService.get<string>(
const encryptionKey = this.configService.get<string>('AGRARMONITOR_ENCRYPTION_KEY'); 'AGRARMONITOR_COOKIE_PATH',
'./data/agrarmonitor-cookies.json',
);
const encryptionKey = this.configService.get<string>(
'AGRARMONITOR_ENCRYPTION_KEY',
);
const encryptor = encryptionKey ? new AesGcmCookieEncryptor(encryptionKey) : undefined; const encryptor = encryptionKey
const cookieStore = new FileCookieStore(cookiePath, { encryptor, logger: this.logger }); ? new AesGcmCookieEncryptor(encryptionKey)
: undefined;
const cookieStore = new FileCookieStore(cookiePath, {
encryptor,
logger: this.logger,
});
this.client = await createAgrarmonitorClient({ this.client = await createAgrarmonitorClient({
baseUrl, baseUrl,
@@ -84,7 +106,10 @@ export class AgrarmonitorService {
} }
} }
async registerDevice(pcName: string, agrarmonitorId: string): Promise<AgrarmonitorRegisterResultDto> { async registerDevice(
pcName: string,
agrarmonitorId: string,
): Promise<AgrarmonitorRegisterResultDto> {
const client = await this.getClient(); const client = await this.getClient();
const result = await client.registerDevice({ agrarmonitorId, pcName }); const result = await client.registerDevice({ agrarmonitorId, pcName });
return { success: result.success, message: result.message }; return { success: result.success, message: result.message };
+6 -2
View File
@@ -19,6 +19,8 @@ import { InboxPostprocessorModule } from './inbox-postprocessor/inbox-postproces
import { UserSettingsModule } from './user-settings/user-settings.module'; import { UserSettingsModule } from './user-settings/user-settings.module';
import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module'; import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module';
import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module'; import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module';
import { FreigabeModule } from './freigabe/freigabe.module';
import { DailyDigestModule } from './daily-digest/daily-digest.module';
import * as path from 'path'; import * as path from 'path';
@Module({ @Module({
@@ -26,8 +28,8 @@ import * as path from 'path';
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
envFilePath: [ envFilePath: [
path.resolve(__dirname, '../../.env'), // Root .env (zentral) path.resolve(__dirname, '../../.env'), // Root .env (zentral)
path.resolve(__dirname, '../.env'), // Lokale .env (Fallback) path.resolve(__dirname, '../.env'), // Lokale .env (Fallback)
], ],
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
@@ -49,6 +51,8 @@ import * as path from 'path';
UserSettingsModule, UserSettingsModule,
LabelPrintAgentModule, LabelPrintAgentModule,
AgrarmonitorModule, AgrarmonitorModule,
FreigabeModule,
DailyDigestModule,
], ],
}) })
export class AppModule {} export class AppModule {}
+15 -5
View File
@@ -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'; import { ApiKeysService } from './api-keys.service';
@Injectable() @Injectable()
@@ -33,8 +39,8 @@ export class ApiKeyGuard implements CanActivate {
this.logger.log( this.logger.log(
`[${method} ${url}] key source: ${apiKey ? source : 'NONE'} | ` + `[${method} ${url}] key source: ${apiKey ? source : 'NONE'} | ` +
`headers: ${JSON.stringify(Object.keys(request.headers))} | ` + `headers: ${JSON.stringify(Object.keys(request.headers))} | ` +
`key prefix: ${apiKey ? String(apiKey).slice(0, 8) + '…' : 'n/a'}`, `key prefix: ${apiKey ? String(apiKey).slice(0, 8) + '…' : 'n/a'}`,
); );
if (!apiKey) { if (!apiKey) {
@@ -44,11 +50,15 @@ export class ApiKeyGuard implements CanActivate {
try { try {
const keyEntry = await this.apiKeysService.validateKey(apiKey as string); 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 }; request.apiKeyMetadata = { id: keyEntry.id, name: keyEntry.name };
return true; return true;
} catch (err) { } 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'); throw new UnauthorizedException(err.message || 'Invalid API Key');
} }
} }
@@ -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 { ApiKeysService } from './api-keys.service';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
+16 -7
View File
@@ -13,22 +13,27 @@ export class ApiKeysService {
private readonly apiKeyRepo: Repository<ApiKey>, private readonly apiKeyRepo: Repository<ApiKey>,
) {} ) {}
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 prefix = 'pm_';
const randomPart = crypto.randomBytes(24).toString('hex'); // 48 chars hex const randomPart = crypto.randomBytes(24).toString('hex'); // 48 chars hex
const plainKey = `${prefix}${randomPart}`; const plainKey = `${prefix}${randomPart}`;
const keyHash = this.hashKey(plainKey); const keyHash = this.hashKey(plainKey);
const apiKey = this.apiKeyRepo.create({ const apiKey = this.apiKeyRepo.create({
name, name,
keyPrefix: prefix, keyPrefix: prefix,
keyHash, 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); const savedKey = await this.apiKeyRepo.save(apiKey);
return { return {
plainKey, plainKey,
entity: savedKey, entity: savedKey,
@@ -37,7 +42,7 @@ export class ApiKeysService {
async validateKey(plainKey: string): Promise<ApiKey> { async validateKey(plainKey: string): Promise<ApiKey> {
const keyHash = this.hashKey(plainKey); const keyHash = this.hashKey(plainKey);
const apiKey = await this.apiKeyRepo.findOne({ const apiKey = await this.apiKeyRepo.findOne({
where: { keyHash }, where: { keyHash },
}); });
@@ -52,7 +57,11 @@ export class ApiKeysService {
// Update last used timestamp (async, don't wait for it to return response faster) // Update last used timestamp (async, don't wait for it to return response faster)
apiKey.lastUsedAt = new Date(); 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; return apiKey;
} }
+9 -1
View File
@@ -33,6 +33,14 @@ import { PermissionsGuard } from './permissions.guard';
useClass: PermissionsGuard, useClass: PermissionsGuard,
}, },
], ],
exports: [PassportModule, ApiKeysService, ApiKeyGuard, JwtAuthGuard, JwtOrApiKeyGuard, PermissionsGuard, TypeOrmModule], exports: [
PassportModule,
ApiKeysService,
ApiKeyGuard,
JwtAuthGuard,
JwtOrApiKeyGuard,
PermissionsGuard,
TypeOrmModule,
],
}) })
export class AuthModule {} export class AuthModule {}
@@ -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 { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
import { ApiKeyGuard } from './api-key.guard'; import { ApiKeyGuard } from './api-key.guard';
@@ -28,7 +33,9 @@ export class JwtOrApiKeyGuard implements CanActivate {
// Try JWT first // Try JWT first
try { try {
const result = this.jwtGuard.canActivate(context); 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) { if (jwtOk) {
this.logger.log(`${tag} authenticated via JWT`); this.logger.log(`${tag} authenticated via JWT`);
return true; return true;
+8 -1
View File
@@ -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 || []; const groups = payload.groups || [];
return { return {
userId: payload.sub, userId: payload.sub,
@@ -2,4 +2,5 @@ import { SetMetadata } from '@nestjs/common';
import { Permission } from './permissions.enum'; import { Permission } from './permissions.enum';
export const PERMISSIONS_KEY = 'permissions'; export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: Permission[]) => SetMetadata(PERMISSIONS_KEY, permissions); export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
@@ -5,11 +5,14 @@ export const Permission = {
VIEW_INBOX: 'VIEW_INBOX', VIEW_INBOX: 'VIEW_INBOX',
VIEW_SCANNER: 'VIEW_SCANNER', VIEW_SCANNER: 'VIEW_SCANNER',
MANAGE_SETTINGS: 'MANAGE_SETTINGS', MANAGE_SETTINGS: 'MANAGE_SETTINGS',
VIEW_FREIGABE: 'VIEW_FREIGABE',
} as const; } 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<Permission>(); const permissions = new Set<Permission>();
if (!groups || !Array.isArray(groups)) { if (!groups || !Array.isArray(groups)) {
@@ -23,13 +26,16 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per
permissions.add(Permission.VIEW_INBOX); permissions.add(Permission.VIEW_INBOX);
permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.VIEW_SCANNER);
permissions.add(Permission.MANAGE_SETTINGS); permissions.add(Permission.MANAGE_SETTINGS);
permissions.add(Permission.VIEW_FREIGABE);
return Array.from(permissions); 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_Maileingang')) permissions.add(Permission.VIEW_MAIL);
if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX); if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX);
if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER); if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER);
if (groups.includes('PM_Freigabe')) permissions.add(Permission.VIEW_FREIGABE);
return Array.from(permissions); return Array.from(permissions);
} }
+13 -11
View File
@@ -8,32 +8,34 @@ export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {} constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(PERMISSIONS_KEY, [ const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
context.getHandler(), PERMISSIONS_KEY,
context.getClass(), [context.getHandler(), context.getClass()],
]); );
if (!requiredPermissions) { if (!requiredPermissions) {
return true; return true;
} }
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const { user } = request; const { user } = request;
if (request.apiKeyMetadata) { if (request.apiKeyMetadata) {
return true; return true;
} }
if (!user || !user.permissions) { if (!user || !user.permissions) {
return false; return false;
} }
const userPermissions = user.permissions as Permission[]; const userPermissions = user.permissions as Permission[];
if (userPermissions.includes(Permission.MANAGE_ALL)) { if (userPermissions.includes(Permission.MANAGE_ALL)) {
return true; return true;
} }
return requiredPermissions.some((permission) => userPermissions.includes(permission)); return requiredPermissions.some((permission) =>
userPermissions.includes(permission),
);
} }
} }
@@ -9,9 +9,15 @@ import {
BarcodeTemplate, BarcodeTemplate,
type BarcodeActionType, type BarcodeActionType,
} from '../database/entities/barcode-template.entity'; } 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 { PageCacheService } from './page-cache.service';
import { applyTemplate, buildVariables } from '../inbox-postprocessor/variable-resolver'; import {
applyTemplate,
buildVariables,
} from '../inbox-postprocessor/variable-resolver';
export interface MatchedBarcode { export interface MatchedBarcode {
page: number; page: number;
@@ -51,7 +57,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
try { try {
rows = await this.templateRepo.find(); rows = await this.templateRepo.find();
} catch (err: any) { } catch (err: any) {
this.logger.warn(`Template-Migration: Query fehlgeschlagen: ${err.message}`); this.logger.warn(
`Template-Migration: Query fehlgeschlagen: ${err.message}`,
);
return; return;
} }
let migrated = 0; let migrated = 0;
@@ -59,13 +67,17 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
const actions = (tpl.Actions ?? []) as string[]; const actions = (tpl.Actions ?? []) as string[];
if (actions.includes('SPLIT_BEFORE')) { if (actions.includes('SPLIT_BEFORE')) {
tpl.SplitBefore = true; 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); await this.templateRepo.save(tpl);
migrated += 1; migrated += 1;
} }
} }
if (migrated > 0) { 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 { try {
await this.documentRepo.save(doc); await this.documentRepo.save(doc);
} catch (err: any) { } 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); return this.matchTemplates(qrCodes);
@@ -105,7 +119,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
return this.matchTemplates(doc.QrCodes ?? []); return this.matchTemplates(doc.QrCodes ?? []);
} }
private async matchTemplates(qrCodes: StoredQrCode[]): Promise<MatchedBarcode[]> { private async matchTemplates(
qrCodes: StoredQrCode[],
): Promise<MatchedBarcode[]> {
if (qrCodes.length === 0) return []; if (qrCodes.length === 0) return [];
const templates = await this.getTemplates(); const templates = await this.getTemplates();
return qrCodes.map((qr) => { return qrCodes.map((qr) => {
@@ -118,7 +134,11 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
dateinameTemplate: tpl?.DateinameTemplate dateinameTemplate: tpl?.DateinameTemplate
? applyTemplate( ? applyTemplate(
tpl.DateinameTemplate, tpl.DateinameTemplate,
buildVariables({ doc: {} as InboxDocument, template: tpl, matchingQrValue: qr.value }), buildVariables({
doc: {} as InboxDocument,
template: tpl,
matchingQrValue: qr.value,
}),
) )
: null, : null,
splitBefore: tpl?.SplitBefore ?? false, 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) { for (const tpl of templates) {
try { try {
const re = new RegExp(tpl.Regex); const re = new RegExp(tpl.Regex);
@@ -141,7 +164,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
private async getTemplates(): Promise<BarcodeTemplate[]> { private async getTemplates(): Promise<BarcodeTemplate[]> {
if (!this.templatesCache) { if (!this.templatesCache) {
this.templatesCache = await this.templateRepo.find({ order: { Id: 'ASC' } }); this.templatesCache = await this.templateRepo.find({
order: { Id: 'ASC' },
});
} }
return this.templatesCache; return this.templatesCache;
} }
@@ -168,7 +193,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
} }
} }
} catch (err: any) { } 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(); const { width: imgW, height: imgH } = await image.metadata();
if (!imgW || !imgH) return { found: [] }; if (!imgW || !imgH) return { found: [] };
const left = Math.round(Math.max(0, x * imgW)); const left = Math.round(Math.max(0, x * imgW));
const top = Math.round(Math.max(0, y * imgH)); const top = Math.round(Math.max(0, y * imgH));
const width = Math.round(Math.min(imgW - left, w * imgW)); const width = Math.round(Math.min(imgW - left, w * imgW));
const height = Math.round(Math.min(imgH - top, h * imgH)); const height = Math.round(Math.min(imgH - top, h * imgH));
if (width <= 0 || height <= 0) return { found: [] }; 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); const qrResults = await this.qrCodeService.extractFromImage(cropped);
if (qrResults.length === 0) return { found: [] }; 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[] = []; const found: string[] = [];
let changed = false; let changed = false;
@@ -254,7 +286,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
} }
if (docs.length === 0) return { scanned: 0, failed: 0 }; 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 scanned = 0;
let failed = 0; let failed = 0;
for (const doc of docs) { for (const doc of docs) {
@@ -277,7 +311,9 @@ export class BarcodeScannerService implements OnApplicationBootstrap {
failed++; failed++;
} }
} }
this.logger.log(`Rescan abgeschlossen: ${scanned} ok, ${failed} fehlgeschlagen`); this.logger.log(
`Rescan abgeschlossen: ${scanned} ok, ${failed} fehlgeschlagen`,
);
return { scanned, failed }; return { scanned, failed };
} }
} }
@@ -13,12 +13,20 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from '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 { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
import { BarcodeScannerService } from './barcode-scanner.service'; 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 { interface UpsertDto {
Name?: string; Name?: string;
@@ -55,8 +63,11 @@ function validate(dto: UpsertDto, partial = false): void {
if (dto.SplitBefore !== undefined && typeof dto.SplitBefore !== 'boolean') { if (dto.SplitBefore !== undefined && typeof dto.SplitBefore !== 'boolean') {
throw new BadRequestException('SplitBefore muss ein Boolean sein'); throw new BadRequestException('SplitBefore muss ein Boolean sein');
} }
if (dto.DateinameTemplate !== undefined && dto.DateinameTemplate !== null && if (
typeof dto.DateinameTemplate !== 'string') { dto.DateinameTemplate !== undefined &&
dto.DateinameTemplate !== null &&
typeof dto.DateinameTemplate !== 'string'
) {
throw new BadRequestException('DateinameTemplate muss ein String sein'); throw new BadRequestException('DateinameTemplate muss ein String sein');
} }
if (!partial || dto.Actions !== undefined) { if (!partial || dto.Actions !== undefined) {
@@ -83,7 +94,9 @@ export class BarcodeTemplatesController {
private triggerRescan(): void { private triggerRescan(): void {
this.scanner.rescanAll().catch((err) => { 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 }); const existing = await this.repo.findOneBy({ Id: id });
if (!existing) throw new NotFoundException('Vorlage nicht gefunden'); 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.Name !== undefined) existing.Name = dto.Name.trim();
if (dto.Regex !== undefined) existing.Regex = dto.Regex; if (dto.Regex !== undefined) existing.Regex = dto.Regex;
if (dto.SplitBefore !== undefined) existing.SplitBefore = dto.SplitBefore; 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.Actions !== undefined) existing.Actions = dto.Actions;
if (dto.LabelEnabled !== undefined) existing.LabelEnabled = dto.LabelEnabled; if (dto.LabelEnabled !== undefined)
if (dto.LabelWidthMm !== undefined) existing.LabelWidthMm = dto.LabelWidthMm ?? null; existing.LabelEnabled = dto.LabelEnabled;
if (dto.LabelHeightMm !== undefined) existing.LabelHeightMm = dto.LabelHeightMm ?? null; if (dto.LabelWidthMm !== undefined)
if (dto.LabelInputFields !== undefined) existing.LabelInputFields = dto.LabelInputFields ?? null; existing.LabelWidthMm = dto.LabelWidthMm ?? null;
if (dto.LabelGetUrl !== undefined) existing.LabelGetUrl = dto.LabelGetUrl ?? null; if (dto.LabelHeightMm !== undefined)
if (dto.LabelPrintedUrl !== undefined) existing.LabelPrintedUrl = dto.LabelPrintedUrl ?? null; existing.LabelHeightMm = dto.LabelHeightMm ?? null;
if (dto.LabelReleaseUrl !== undefined) existing.LabelReleaseUrl = dto.LabelReleaseUrl ?? null; if (dto.LabelInputFields !== undefined)
if (dto.LabelLayout !== undefined) existing.LabelLayout = dto.LabelLayout ?? null; 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); const saved = await this.repo.save(existing);
this.scanner.invalidateTemplates(); this.scanner.invalidateTemplates();
@@ -12,7 +12,10 @@ export class PageCacheService {
private readonly inboxRoot: string; private readonly inboxRoot: string;
constructor(configService: ConfigService) { constructor(configService: ConfigService) {
this.inboxRoot = configService.get<string>('INBOX_DATA_DIR', '/mnt/data/inbox'); this.inboxRoot = configService.get<string>(
'INBOX_DATA_DIR',
'/mnt/data/inbox',
);
} }
documentDir(documentId: string): string { documentDir(documentId: string): string {
@@ -47,7 +50,10 @@ export class PageCacheService {
try { try {
await fs.copyFile(src, previewDest); 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) { } catch (err: any) {
this.logger.warn( this.logger.warn(
`Seiten-Cache fehlgeschlagen (${documentId} Seite ${page}): ${err.message}`, `Seiten-Cache fehlgeschlagen (${documentId} Seite ${page}): ${err.message}`,
@@ -0,0 +1,30 @@
import { Controller, Post, Request, HttpCode } from '@nestjs/common';
import { DailyDigestService } from './daily-digest.service';
@Controller('api/daily-digest')
export class DailyDigestController {
constructor(private readonly dailyDigestService: DailyDigestService) {}
@Post('send-now')
@HttpCode(200)
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.',
};
}
try {
await this.dailyDigestService.sendDigestForUser(
userId,
email,
preferredUsername,
groups,
);
return { ok: true };
} catch (err: any) {
return { ok: false, error: err.message };
}
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DailyDigestService } from './daily-digest.service';
import { DailyDigestController } from './daily-digest.controller';
import { StatsModule } from '../stats/stats.module';
import { UserSettingsModule } from '../user-settings/user-settings.module';
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
@Module({
imports: [StatsModule, UserSettingsModule, PostprocessingModule],
providers: [DailyDigestService],
controllers: [DailyDigestController],
})
export class DailyDigestModule {}
@@ -0,0 +1,318 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { StatsService, DashboardCounts } from '../stats/stats.service';
import { UserSettingsService } from '../user-settings/user-settings.service';
import { MailService } from '../postprocessing/mail.service';
import { mapGroupsToPermissions, Permission } from '../auth/permissions.enum';
interface DigestTile {
key: keyof DashboardCounts;
title: string;
description: string;
icon: string;
accent: string;
accentSoft: string;
permission: Permission;
}
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,
},
];
function getVisibleTiles(groups: string[] | null | undefined): DigestTile[] {
const permissions = mapGroupsToPermissions(groups ?? []);
return DIGEST_TILES.filter((t) => permissions.includes(t.permission));
}
@Injectable()
export class DailyDigestService {
private readonly logger = new Logger(DailyDigestService.name);
private readonly appUrl: string;
private readonly agrarmonitorBaseUrl: string;
constructor(
private readonly statsService: StatsService,
private readonly userSettingsService: UserSettingsService,
private readonly mailService: MailService,
private readonly configService: ConfigService,
) {
this.appUrl = this.configService
.get<string>('APP_URL', '')
.replace(/\/+$/, '');
this.agrarmonitorBaseUrl = this.configService
.get<string>('AGRARMONITOR_BASE_URL', '')
.replace(/\/+$/, '');
}
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)})`,
);
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 plainText = buildDigestPlainText(counts, today, visibleTiles);
await this.mailService.sendMail({
to: email,
subject: `Paperless Manager Tagesübersicht ${today}`,
body: plainText,
html,
});
this.logger.log(
`Manueller Digest gesendet an ${email} (userId: ${userId})`,
);
}
@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();
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',
});
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`,
);
continue;
}
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!,
subject: `Paperless Manager Tagesübersicht ${today}`,
body: plainText,
html,
});
this.logger.log(`Digest gesendet an ${sub.UserEmail}`);
} catch (err) {
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`
: '';
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) => ({
...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 <strong>${totalOpen} offene Vorgänge</strong> in Ihren Bereichen.`
: 'Alle Bereiche sind auf dem aktuellen Stand. ✓';
const cards = tiles
.map((t) => {
const badge =
t.count > 0
? `<td align="right" valign="top"><span style="display:inline-block;background:${t.accent};color:#ffffff;font-family:sans-serif;font-size:12px;font-weight:700;padding:2px 9px;border-radius:10px;">${t.count}</span></td>`
: '<td></td>';
const footerCount =
t.count > 0
? `<span style="font-family:sans-serif;font-size:13px;color:${t.accent};font-weight:600;">${t.count} offen</span>`
: `<span style="font-family:sans-serif;font-size:13px;color:#9ca3af;">Keine offenen Vorgänge</span>`;
const openLink = t.url
? `<a href="${t.url}" style="font-family:sans-serif;font-size:13px;color:${t.accent};text-decoration:none;font-weight:500;">Öffnen </a>`
: '';
return `
<tr><td style="padding:0 0 16px 0;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff;border:1px solid #e5e7eb;border-top:3px solid ${t.accent};border-radius:8px;">
<tr><td style="padding:20px 20px 16px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td valign="middle">
<span style="display:inline-block;width:40px;height:40px;line-height:40px;text-align:center;background:${t.accentSoft};border-radius:10px;font-size:20px;">${t.icon}</span>
</td>
${badge}
</tr>
</table>
<p style="margin:14px 0 4px;font-family:sans-serif;font-size:15px;font-weight:700;color:#111827;">${t.title}</p>
<p style="margin:0;font-family:sans-serif;font-size:13px;color:#6b7280;line-height:1.5;">${t.description}</p>
</td></tr>
<tr><td style="padding:12px 20px;border-top:1px solid #f3f4f6;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>${footerCount}</td>
<td align="right">${openLink}</td>
</tr>
</table>
</td></tr>
</table>
</td></tr>`;
})
.join('');
return `<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f3f4f6;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0">
<!-- Header -->
<tr><td style="background:#1677ff;padding:28px 32px;border-radius:8px 8px 0 0;">
<p style="margin:0;font-family:sans-serif;font-size:12px;color:#bfdbfe;text-transform:uppercase;letter-spacing:0.08em;">Paperless Manager</p>
<h1 style="margin:6px 0 0;font-family:sans-serif;font-size:22px;font-weight:700;color:#ffffff;">Tagesübersicht</h1>
<p style="margin:4px 0 0;font-family:sans-serif;font-size:14px;color:#bfdbfe;">${today}</p>
</td></tr>
<!-- Summary bar -->
<tr><td style="background:#eff6ff;padding:14px 32px;border-bottom:1px solid #dbeafe;">
<p style="margin:0;font-family:sans-serif;font-size:14px;color:#1e40af;">${summaryText}</p>
</td></tr>
<!-- Cards -->
<tr><td style="padding:24px 32px 8px;">
<table width="100%" cellpadding="0" cellspacing="0">
${cards}
</table>
</td></tr>
<!-- Footer -->
<tr><td style="padding:0 32px 32px;">
<p style="margin:0;font-family:sans-serif;font-size:12px;color:#9ca3af;line-height:1.6;">
Diese E-Mail wird täglich automatisch von Paperless Manager versendet.<br>
Sie können den Digest in den Benutzereinstellungen deaktivieren.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
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:
${lines}
Diese E-Mail wird täglich automatisch von Paperless Manager versendet.
`;
}
@@ -1,4 +1,10 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('api_keys') @Entity('api_keys')
export class ApiKey { export class ApiKey {
@@ -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 { Email } from './email.entity';
import { Content } from './content.entity'; import { Content } from './content.entity';
@@ -15,9 +15,25 @@ export interface LabelInputField {
} }
export type LabelElement = 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: 'text';
| { type: 'line'; x1: number; y1: number; x2: number; y2: number; lineWidth?: number }; 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') @Entity('barcode_templates')
export class BarcodeTemplate { export class BarcodeTemplate {
@@ -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'; import { Attachment } from './attachment.entity';
@Entity('Contents') @Entity('Contents')
@@ -16,4 +16,7 @@ export class DocumentType {
@Column({ type: 'int', nullable: true }) @Column({ type: 'int', nullable: true })
TagReady!: number | null; TagReady!: number | null;
@Column({ type: 'tinyint', width: 1, nullable: true, default: null })
FreigabeErforderlich!: boolean | null;
} }
@@ -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'; import { Attachment } from './attachment.entity';
@Entity('Emails') @Entity('Emails')
@@ -1,7 +1,7 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
export interface FilterCondition { export interface FilterCondition {
field: string; // 'document_type' | 'correspondent' | 'owner' | 'tag' | 'custom_field_<id>' | 'title' | 'archive_serial_number' field: string; // 'document_type' | 'correspondent' | 'owner' | 'tag' | 'custom_field_<id>' | 'title' | 'archive_serial_number'
operator: string; // 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'is_set' | 'is_not_set' operator: string; // 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'is_set' | 'is_not_set'
value: any; value: any;
} }
@@ -11,6 +11,10 @@ export class UserClient {
@Column({ type: 'int' }) @Column({ type: 'int' })
ClientId!: number; ClientId!: number;
@Column({ type: 'enum', enum: ['viewer', 'editor', 'admin'], default: 'editor' }) @Column({
type: 'enum',
enum: ['viewer', 'editor', 'admin'],
default: 'editor',
})
Role!: 'viewer' | 'editor' | 'admin'; Role!: 'viewer' | 'editor' | 'admin';
} }
@@ -34,4 +34,16 @@ export class UserSettings {
@Column({ type: 'json', nullable: true }) @Column({ type: 'json', nullable: true })
EmailRecipientHistory!: string[] | null; EmailRecipientHistory!: string[] | null;
@Column({ type: 'boolean', default: false })
DailyDigestEnabled!: boolean;
@Column({ type: 'json', nullable: true })
UserGroups!: string[] | null;
@Column({ type: 'varchar', length: 255, nullable: true })
UserEmail!: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
UserPreferredUsername!: string | null;
} }
@@ -23,7 +23,8 @@ export class EmailDownloadController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async backfillThumbnails() { async backfillThumbnails() {
this.logger.log('Manueller Backfill für Thumbnails wurde ausgelöst.'); 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 }; return { message: 'Backfill abgeschlossen.', result };
} }
} }
@@ -12,11 +12,10 @@ import { EmailModule } from '../email/email.module';
imports: [ imports: [
TypeOrmModule.forFeature([Email, Attachment, Content]), TypeOrmModule.forFeature([Email, Attachment, Content]),
PreprocessingModule, PreprocessingModule,
EmailModule EmailModule,
], ],
controllers: [EmailDownloadController], controllers: [EmailDownloadController],
providers: [EmailDownloadService], providers: [EmailDownloadService],
exports: [EmailDownloadService], exports: [EmailDownloadService],
}) })
export class EmailDownloadModule {} export class EmailDownloadModule {}
@@ -4,7 +4,11 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ImapFlow, type FetchMessageObject } from 'imapflow'; 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 crypto from 'crypto';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
@@ -26,8 +30,10 @@ export class EmailDownloadService {
private readonly pdfService: PdfService, private readonly pdfService: PdfService,
private readonly pageCache: EmailPageCacheService, private readonly pageCache: EmailPageCacheService,
@InjectRepository(Email) private readonly emailRepo: Repository<Email>, @InjectRepository(Email) private readonly emailRepo: Repository<Email>,
@InjectRepository(Attachment) private readonly attachmentRepo: Repository<Attachment>, @InjectRepository(Attachment)
@InjectRepository(Content) private readonly contentRepo: Repository<Content>, private readonly attachmentRepo: Repository<Attachment>,
@InjectRepository(Content)
private readonly contentRepo: Repository<Content>,
) {} ) {}
@Cron(CronExpression.EVERY_5_MINUTES) @Cron(CronExpression.EVERY_5_MINUTES)
@@ -40,7 +46,10 @@ export class EmailDownloadService {
try { try {
await this.fetchAndStore(); await this.fetchAndStore();
} catch (err: any) { } 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 { } finally {
this.running = false; this.running = false;
} }
@@ -49,12 +58,15 @@ export class EmailDownloadService {
private async fetchAndStore(): Promise<void> { private async fetchAndStore(): Promise<void> {
const host = this.configService.get<string>('IMAP_HOST'); const host = this.configService.get<string>('IMAP_HOST');
const port = this.configService.get<number>('IMAP_PORT', 993); const port = this.configService.get<number>('IMAP_PORT', 993);
const secure = this.configService.get<string>('IMAP_USE_SSL', 'true') === 'true'; const secure =
this.configService.get<string>('IMAP_USE_SSL', 'true') === 'true';
const user = this.configService.get<string>('IMAP_USERNAME'); const user = this.configService.get<string>('IMAP_USERNAME');
const pass = this.configService.get<string>('IMAP_PASSWORD'); const pass = this.configService.get<string>('IMAP_PASSWORD');
if (!host || !user || !pass) { 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; return;
} }
@@ -74,35 +86,47 @@ export class EmailDownloadService {
const lock = await client.getMailboxLock('INBOX'); const lock = await client.getMailboxLock('INBOX');
try { try {
const status = await client.status('INBOX', { messages: true }); 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) { if (!status.messages || status.messages === 0) {
return; return;
} }
const iter = client.fetch( const iter = client.fetch('1:*', {
'1:*', envelope: true,
{ envelope: true, uid: true, source: true }, uid: true,
); source: true,
});
for await (const msg of iter) { for await (const msg of iter) {
const messageId = msg.envelope?.messageId; const messageId = msg.envelope?.messageId;
if (!messageId) continue; if (!messageId) continue;
try { try {
const existing = await this.emailRepo.findOne({ where: { MessageId: messageId } }); const existing = await this.emailRepo.findOne({
where: { MessageId: messageId },
});
if (existing) { if (existing) {
this.logger.debug(`E-Mail mit MessageId ${messageId} bereits vorhanden.`); this.logger.debug(
`E-Mail mit MessageId ${messageId} bereits vorhanden.`,
);
continue; continue;
} }
await this.processMessage(msg); await this.processMessage(msg);
} catch (err: any) { } 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 { } finally {
lock.release(); lock.release();
await client.logout().catch(() => undefined); await client.logout().catch(() => undefined);
@@ -119,12 +143,18 @@ export class EmailDownloadService {
email.MessageId = messageId; email.MessageId = messageId;
email.SenderAddress = formatAddress(parsed.from); email.SenderAddress = formatAddress(parsed.from);
email.RecipientAddress = formatAddress(parsed.to); 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.Date = msg.envelope?.date ?? parsed.date ?? new Date();
email.Body = parsed.html || parsed.text || ''; email.Body = parsed.html || parsed.text || '';
email.Status = 0; email.Status = 0;
const attachmentsToPersist: Array<{ attachment: Attachment; buffer: Buffer }> = []; const attachmentsToPersist: Array<{
attachment: Attachment;
buffer: Buffer;
}> = [];
for (const att of parsed.attachments) { for (const att of parsed.attachments) {
const entry = await this.buildAttachment(att); 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#) // 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) { 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; return;
} }
@@ -152,55 +186,79 @@ export class EmailDownloadService {
// Generate PDF thumbnails if it's a PDF // Generate PDF thumbnails if it's a PDF
if (savedAttachment.ContentType === 'application/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<void> { public async generateThumbnailsForAttachment(
try { attachment: Attachment,
const tempPdfPath = path.join(os.tmpdir(), `email-att-${attachment.Id}.pdf`); buffer: Buffer,
await fs.writeFile(tempPdfPath, buffer); ): Promise<void> {
try {
const images = await this.pdfService.pdfToImages(tempPdfPath, 400); const tempPdfPath = path.join(
await this.pageCache.generate(attachment.Id, images); os.tmpdir(),
`email-att-${attachment.Id}.pdf`,
attachment.PageCount = images.length; );
await this.attachmentRepo.save(attachment); await fs.writeFile(tempPdfPath, buffer);
await this.pdfService.cleanup(images); const images = await this.pdfService.pdfToImages(tempPdfPath, 400);
await fs.unlink(tempPdfPath).catch(() => {}); await this.pageCache.generate(attachment.Id, images);
} catch (err: any) {
this.logger.warn(`Konnte Vorschaubilder für Anhang ${attachment.Id} nicht generieren: ${err.message}`); 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({ const emails = await this.emailRepo.find({
where: { Status: 0 }, where: { Status: 0 },
relations: ['Attachments', 'Attachments.Content'] relations: ['Attachments', 'Attachments.Content'],
}); });
let processed = 0; let processed = 0;
let failed = 0; let failed = 0;
for (const email of emails) { for (const email of emails) {
for (const attachment of email.Attachments) { for (const attachment of email.Attachments) {
if (attachment.ContentType === 'application/pdf' && attachment.PageCount === 0 && attachment.Content?.Content1) { if (
this.logger.log(`Backfill: Generiere Thumbnails für Attachment ${attachment.Id} (Email ${email.Id})`); attachment.ContentType === 'application/pdf' &&
try { attachment.PageCount === 0 &&
await this.generateThumbnailsForAttachment(attachment, attachment.Content.Content1); attachment.Content?.Content1
processed++; ) {
} catch (err) { this.logger.log(
failed++; `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 }; return { processed, failed };
} }
@@ -234,14 +292,19 @@ export class EmailDownloadService {
attachment.IsEmbedded = isEmbedded; attachment.IsEmbedded = isEmbedded;
attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null; attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null;
attachment.Checksum = crypto.createHash('md5').update(buffer).digest('hex'); 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; attachment.ParentId = null;
return { attachment, buffer }; return { attachment, buffer };
} }
} }
function formatAddress(addr: AddressObject | AddressObject[] | undefined): string { function formatAddress(
addr: AddressObject | AddressObject[] | undefined,
): string {
if (!addr) return ''; if (!addr) return '';
const first = Array.isArray(addr) ? addr[0] : addr; const first = Array.isArray(addr) ? addr[0] : addr;
return (first?.text ?? '').slice(0, 255); return (first?.text ?? '').slice(0, 255);
@@ -1,8 +1,4 @@
const KNOWN_NAMES = [ const KNOWN_NAMES = ['factur-x.xml', 'zugferd-invoice.xml', 'xrechnung.xml'];
'factur-x.xml',
'zugferd-invoice.xml',
'xrechnung.xml',
];
export function isERechnung(pdfBuffer: Buffer): boolean { export function isERechnung(pdfBuffer: Buffer): boolean {
const asText = pdfBuffer.toString('latin1').toLowerCase(); const asText = pdfBuffer.toString('latin1').toLowerCase();
@@ -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 type { Response } from 'express';
import { EmailImportService } from './email-import.service'; import { EmailImportService } from './email-import.service';
import { EmailPageCacheService } from './email-page-cache.service'; import { EmailPageCacheService } from './email-page-cache.service';
@@ -23,11 +35,19 @@ export class EmailImportController {
@Post('mappings') @Post('mappings')
@RequirePermissions(Permission.MANAGE_SETTINGS) @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) { 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') @Delete('mappings/:id')
@@ -54,7 +74,11 @@ export class EmailImportController {
@Get('belegnummer') @Get('belegnummer')
@RequirePermissions(Permission.VIEW_MAIL) @RequirePermissions(Permission.VIEW_MAIL)
async getBelegnummer(@Query('date') date: string) { 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); const nummer = await this.importService.getBelegnummer(date);
return { nummer }; return { nummer };
} }
@@ -62,7 +86,11 @@ export class EmailImportController {
@Post('belegnummer/release') @Post('belegnummer/release')
@RequirePermissions(Permission.VIEW_MAIL) @RequirePermissions(Permission.VIEW_MAIL)
async releaseBelegnummer(@Body() body: { date: string; number: string }) { 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); await this.importService.releaseBelegnummer(body.date, body.number);
return { success: true }; return { success: true };
} }
@@ -74,7 +102,10 @@ export class EmailImportController {
@Param('attachmentId') attachmentId: number, @Param('attachmentId') attachmentId: number,
@Body() body: { pages: { start: number; end: 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 }; return { isDuplicate };
} }
@@ -87,10 +118,18 @@ export class EmailImportController {
@Res() res: Response, @Res() res: Response,
) { ) {
try { try {
const pdfBuffer = await this.importService.generatePrintPdf(attachmentId, barcodeData); const pdfBuffer = await this.importService.generatePrintPdf(
this.logger.log(`Print preview generated for attachment ${attachmentId}: ${pdfBuffer.length} bytes`); attachmentId,
barcodeData,
);
this.logger.log(
`Print preview generated for attachment ${attachmentId}: ${pdfBuffer.length} bytes`,
);
res.setHeader('Content-Type', 'application/pdf'); 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); res.send(pdfBuffer);
} catch (err: any) { } catch (err: any) {
this.logger.error(`Error generating print preview: ${err.message}`); this.logger.error(`Error generating print preview: ${err.message}`);
@@ -130,6 +169,15 @@ export class EmailImportController {
res.sendFile(previewPath); res.sendFile(previewPath);
} }
// --- Import Job Status ---
@Get('jobs/:jobId/status')
@RequirePermissions(Permission.VIEW_MAIL)
getJobStatus(@Param('jobId') jobId: string) {
return (
this.importService.getJobStatus(jobId) ?? { message: '', done: false }
);
}
// --- Final Import --- // --- Final Import ---
@Post('execute') @Post('execute')
@RequirePermissions(Permission.VIEW_MAIL) @RequirePermissions(Permission.VIEW_MAIL)
@@ -21,13 +21,33 @@ import * as crypto from 'crypto';
@Injectable() @Injectable()
export class EmailImportService { export class EmailImportService {
private readonly logger = new Logger(EmailImportService.name); private readonly logger = new Logger(EmailImportService.name);
private readonly importJobs = new Map<
string,
{ message: string; done: boolean }
>();
private setJobStatus(
jobId: string | undefined,
message: string,
done = false,
): void {
if (!jobId) return;
this.importJobs.set(jobId, { message, done });
}
getJobStatus(jobId: string): { message: string; done: boolean } | null {
return this.importJobs.get(jobId) ?? null;
}
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
@InjectRepository(Email) private readonly emailRepo: Repository<Email>, @InjectRepository(Email) private readonly emailRepo: Repository<Email>,
@InjectRepository(Attachment) private readonly attachmentRepo: Repository<Attachment>, @InjectRepository(Attachment)
@InjectRepository(Content) private readonly contentRepo: Repository<Content>, private readonly attachmentRepo: Repository<Attachment>,
@InjectRepository(CorrespondentEmailMapping) private readonly mappingRepo: Repository<CorrespondentEmailMapping>, @InjectRepository(Content)
private readonly contentRepo: Repository<Content>,
@InjectRepository(CorrespondentEmailMapping)
private readonly mappingRepo: Repository<CorrespondentEmailMapping>,
@InjectRepository(Task) private readonly taskRepo: Repository<Task>, @InjectRepository(Task) private readonly taskRepo: Repository<Task>,
private readonly paperlessService: PaperlessService, private readonly paperlessService: PaperlessService,
private readonly pdfService: PdfService, private readonly pdfService: PdfService,
@@ -43,8 +63,13 @@ export class EmailImportService {
for (const attachment of attachments) { for (const attachment of attachments) {
const hasPreview = await this.pageCache.hasPreview(attachment.Id, 1); const hasPreview = await this.pageCache.hasPreview(attachment.Id, 1);
if (!hasPreview && attachment.Content?.Content1) { if (!hasPreview && attachment.Content?.Content1) {
this.logger.log(`Generiere fehlende Vorschaubilder für Anhang ${attachment.Id} (Email ${emailId})`); this.logger.log(
const tempPdfPath = path.join(os.tmpdir(), `email-att-gen-${attachment.Id}.pdf`); `Generiere fehlende Vorschaubilder für Anhang ${attachment.Id} (Email ${emailId})`,
);
const tempPdfPath = path.join(
os.tmpdir(),
`email-att-gen-${attachment.Id}.pdf`,
);
try { try {
await fs.writeFile(tempPdfPath, attachment.Content.Content1); await fs.writeFile(tempPdfPath, attachment.Content.Content1);
@@ -56,7 +81,9 @@ export class EmailImportService {
await this.pdfService.cleanup(images); await this.pdfService.cleanup(images);
} catch (err: any) { } 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 { } finally {
await fs.unlink(tempPdfPath).catch(() => {}); await fs.unlink(tempPdfPath).catch(() => {});
} }
@@ -70,9 +97,14 @@ export class EmailImportService {
} }
async addMapping(emailAddress: string, paperlessCorrespondentId: number) { 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) { if (!mapping) {
mapping = this.mappingRepo.create({ EmailAddress: emailAddress, PaperlessCorrespondentId: paperlessCorrespondentId }); mapping = this.mappingRepo.create({
EmailAddress: emailAddress,
PaperlessCorrespondentId: paperlessCorrespondentId,
});
} else { } else {
mapping.PaperlessCorrespondentId = paperlessCorrespondentId; mapping.PaperlessCorrespondentId = paperlessCorrespondentId;
} }
@@ -84,114 +116,177 @@ export class EmailImportService {
} }
async getCorrespondentByEmail(emailAddress: string): Promise<number | null> { async getCorrespondentByEmail(emailAddress: string): Promise<number | null> {
const mapping = await this.mappingRepo.findOne({ where: { EmailAddress: emailAddress } }); const mapping = await this.mappingRepo.findOne({
where: { EmailAddress: emailAddress },
});
return mapping ? mapping.PaperlessCorrespondentId : null; return mapping ? mapping.PaperlessCorrespondentId : null;
} }
// --- Belegnummern API --- // --- Belegnummern API ---
private buildUrl(urlTemplate: string, dateStr: string): string { private buildUrl(urlTemplate: string, dateStr: string): string {
const dateObj = new Date(dateStr); 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); return urlTemplate.replace('{Jahr}', year);
} }
async getBelegnummer(emailDate: string): Promise<string> { async getBelegnummer(emailDate: string): Promise<string> {
const urlTemplate = this.configService.get<string>('BELEGNUMMER_GET_URL'); const urlTemplate = this.configService.get<string>('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); const url = this.buildUrl(urlTemplate, emailDate);
try { try {
this.logger.debug(`Fetching Belegnummer from ${url}`); this.logger.debug(`Fetching Belegnummer from ${url}`);
const response = await axios.get(url); const response = await axios.get(url);
// If the response is an object, try to extract 'nummer' or 'number' // If the response is an object, try to extract 'nummer' or 'number'
let result = response.data; let result = response.data;
if (result && typeof result === 'object') { 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}`); this.logger.debug(`Received Belegnummer: ${result}`);
return String(result); return String(result);
} catch (error: any) { } catch (error: any) {
const status = error.response?.status || 'UNKNOWN'; const status = error.response?.status || 'UNKNOWN';
const detail = error.response?.data ? JSON.stringify(error.response.data) : error.message; const detail = error.response?.data
this.logger.error(`Failed to fetch Belegnummer from ${url}. Status: ${status}, Detail: ${detail}`); ? JSON.stringify(error.response.data)
throw new HttpException(`Fehler beim Abrufen der Belegnummer: ${detail}`, HttpStatus.BAD_GATEWAY); : 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<void> { async releaseBelegnummer(emailDate: string, number: string): Promise<void> {
const urlTemplate = this.configService.get<string>('BELEGNUMMER_RELEASE_URL'); const urlTemplate = this.configService.get<string>(
'BELEGNUMMER_RELEASE_URL',
);
if (!urlTemplate) { if (!urlTemplate) {
this.logger.warn('BELEGNUMMER_RELEASE_URL not configured, skipping release.'); this.logger.warn(
'BELEGNUMMER_RELEASE_URL not configured, skipping release.',
);
return; return;
} }
const cleanNumber = number.replace(/^0+/, '') || '0'; const cleanNumber = number.replace(/^0+/, '') || '0';
let url = this.buildUrl(urlTemplate, emailDate); let url = this.buildUrl(urlTemplate, emailDate);
url = url.replace('{Nummer}', cleanNumber); url = url.replace('{Nummer}', cleanNumber);
try { 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); await axios.get(url);
} catch (error: any) { } 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<void> { async setBelegnummer(emailDate: string, number: string): Promise<void> {
const urlTemplate = this.configService.get<string>('BELEGNUMMER_SET_URL'); const urlTemplate = this.configService.get<string>('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'; const cleanNumber = number.replace(/^0+/, '') || '0';
let url = this.buildUrl(urlTemplate, emailDate); let url = this.buildUrl(urlTemplate, emailDate);
url = url.replace('{Nummer}', cleanNumber); url = url.replace('{Nummer}', cleanNumber);
try { 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); await axios.get(url);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to set Belegnummer at ${url}: ${error.message}`); this.logger.error(
throw new HttpException('Fehler beim Setzen der Belegnummer', HttpStatus.BAD_GATEWAY); `Failed to set Belegnummer at ${url}: ${error.message}`,
);
throw new HttpException(
'Fehler beim Setzen der Belegnummer',
HttpStatus.BAD_GATEWAY,
);
} }
} }
// --- Checksum Check for Split Documents --- // --- Checksum Check for Split Documents ---
async checkSplitChecksum(attachmentId: number, pages: { start: number; end: number }): Promise<boolean> { async checkSplitChecksum(
const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachmentId } }); attachmentId: number,
pages: { start: number; end: number },
): Promise<boolean> {
const content = await this.contentRepo.findOne({
where: { AttachmentEntityId: attachmentId },
});
if (!content) return false; 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 total = pdfDoc.getPageCount();
const startIdx = Math.max(1, pages.start) - 1; const startIdx = Math.max(1, pages.start) - 1;
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1; const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1;
const sliced = await PDFDocument.create(); 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); 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); return this.paperlessService.checksumExists(checksum);
} }
// --- Print Preview --- // --- Print Preview ---
async generatePrintPdf(attachmentId: number, barcodeData: any): Promise<Buffer> { async generatePrintPdf(
const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachmentId } }); attachmentId: number,
if (!content) throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND); barcodeData: any,
): Promise<Buffer> {
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; 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) { if (pages) {
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); const pdfDoc = await PDFDocument.load(pdfBytes, {
ignoreEncryption: true,
});
const total = pdfDoc.getPageCount(); const total = pdfDoc.getPageCount();
const startIdx = Math.max(1, pages.start) - 1; const startIdx = Math.max(1, pages.start) - 1;
const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1; const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1;
const sliced = await PDFDocument.create(); 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); 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()); pdfBytes = Buffer.from(await sliced.save());
} }
@@ -200,18 +295,24 @@ export class EmailImportService {
} }
async applyBarcodeToPdf(pdfBytes: Buffer, barcodeData: any): Promise<Buffer> { async applyBarcodeToPdf(pdfBytes: Buffer, barcodeData: any): Promise<Buffer> {
this.logger.debug(`applyBarcodeToPdf: Input size = ${pdfBytes.length} bytes`); this.logger.debug(
`applyBarcodeToPdf: Input size = ${pdfBytes.length} bytes`,
);
let currentPdfBytes = pdfBytes; let currentPdfBytes = pdfBytes;
const tempInputPath = path.join(os.tmpdir(), `input-${Date.now()}.pdf`); const tempInputPath = path.join(os.tmpdir(), `input-${Date.now()}.pdf`);
await fs.writeFile(tempInputPath, pdfBytes); await fs.writeFile(tempInputPath, pdfBytes);
try { try {
// First try to load to check encryption // 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) { 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); const sanitizedPath = await this.pdfService.sanitizePdf(tempInputPath);
currentPdfBytes = await fs.readFile(sanitizedPath); currentPdfBytes = await fs.readFile(sanitizedPath);
await fs.unlink(sanitizedPath).catch(() => {}); await fs.unlink(sanitizedPath).catch(() => {});
@@ -220,107 +321,128 @@ export class EmailImportService {
} }
const pages = pdfDoc.getPages(); 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) { if (pages.length === 0) {
this.logger.warn('applyBarcodeToPdf: Keine Seiten gefunden'); this.logger.warn('applyBarcodeToPdf: Keine Seiten gefunden');
return Buffer.from(await pdfDoc.save()); return Buffer.from(await pdfDoc.save());
} }
const firstPage = pages[0]; const firstPage = pages[0];
const { x, y, nummer, datum, jahr } = barcodeData; 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}`;
// Dimensions: 57x32 mm // Parse date
const PT_PER_MM = 2.83465; const d = new Date(datum);
const boxW = 57 * PT_PER_MM; const yyyy = (isNaN(d.getTime()) ? new Date() : d)
const boxH = 32 * PT_PER_MM; .getFullYear()
.toString();
// A4 dimensions: 210x297 mm const mm = String(
const PAGE_H_PT = 297 * PT_PER_MM; (isNaN(d.getTime()) ? new Date() : d).getMonth() + 1,
).padStart(2, '0');
// Convert mm to points (Y is from bottom in pdf-lib) const dd = String(
const startX = Number(x) * PT_PER_MM; (isNaN(d.getTime()) ? new Date() : d).getDate(),
const startY = PAGE_H_PT - (Number(y) * PT_PER_MM) - boxH; ).padStart(2, '0');
// 1. Draw Background Box (White with Black border) const qrDateStr = `${yyyy}${mm}${dd}`; // yyyyMMdd
firstPage.drawRectangle({ const qrContent = `${String(jahr).padStart(4, '0')}-${String(nummer).padStart(6, '0')}-${qrDateStr}`;
x: startX, const printDateStr = `${dd}.${mm}.${yyyy}`;
y: startY,
width: boxW,
height: boxH,
color: rgb(1, 1, 1),
borderColor: rgb(0, 0, 0),
borderWidth: 1,
});
// 2. Draw QR Code // Dimensions: 57x32 mm
const qrBuffer = await QRCode.toBuffer(qrContent, { const PT_PER_MM = 2.83465;
errorCorrectionLevel: 'H', const boxW = 57 * PT_PER_MM;
margin: 0, const boxH = 32 * PT_PER_MM;
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,
});
// 3. Draw Texts // A4 dimensions: 210x297 mm
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const PAGE_H_PT = 297 * PT_PER_MM;
// Helper to draw centered text in a specific area // Convert mm to points (Y is from bottom in pdf-lib)
const drawCenteredInArea = (text: string, relX: number, relY: number, areaW: number, areaH: number, fontSize: number) => { const startX = Number(x) * PT_PER_MM;
const textWidth = helveticaBold.widthOfTextAtSize(text, fontSize); const startY = PAGE_H_PT - Number(y) * PT_PER_MM - boxH;
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); // 1. Draw Background Box (White with Black border)
firstPage.drawRectangle({
firstPage.drawText(text, { x: startX,
x: absX, y: startY,
y: absY, width: boxW,
size: fontSize, height: boxH,
font: helveticaBold, color: rgb(1, 1, 1),
color: rgb(0, 0, 0), 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 // QR Code size: 27x27 mm (10% smaller than 30x30)
// Year: Y + 3mm, Height: 7.5mm const qrSize = 27 * PT_PER_MM;
drawCenteredInArea(String(jahr).padStart(4, '0'), 33.3, 3, 21, 7.5, 12); 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 firstPage.drawImage(qrImage, {
const numberText = isNeu ? '<- neu ->' : String(nummer).padStart(6, '0'); x: qrX,
drawCenteredInArea(numberText, 33.3, 10.5, 21, 7.5, 12); y: qrY,
width: qrSize,
height: qrSize,
});
// "Eingegangen": Y + 19mm, Height: 4mm, Size 8 // 3. Draw Texts
drawCenteredInArea('Eingegangen', 33.3, 19, 21, 4, 8); const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
// Date: Y + 19 + 3.5 = 22.5mm, Height: 4mm, Size 8 // Helper to draw centered text in a specific area
drawCenteredInArea(printDateStr, 33.3, 22.5, 21, 4, 8); 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 { } finally {
await fs.unlink(tempInputPath).catch(() => {}); await fs.unlink(tempInputPath).catch(() => {});
} }
@@ -328,28 +450,42 @@ export class EmailImportService {
// --- Import Logic --- // --- Import Logic ---
async executeImport(data: { async executeImport(data: {
jobId?: string;
attachments: { attachments: {
attachmentId: number; attachmentId: number;
type: 'MAIN' | 'ATTACHMENT' | 'IGNORE'; type: 'MAIN' | 'ATTACHMENT' | 'IGNORE';
paperlessCorrespondentId?: number | null; paperlessCorrespondentId?: number | null;
parentDocumentId?: number | null; // Used if type is ATTACHMENT (should map to a Custom Field theoretically, or just tags. For now, CF if configured, but we pass it) parentDocumentId?: number | null;
splitRanges?: { start: number; end: number }[]; // 1-based pages, e.g. [{start: 1, end: 3}, {start: 4, end: 5}] 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; belegnummer?: string;
}[]; }[];
emailDate: string; emailDate: string;
}): Promise<{ success: boolean; results: any[] }> { }): 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 = []; const results = [];
this.setJobStatus(data.jobId, 'Dokumente werden vorbereitet...');
try { try {
for (const att of data.attachments) { for (const att of data.attachments) {
if (att.type === 'IGNORE') continue; 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; 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; if (!content) continue;
const originalPdfBytes = content.Content1; const originalPdfBytes = content.Content1;
@@ -363,26 +499,36 @@ export class EmailImportService {
if (att.splitRanges && att.splitRanges.length > 0) { if (att.splitRanges && att.splitRanges.length > 0) {
// SPLIT PDF // SPLIT PDF
const pdfDoc = await PDFDocument.load(originalPdfBytes, { ignoreEncryption: true }); const pdfDoc = await PDFDocument.load(originalPdfBytes, {
ignoreEncryption: true,
});
const totalPages = pdfDoc.getPageCount(); const totalPages = pdfDoc.getPageCount();
for (const range of att.splitRanges) { for (const range of att.splitRanges) {
const start = Math.max(1, range.start); const start = Math.max(1, range.start);
const end = Math.min(range.end, totalPages); const end = Math.min(range.end, totalPages);
if (start > end) { 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; continue;
} }
const newPdf = await PDFDocument.create(); const newPdf = await PDFDocument.create();
// Pages are 0-indexed in pdf-lib // 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); const copiedPages = await newPdf.copyPages(pdfDoc, pageIndices);
copiedPages.forEach((p) => newPdf.addPage(p)); copiedPages.forEach((p) => newPdf.addPage(p));
const splitPdfBytes = await newPdf.save(); 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)); await fs.writeFile(tempFilePath, Buffer.from(splitPdfBytes));
uploadPromises.push({ uploadPromises.push({
@@ -411,19 +557,28 @@ export class EmailImportService {
for (const uploadItem of uploadPromises) { for (const uploadItem of uploadPromises) {
const options: any = { const options: any = {
filename: uploadItem.filename, filename: uploadItem.filename,
title: att.belegnummer ? `Beleg ${att.belegnummer}` : uploadItem.filename, title: att.belegnummer
? `Beleg ${att.belegnummer}`
: uploadItem.filename,
created: createdDate, created: createdDate,
owner: null, 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) // Create background task for enrichment (same logic as Inbox)
const backgroundTask = this.taskRepo.create({ const backgroundTask = this.taskRepo.create({
TaskId: paperlessTaskId, TaskId: paperlessTaskId,
InterneBelegnummer: att.belegnummer || '', 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, Belegdatum: createdDate,
BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null, BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null,
BetriebID: null, // Owner BetriebID: null, // Owner
@@ -437,16 +592,25 @@ export class EmailImportService {
// Still poll for Doc ID so we can return it to the frontend for immediate preview // Still poll for Doc ID so we can return it to the frontend for immediate preview
let docId = null; let docId = null;
for (let i = 0; i < 30; i++) { for (let i = 0; i < 30; i++) {
await new Promise(resolve => setTimeout(resolve, 2000)); this.setJobStatus(
try { data.jobId,
const taskStatus = await this.paperlessService.getTask(paperlessTaskId); `Warte auf Paperless-Verarbeitung... (${i + 1}/30)`,
// Paperless returns { results: [ ... ] } for filtered tasks );
const statusObj = taskStatus.results ? taskStatus.results[0] : (Array.isArray(taskStatus) ? taskStatus[0] : taskStatus); await new Promise((resolve) => setTimeout(resolve, 2000));
if (statusObj && statusObj.related_document) { try {
docId = statusObj.related_document; const taskStatus =
break; await this.paperlessService.getTask(paperlessTaskId);
} // Paperless returns { results: [ ... ] } for filtered tasks
} catch(e) {} 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) { if (docId) {
@@ -464,7 +628,9 @@ export class EmailImportService {
// Confirm Belegnummer if used // Confirm Belegnummer if used
if (att.belegnummer && att.barcode?.nummer) { 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 }); results.push({ attachmentId: att.attachmentId, paperlessIds });
@@ -472,19 +638,24 @@ export class EmailImportService {
// Mark Email as processed (Status = 1) // Mark Email as processed (Status = 1)
if (data.attachments.length > 0) { if (data.attachments.length > 0) {
const firstAtt = await this.attachmentRepo.findOne({ const firstAtt = await this.attachmentRepo.findOne({
where: { Id: data.attachments[0].attachmentId } where: { Id: data.attachments[0].attachmentId },
}); });
if (firstAtt) { if (firstAtt) {
await this.emailRepo.update(firstAtt.EmailMessageId, { Status: 1 }); 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.`,
);
} }
} }
this.setJobStatus(data.jobId, 'Import abgeschlossen', true);
return { success: true, results }; return { success: true, results };
} finally { } finally {
// Clean up temp dir // Clean up temp dir and job status
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
if (data.jobId)
setTimeout(() => this.importJobs.delete(data.jobId!), 5000);
} }
} }
} }
@@ -12,7 +12,10 @@ export class EmailPageCacheService {
private readonly mailsRoot: string; private readonly mailsRoot: string;
constructor(configService: ConfigService) { constructor(configService: ConfigService) {
this.mailsRoot = configService.get<string>('MAILS_DATA_DIR', '/mnt/data/mails'); this.mailsRoot = configService.get<string>(
'MAILS_DATA_DIR',
'/mnt/data/mails',
);
} }
attachmentDir(attachmentId: number | string): string { attachmentDir(attachmentId: number | string): string {
@@ -20,14 +23,23 @@ export class EmailPageCacheService {
} }
previewPath(attachmentId: number | string, page: number): string { 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 { 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<void> { async generate(
attachmentId: number | string,
renderedImages: string[],
): Promise<void> {
const dir = this.attachmentDir(attachmentId); const dir = this.attachmentDir(attachmentId);
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
@@ -39,14 +51,22 @@ export class EmailPageCacheService {
try { try {
await fs.copyFile(src, previewDest); 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) { } 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<boolean> { async hasPreview(
attachmentId: number | string,
page: number,
): Promise<boolean> {
try { try {
await fs.access(this.previewPath(attachmentId, page)); await fs.access(this.previewPath(attachmentId, page));
return true; return true;
@@ -4,8 +4,26 @@ import { EmailController } from './email.controller';
import { Email } from '../database/entities/email.entity'; import { Email } from '../database/entities/email.entity';
const mockEmails: Partial<Email>[] = [ const mockEmails: Partial<Email>[] = [
{ 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 = { const mockQueryBuilder = {
@@ -44,7 +62,9 @@ describe('EmailController', () => {
it('getEmails filters by status', async () => { it('getEmails filters by status', async () => {
await controller.getEmails('1'); 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 () => { it('getEmail returns single item', async () => {
+78 -26
View File
@@ -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 { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import type { Response } from 'express'; import type { Response } from 'express';
@@ -15,8 +26,10 @@ export class EmailController {
constructor( constructor(
@InjectRepository(Email) private readonly emailRepo: Repository<Email>, @InjectRepository(Email) private readonly emailRepo: Repository<Email>,
@InjectRepository(Attachment) private readonly attachmentRepo: Repository<Attachment>, @InjectRepository(Attachment)
@InjectRepository(Content) private readonly contentRepo: Repository<Content>, private readonly attachmentRepo: Repository<Attachment>,
@InjectRepository(Content)
private readonly contentRepo: Repository<Content>,
private readonly paperlessService: PaperlessService, private readonly paperlessService: PaperlessService,
) {} ) {}
@@ -26,7 +39,8 @@ export class EmailController {
@Query('status') status?: string, @Query('status') status?: string,
@Query('limit') limit?: string, @Query('limit') limit?: string,
) { ) {
const qb = this.emailRepo.createQueryBuilder('e') const qb = this.emailRepo
.createQueryBuilder('e')
.leftJoinAndSelect('e.Attachments', 'a') .leftJoinAndSelect('e.Attachments', 'a')
.orderBy('e.Date', 'DESC') .orderBy('e.Date', 'DESC')
.take(parseInt(limit ?? '50', 10)); .take(parseInt(limit ?? '50', 10));
@@ -66,21 +80,28 @@ export class EmailController {
const attachment = await this.attachmentRepo.findOne({ where: { Id: id } }); const attachment = await this.attachmentRepo.findOne({ where: { Id: id } });
if (!attachment) throw new NotFoundException('Anhang nicht gefunden'); 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'); if (!content) throw new NotFoundException('Inhalt nicht gefunden');
res.setHeader('Content-Type', attachment.ContentType || 'application/octet-stream'); res.setHeader(
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(attachment.FileName)}"`); 'Content-Type',
attachment.ContentType || 'application/octet-stream',
);
res.setHeader(
'Content-Disposition',
`inline; filename="${encodeURIComponent(attachment.FileName)}"`,
);
res.send(content.Content1); res.send(content.Content1);
} }
@Patch(':id/status') @Patch(':id/status')
@RequirePermissions(Permission.MANAGE_ALL) @RequirePermissions(Permission.MANAGE_ALL)
async updateStatus( async updateStatus(@Param('id') id: string, @Body('status') status: number) {
@Param('id') id: string, const email = await this.emailRepo.findOneOrFail({
@Body('status') status: number, where: { Id: parseInt(id, 10) },
) { });
const email = await this.emailRepo.findOneOrFail({ where: { Id: parseInt(id, 10) } });
email.Status = status; email.Status = status;
await this.emailRepo.save(email); await this.emailRepo.save(email);
this.logger.log(`E-Mail ${id} auf Status ${status} gesetzt.`); this.logger.log(`E-Mail ${id} auf Status ${status} gesetzt.`);
@@ -91,10 +112,14 @@ export class EmailController {
@RequirePermissions(Permission.MANAGE_ALL) @RequirePermissions(Permission.MANAGE_ALL)
async checkAttachments(@Body() body: { includeProcessed?: boolean } = {}) { async checkAttachments(@Body() body: { includeProcessed?: boolean } = {}) {
const { includeProcessed = false } = body; 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 { 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({ const emails = await this.emailRepo.find({
where: whereCondition, where: whereCondition,
relations: ['Attachments'], relations: ['Attachments'],
@@ -116,25 +141,43 @@ export class EmailController {
for (const attachment of email.Attachments) { for (const attachment of email.Attachments) {
// Prüfe nur PDFs mit Checksumme // Prüfe nur PDFs mit Checksumme
if (attachment.ContentType === 'application/pdf' && attachment.Checksum) { if (
this.logger.debug(`Prüfe Checksumme für E-Mail ${email.Id}, Anhang ${attachment.Id} (${attachment.FileName})`); attachment.ContentType === 'application/pdf' &&
attachment.Checksum
) {
this.logger.debug(
`Prüfe Checksumme für E-Mail ${email.Id}, Anhang ${attachment.Id} (${attachment.FileName})`,
);
try { try {
const docId = await this.paperlessService.getDocumentIdByChecksum(attachment.Checksum); const docId = await this.paperlessService.getDocumentIdByChecksum(
attachment.Checksum,
);
if (docId !== null) { 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; hasMatch = true;
// PaperlessDocumentId hinterlegen, falls noch nicht vorhanden // PaperlessDocumentId hinterlegen, falls noch nicht vorhanden
const existingIds: Record<string, number> = attachment.PaperlessDocumentIds ?? {}; const existingIds: Record<string, number> =
attachment.PaperlessDocumentIds ?? {};
if (!existingIds['full']) { if (!existingIds['full']) {
attachment.PaperlessDocumentIds = { ...existingIds, full: docId }; attachment.PaperlessDocumentIds = {
...existingIds,
full: docId,
};
await this.attachmentRepo.save(attachment); await this.attachmentRepo.save(attachment);
idsUpdated++; idsUpdated++;
this.logger.log(`Anhang ${attachment.Id}: PaperlessDocumentIds aktualisiert (full=${docId}).`); this.logger.log(
`Anhang ${attachment.Id}: PaperlessDocumentIds aktualisiert (full=${docId}).`,
);
} }
} }
} catch (err: any) { } 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; email.Status = 1;
await this.emailRepo.save(email); await this.emailRepo.save(email);
updatedCount++; 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) { 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 }; return { updatedCount, idsUpdated };
} catch (error: any) { } 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; throw error;
} }
} }
+7 -1
View File
@@ -15,7 +15,13 @@ import { PreprocessingModule } from '../preprocessing/preprocessing.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Email, Attachment, Content, CorrespondentEmailMapping, Task]), TypeOrmModule.forFeature([
Email,
Attachment,
Content,
CorrespondentEmailMapping,
Task,
]),
PaperlessModule, PaperlessModule,
PreprocessingModule, PreprocessingModule,
], ],
@@ -0,0 +1,33 @@
import { Controller, Get, Put, Param, Body, Query } from '@nestjs/common';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
import { FreigabeService } from './freigabe.service';
@Controller('api/freigabe')
@RequirePermissions(Permission.VIEW_FREIGABE)
export class FreigabeController {
constructor(private readonly freigabeService: FreigabeService) {}
@Get('documents')
getDocuments(
@Query('page') page = '1',
@Query('pageSize') pageSize = '25',
@Query('nurNichtFreigegeben') nurNichtFreigegeben = 'true',
) {
return this.freigabeService.getFreigabeDocuments(
parseInt(page, 10),
Math.min(parseInt(pageSize, 10), 100),
nurNichtFreigegeben !== 'false',
);
}
@Put('documents/:id/freigabe')
setFreigabe(@Param('id') id: string, @Body('value') value: string | null) {
return this.freigabeService.setFreigabe(parseInt(id, 10), value ?? null);
}
@Get('options')
getOptions() {
return this.freigabeService.getFreigabeOptions();
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DocumentType } from '../database/entities/document-type.entity';
import { PaperlessModule } from '../paperless/paperless.module';
import { FreigabeController } from './freigabe.controller';
import { FreigabeService } from './freigabe.service';
@Module({
imports: [TypeOrmModule.forFeature([DocumentType]), PaperlessModule],
controllers: [FreigabeController],
providers: [FreigabeService],
})
export class FreigabeModule {}
@@ -0,0 +1,133 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DocumentType } from '../database/entities/document-type.entity';
import { PaperlessService } from '../paperless/paperless.service';
const FREIGABE_FIELD_ID = 15;
@Injectable()
export class FreigabeService {
private readonly logger = new Logger(FreigabeService.name);
constructor(
@InjectRepository(DocumentType)
private readonly documentTypeRepo: Repository<DocumentType>,
private readonly paperlessService: PaperlessService,
) {}
async getFreigabeDocuments(
page: number,
pageSize: number,
nurNichtFreigegeben: boolean,
) {
const docTypes = await this.documentTypeRepo.find({
where: { FreigabeErforderlich: true as any },
});
if (docTypes.length === 0) {
return { count: 0, results: [] };
}
const docTypeIds = docTypes.map((dt) => dt.DocumentTypeId).join(',');
const params: Record<string, any> = {
page,
page_size: pageSize,
document_type__id__in: docTypeIds,
ordering: '-created',
truncate_content: true,
};
if (nurNichtFreigegeben) {
// Filter für Belege, bei denen Custom Field 15 nicht gesetzt ist
params[`custom_fields__field_id`] = FREIGABE_FIELD_ID;
params[`custom_fields__value__isnull`] = true;
}
try {
const result = await this.paperlessService.getDocuments(params);
return result;
} 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',
);
const fallbackParams: Record<string, any> = {
page: 1,
page_size: 9999,
document_type__id__in: docTypeIds,
ordering: '-created',
truncate_content: true,
};
const allDocs = await this.paperlessService.getDocuments(fallbackParams);
const results: any[] = allDocs.results ?? [];
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 start = (page - 1) * pageSize;
return {
count: filtered.length,
results: filtered.slice(start, start + pageSize),
};
}
const start = (page - 1) * pageSize;
return {
count: results.length,
results: results.slice(start, start + pageSize),
};
}
}
async setFreigabe(documentId: number, value: string | null) {
const doc = await this.paperlessService.getDocument(documentId);
const customFields: any[] = [...(doc.custom_fields ?? [])];
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,
});
return { success: true };
}
async getFreigabeOptions(): Promise<{ id: string; label: string }[]> {
const fields = await this.paperlessService.getCustomFields();
const field = fields.find((f: any) => f.id === FREIGABE_FIELD_ID);
if (!field) return [];
const rawOptions: any[] = field.extra_data?.select_options ?? [];
return rawOptions
.filter((o) => o !== null && o !== undefined && o !== '')
.map((o) => {
if (typeof o === 'object') {
// Paperless kann select_options als Objekte liefern
return {
id: String(o.id ?? o.value ?? o.label ?? ''),
label: String(o.label ?? o.name ?? o.id ?? ''),
};
}
return { id: String(o), label: String(o) };
})
.filter((o) => o.id !== '');
}
}
@@ -22,7 +22,7 @@ export async function applyEditsToTemp(
if (!Number.isInteger(pageNum)) continue; if (!Number.isInteger(pageNum)) continue;
const idx = pageNum - 1; const idx = pageNum - 1;
if (idx < 0 || idx >= pdf.getPageCount()) continue; 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; if (normalized === 0) continue;
pdf.getPage(idx).setRotation(degrees(normalized)); pdf.getPage(idx).setRotation(degrees(normalized));
} }
@@ -74,7 +74,7 @@ export async function buildSegmentBuffer(
copied.forEach((page, i) => { copied.forEach((page, i) => {
const rot = rotations[String(segmentPages[i])]; const rot = rotations[String(segmentPages[i])];
if (rot !== undefined) { 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)); if (normalized !== 0) page.setRotation(degrees(normalized));
} }
outPdf.addPage(page); outPdf.addPage(page);
@@ -91,7 +91,7 @@ export async function extractSectionToTemp(
const srcPdf = await PDFDocument.load(bytes, { ignoreEncryption: true }); const srcPdf = await PDFDocument.load(bytes, { ignoreEncryption: true });
const outPdf = await PDFDocument.create(); const outPdf = await PDFDocument.create();
const copied = await outPdf.copyPages(srcPdf, pageIndices); 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 out = await outPdf.save();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inbox-section-')); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inbox-section-'));
const tmpPath = path.join(tmpDir, 'section.pdf'); const tmpPath = path.join(tmpDir, 'section.pdf');
@@ -11,7 +11,12 @@ import { PostprocessingModule } from '../postprocessing/postprocessing.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([InboxPostprocessingAction, InboxDocument, BarcodeTemplate, Task]), TypeOrmModule.forFeature([
InboxPostprocessingAction,
InboxDocument,
BarcodeTemplate,
Task,
]),
BarcodeModule, BarcodeModule,
PaperlessModule, PaperlessModule,
PostprocessingModule, PostprocessingModule,
@@ -4,9 +4,7 @@ import { Repository } from 'typeorm';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { import { InboxPostprocessingAction } from '../database/entities/inbox-postprocessing-action.entity';
InboxPostprocessingAction,
} from '../database/entities/inbox-postprocessing-action.entity';
import { InboxDocument } from '../database/entities/inbox-document.entity'; import { InboxDocument } from '../database/entities/inbox-document.entity';
import { BarcodeTemplate } from '../database/entities/barcode-template.entity'; import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
import { Task } from '../database/entities/task.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 { PaperlessService } from '../paperless/paperless.service';
import { MailService } from '../postprocessing/mail.service'; import { MailService } from '../postprocessing/mail.service';
import { ExportService } from '../postprocessing/export.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'; import { applyTemplate, buildVariables } from './variable-resolver';
function parseFlexDate(s: string): Date | null { function parseFlexDate(s: string): Date | null {
@@ -27,7 +29,9 @@ function parseFlexDate(s: string): Date | null {
// German: DD.MM.YYYY // German: DD.MM.YYYY
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (m) { 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 isNaN(d.getTime()) ? null : d;
} }
return null; return null;
@@ -96,21 +100,26 @@ export class InboxPostprocessorService {
const templates = await this.templateRepo.find({ order: { Id: 'ASC' } }); const templates = await this.templateRepo.find({ order: { Id: 'ASC' } });
const matchedTemplateIds = [ const matchedTemplateIds = [
...new Set( ...new Set(
doc.QrCodes doc.QrCodes.map((qr) => {
.map((qr) => { const tpl = templates.find((t) => {
const tpl = templates.find((t) => { try {
try { return new RegExp(t.Regex).test(qr.value); } return new RegExp(t.Regex).test(qr.value);
catch { return false; } } catch {
}); return false;
return tpl?.Id ?? null; }
}) });
.filter((id): id is number => id !== null), 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({ 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' }, order: { BarcodeTemplateId: 'ASC', Order: 'ASC', Id: 'ASC' },
}); });
@@ -140,18 +149,31 @@ export class InboxPostprocessorService {
} }
// Mapping: Original-Seite → 0-basierter Index in processedPdf // Mapping: Original-Seite → 0-basierter Index in processedPdf
const processedPageIndex = new Map<number, number>(); const processedPageIndex = new Map<number, number>();
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) // QR-Codes mit ihrem Index in der verarbeiteten PDF (gelöschte QR-Seiten herausfiltern)
const qrsWithIdx = doc.QrCodes const qrsWithIdx = doc.QrCodes.filter((qr) =>
.filter(qr => processedPageIndex.has(qr.page)) processedPageIndex.has(qr.page),
.map(qr => ({ page: qr.page, value: qr.value, processedIdx: processedPageIndex.get(qr.page)! })) )
.map((qr) => ({
page: qr.page,
value: qr.value,
processedIdx: processedPageIndex.get(qr.page)!,
}))
.sort((a, b) => a.processedIdx - b.processedIdx); .sort((a, b) => a.processedIdx - b.processedIdx);
// Split-Punkte: 0-basierte Indizes in der verarbeiteten PDF wo SplitBefore-Templates beginnen // Split-Punkte: 0-basierte Indizes in der verarbeiteten PDF wo SplitBefore-Templates beginnen
const splitPoints: number[] = []; const splitPoints: number[] = [];
for (const qr of qrsWithIdx) { 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); if (tplMatch?.SplitBefore) splitPoints.push(qr.processedIdx);
} }
const processedPageCount = survivingOriginalPages.length; const processedPageCount = survivingOriginalPages.length;
@@ -160,7 +182,13 @@ export class InboxPostprocessorService {
let totalSections = 0; let totalSections = 0;
for (let i = 0; i < qrsWithIdx.length; i++) { for (let i = 0; i < qrsWithIdx.length; i++) {
const qr = qrsWithIdx[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 (!tpl) continue;
if (i > 0 && !tpl.SplitBefore) continue; if (i > 0 && !tpl.SplitBefore) continue;
totalSections++; totalSections++;
@@ -177,7 +205,13 @@ export class InboxPostprocessorService {
if (processedSection && processOnlyOne) break; if (processedSection && processOnlyOne) break;
const qr = qrsWithIdx[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 (!tpl) continue;
if (i > 0 && !tpl.SplitBefore) continue; if (i > 0 && !tpl.SplitBefore) continue;
@@ -191,14 +225,27 @@ export class InboxPostprocessorService {
const tplActions = actionsByTemplate.get(tpl.Id); const tplActions = actionsByTemplate.get(tpl.Id);
if (!tplActions || tplActions.length === 0) continue; 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 // Abschnitt aus der verarbeiteten PDF extrahieren
const startIdx = qr.processedIdx; const startIdx = qr.processedIdx;
const nextSplitIdx = splitPoints.find(sp => sp > startIdx); const nextSplitIdx = splitPoints.find((sp) => sp > startIdx);
const endIdx = nextSplitIdx !== undefined ? nextSplitIdx - 1 : processedPageCount - 1; const endIdx =
const pageIndices = Array.from({ length: endIdx - startIdx + 1 }, (_, i) => startIdx + i); nextSplitIdx !== undefined
const sectionPdfPath = await extractSectionToTemp(processedPdfPath, pageIndices); ? 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 const defaultFilenameBase = tpl.DateinameTemplate
? applyTemplate(tpl.DateinameTemplate, variables) ? applyTemplate(tpl.DateinameTemplate, variables)
@@ -209,7 +256,13 @@ export class InboxPostprocessorService {
if (abortProcessing) break; if (abortProcessing) break;
if (action.ActionType === 'PAPERLESS') { if (action.ActionType === 'PAPERLESS') {
try { 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({ results.push({
sectionIndex: currentSectionIndex, sectionIndex: currentSectionIndex,
actionId: action.Id, actionId: action.Id,
@@ -227,17 +280,40 @@ export class InboxPostprocessorService {
this.logger.error( this.logger.error(
`Aktion PAPERLESS#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`, `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 { } else {
try { try {
await this.runAction(action, sectionPdfPath, doc, variables, defaultFilenameBase); await this.runAction(
results.push({ sectionIndex: currentSectionIndex, actionId: action.Id, actionType: action.ActionType, ok: true }); action,
sectionPdfPath,
doc,
variables,
defaultFilenameBase,
);
results.push({
sectionIndex: currentSectionIndex,
actionId: action.Id,
actionType: action.ActionType,
ok: true,
});
} catch (err: any) { } catch (err: any) {
this.logger.error( this.logger.error(
`Aktion ${action.ActionType}#${action.Id} für Dokument ${doc.Id} fehlgeschlagen: ${err.message}`, `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) { switch (action.ActionType) {
case 'MAIL': case 'MAIL':
return this.runMail(content, pdfPath, doc, variables, defaultFilenameBase); return this.runMail(
content,
pdfPath,
doc,
variables,
defaultFilenameBase,
);
case 'EXPORT': case 'EXPORT':
return this.runExport(content, pdfPath, variables, defaultFilenameBase); return this.runExport(content, pdfPath, variables, defaultFilenameBase);
default: default:
@@ -326,9 +408,13 @@ export class InboxPostprocessorService {
): Promise<PaperlessRunResult> { ): Promise<PaperlessRunResult> {
// 1. Interne Belegnummer auflösen (Pflicht) // 1. Interne Belegnummer auflösen (Pflicht)
const intNrTpl = String(content.interneBelegnummer ?? '').trim(); 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(); 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) // 2. ASN bestimmen (vor Duplikat-Checks, damit Paperless danach gefragt werden kann)
const asnTpl = String(content.asn ?? '').trim(); const asnTpl = String(content.asn ?? '').trim();
@@ -344,12 +430,18 @@ export class InboxPostprocessorService {
} }
if (replaceDuplicate) { 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 { } else {
// 3. Duplikat-Check lokal (tasks-Tabelle) // 3. Duplikat-Check lokal (tasks-Tabelle)
const existingTask = await this.taskRepo.findOneBy({ InterneBelegnummer: interneBelegnummer }); const existingTask = await this.taskRepo.findOneBy({
InterneBelegnummer: interneBelegnummer,
});
if (existingTask) { 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 { return {
skipped: true, skipped: true,
message: `Duplikat Belegnummer ${interneBelegnummer} bereits vorhanden`, message: `Duplikat Belegnummer ${interneBelegnummer} bereits vorhanden`,
@@ -358,9 +450,14 @@ export class InboxPostprocessorService {
} }
// 4. Duplikat-Check Paperless API (Custom Field 7 = InterneBelegnummer) // 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) { 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 { return {
skipped: true, skipped: true,
message: `Duplikat Belegnummer ${interneBelegnummer} bereits in Paperless`, message: `Duplikat Belegnummer ${interneBelegnummer} bereits in Paperless`,
@@ -370,9 +467,12 @@ export class InboxPostprocessorService {
// 5. Duplikat-Check Paperless API (archive_serial_number) // 5. Duplikat-Check Paperless API (archive_serial_number)
if (archiveSerialNumber !== undefined) { if (archiveSerialNumber !== undefined) {
const asnDocId = await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber); const asnDocId =
await this.paperlessService.findDocumentIdByAsn(archiveSerialNumber);
if (asnDocId !== null) { 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 { return {
skipped: true, skipped: true,
message: `Duplikat ASN ${archiveSerialNumber} bereits in Paperless`, message: `Duplikat ASN ${archiveSerialNumber} bereits in Paperless`,
@@ -384,10 +484,14 @@ export class InboxPostprocessorService {
// 6. Checksum berechnen und prüfen // 6. Checksum berechnen und prüfen
const buffer = await fs.readFile(pdfPath); const buffer = await fs.readFile(pdfPath);
const checksum = crypto.createHash('md5').update(buffer).digest('hex'); 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) { if (checksumExists) {
this.logger.warn(`Duplikat (Checksum): ${checksum}`); 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; : defaultFilenameBase || undefined;
const tags = Array.isArray(content.tags) 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; : undefined;
const documentType = content.documentType ? Number(content.documentType) : undefined; const documentType = content.documentType
const correspondent = content.correspondent ? Number(content.correspondent) : undefined; ? Number(content.documentType)
const owner = content.owner !== undefined && content.owner !== null && content.owner !== ''
? Number(content.owner)
: undefined; : 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<string, string> | null = const rawCustomFields: Record<string, string> | null =
content.customFields && typeof content.customFields === 'object' content.customFields && typeof content.customFields === 'object'
? Object.fromEntries( ? Object.fromEntries(
Object.entries(content.customFields as Record<string, any>).map(([k, v]) => [ Object.entries(content.customFields as Record<string, any>).map(
k, ([k, v]) => [k, applyTemplate(String(v ?? ''), variables)],
applyTemplate(String(v ?? ''), variables), ),
]),
) )
: null; : null;
@@ -422,7 +534,9 @@ export class InboxPostprocessorService {
const eingangsdatumTpl = String(content.eingangsdatum ?? '').trim(); const eingangsdatumTpl = String(content.eingangsdatum ?? '').trim();
let eingangsdatum: Date; let eingangsdatum: Date;
if (eingangsdatumTpl) { if (eingangsdatumTpl) {
eingangsdatum = parseFlexDate(applyTemplate(eingangsdatumTpl, variables).trim()) ?? new Date(); eingangsdatum =
parseFlexDate(applyTemplate(eingangsdatumTpl, variables).trim()) ??
new Date();
} else if (rawCustomFields?.['9']) { } else if (rawCustomFields?.['9']) {
const parsed = parseFlexDate(rawCustomFields['9']); const parsed = parseFlexDate(rawCustomFields['9']);
if (parsed) { if (parsed) {
@@ -441,10 +555,12 @@ export class InboxPostprocessorService {
// - User-konfigurierte Felder aus rawCustomFields // - User-konfigurierte Felder aus rawCustomFields
const uploadCustomFields: Record<string, string> = {}; const uploadCustomFields: Record<string, string> = {};
if (rawCustomFields) { 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['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 // 6. Upload
const taskIdRaw = await this.paperlessService.uploadDocument(pdfPath, { const taskIdRaw = await this.paperlessService.uploadDocument(pdfPath, {
@@ -469,9 +585,10 @@ export class InboxPostprocessorService {
Tags: tags && tags.length > 0 ? tags.join(',') : null, Tags: tags && tags.length > 0 ? tags.join(',') : null,
BetriebID: owner ?? null, BetriebID: owner ?? null,
externeBelegnummer: null, externeBelegnummer: null,
CustomFieldsJson: rawCustomFields && Object.keys(rawCustomFields).length > 0 CustomFieldsJson:
? JSON.stringify(rawCustomFields) rawCustomFields && Object.keys(rawCustomFields).length > 0
: null, ? JSON.stringify(rawCustomFields)
: null,
Asn: asn || null, Asn: asn || null,
Lieferant: null, Lieferant: null,
EinkaufID: null, EinkaufID: null,
@@ -482,7 +599,9 @@ export class InboxPostprocessorService {
}); });
await this.taskRepo.save(task); 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 {}; return {};
} }
} }
@@ -58,7 +58,10 @@ export function buildVariables(ctx: ResolverContext): Record<string, string> {
* Ersetzt Platzhalter der Form `{name}` und `{name.group}` im Template. * Ersetzt Platzhalter der Form `{name}` und `{name.group}` im Template.
* Unbekannte Platzhalter bleiben unverändert. * Unbekannte Platzhalter bleiben unverändert.
*/ */
export function applyTemplate(template: string, vars: Record<string, string>): string { export function applyTemplate(
template: string,
vars: Record<string, string>,
): string {
if (!template) return template; if (!template) return template;
return template.replace(/\{([A-Za-z0-9_.]+)\}/g, (full, name: string) => { return template.replace(/\{([A-Za-z0-9_.]+)\}/g, (full, name: string) => {
return name in vars ? vars[name] : full; return name in vars ? vars[name] : full;
@@ -10,13 +10,16 @@ export class ClientsController {
constructor( constructor(
@InjectRepository(Client) private readonly clientRepo: Repository<Client>, @InjectRepository(Client) private readonly clientRepo: Repository<Client>,
@InjectRepository(UserClient) private readonly userClientRepo: Repository<UserClient>, @InjectRepository(UserClient)
private readonly userClientRepo: Repository<UserClient>,
) {} ) {}
@Get() @Get()
async getMyClients(@Request() req: any) { async getMyClients(@Request() req: any) {
const userId = req.user.userId; 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); const clientIds = mappings.map((m) => m.ClientId);
if (clientIds.length === 0) { if (clientIds.length === 0) {
@@ -28,17 +28,24 @@ export class InboxMigrationService implements OnApplicationBootstrap {
@InjectRepository(InboxDocument) @InjectRepository(InboxDocument)
private readonly documentRepo: Repository<InboxDocument>, private readonly documentRepo: Repository<InboxDocument>,
) { ) {
this.legacyRoot = this.configService.get<string>('SCANS_DATA_DIR', '/mnt/data/scans'); this.legacyRoot = this.configService.get<string>(
'SCANS_DATA_DIR',
'/mnt/data/scans',
);
} }
async onApplicationBootstrap(): Promise<void> { async onApplicationBootstrap(): Promise<void> {
let subdirs: string[]; let subdirs: string[];
try { 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); subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch (err: any) { } catch (err: any) {
if (err.code !== 'ENOENT') { 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; return;
} }
@@ -61,7 +68,9 @@ export class InboxMigrationService implements OnApplicationBootstrap {
await this.migrateFile(src, subdir, name); await this.migrateFile(src, subdir, name);
migrated += 1; migrated += 1;
} catch (err: any) { } 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<void> { private async migrateFile(
src: string,
subdir: string,
name: string,
): Promise<void> {
const id = randomUUID(); const id = randomUUID();
const source: InboxSource = subdir === 'all' ? 'all' : 'user'; const source: InboxSource = subdir === 'all' ? 'all' : 'user';
const owner = source === 'all' ? null : subdir; const owner = source === 'all' ? null : subdir;
@@ -115,7 +128,9 @@ export class InboxMigrationService implements OnApplicationBootstrap {
} }
} }
private async loadLegacyQrCodes(oldFilePath: string): Promise<StoredQrCode[]> { private async loadLegacyQrCodes(
oldFilePath: string,
): Promise<StoredQrCode[]> {
try { try {
const rows = await this.dataSource.query<LegacyScanRow[]>( const rows = await this.dataSource.query<LegacyScanRow[]>(
'SELECT QrCodes FROM barcode_scans WHERE FilePath = ? LIMIT 1', 'SELECT QrCodes FROM barcode_scans WHERE FilePath = ? LIMIT 1',
+88 -28
View File
@@ -34,7 +34,8 @@ export class InboxController {
@Get() @Get()
async list(@Request() req: any) { 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); return this.inboxService.listFiles(preferredUsername);
} }
@@ -49,11 +50,18 @@ export class InboxController {
@Request() req: any, @Request() req: any,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null; const preferredUsername: string | null =
const { doc, pdfPath } = await this.inboxService.resolveDocument(id, preferredUsername); req.user?.preferredUsername ?? null;
const { doc, pdfPath } = await this.inboxService.resolveDocument(
id,
preferredUsername,
);
res.setHeader('Content-Type', 'application/pdf'); 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)); return new StreamableFile(createReadStream(pdfPath));
} }
@@ -64,8 +72,14 @@ export class InboxController {
@Request() req: any, @Request() req: any,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null; const preferredUsername: string | null =
const filePath = await this.inboxService.resolvePageImage(id, page, 'thumbnail', preferredUsername); req.user?.preferredUsername ?? null;
const filePath = await this.inboxService.resolvePageImage(
id,
page,
'thumbnail',
preferredUsername,
);
res.setHeader('Content-Type', 'image/png'); res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'private, max-age=3600'); res.setHeader('Cache-Control', 'private, max-age=3600');
@@ -75,7 +89,8 @@ export class InboxController {
@Delete(':id') @Delete(':id')
@HttpCode(204) @HttpCode(204)
async remove(@Param('id') id: string, @Request() req: any): Promise<void> { async remove(@Param('id') id: string, @Request() req: any): Promise<void> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null; const preferredUsername: string | null =
req.user?.preferredUsername ?? null;
await this.inboxService.deleteDocument(id, preferredUsername); await this.inboxService.deleteDocument(id, preferredUsername);
} }
@@ -86,7 +101,8 @@ export class InboxController {
@Param('page', ParseIntPipe) page: number, @Param('page', ParseIntPipe) page: number,
@Request() req: any, @Request() req: any,
): Promise<void> { ): Promise<void> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null; const preferredUsername: string | null =
req.user?.preferredUsername ?? null;
await this.inboxService.deletePage(id, page, preferredUsername); await this.inboxService.deletePage(id, page, preferredUsername);
} }
@@ -97,14 +113,19 @@ export class InboxController {
@Param('page', ParseIntPipe) page: number, @Param('page', ParseIntPipe) page: number,
@Request() req: any, @Request() req: any,
): Promise<void> { ): Promise<void> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null; const preferredUsername: string | null =
req.user?.preferredUsername ?? null;
await this.inboxService.toggleManualSplit(id, page, preferredUsername); await this.inboxService.toggleManualSplit(id, page, preferredUsername);
} }
@Post(':id/reset-edits') @Post(':id/reset-edits')
@HttpCode(204) @HttpCode(204)
async resetEdits(@Param('id') id: string, @Request() req: any): Promise<void> { async resetEdits(
const preferredUsername: string | null = req.user?.preferredUsername ?? null; @Param('id') id: string,
@Request() req: any,
): Promise<void> {
const preferredUsername: string | null =
req.user?.preferredUsername ?? null;
await this.inboxService.resetEdits(id, preferredUsername); await this.inboxService.resetEdits(id, preferredUsername);
} }
@@ -112,9 +133,15 @@ export class InboxController {
async postprocess( async postprocess(
@Param('id') id: string, @Param('id') id: string,
@Request() req: any, @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( const { results, totalSections } = await this.postprocessor.runForDocument(
id, id,
preferredUsername, preferredUsername,
@@ -137,8 +164,14 @@ export class InboxController {
if (!Number.isFinite(rotation)) { if (!Number.isFinite(rotation)) {
throw new BadRequestException('rotation muss eine Zahl sein'); throw new BadRequestException('rotation muss eine Zahl sein');
} }
const preferredUsername: string | null = req.user?.preferredUsername ?? null; const preferredUsername: string | null =
await this.inboxService.setPageRotation(id, page, rotation, preferredUsername); req.user?.preferredUsername ?? null;
await this.inboxService.setPageRotation(
id,
page,
rotation,
preferredUsername,
);
} }
@Get(':id/pages/:page/preview') @Get(':id/pages/:page/preview')
@@ -148,8 +181,14 @@ export class InboxController {
@Request() req: any, @Request() req: any,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null; const preferredUsername: string | null =
const filePath = await this.inboxService.resolvePageImage(id, page, 'preview', preferredUsername); req.user?.preferredUsername ?? null;
const filePath = await this.inboxService.resolvePageImage(
id,
page,
'preview',
preferredUsername,
);
res.setHeader('Content-Type', 'image/png'); res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'private, max-age=3600'); 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 }, @Body() body: { x: number; y: number; w: number; h: number },
@Request() req: any, @Request() req: any,
): Promise<{ found: string[] }> { ): Promise<{ found: string[] }> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null; const preferredUsername: string | null =
return this.inboxService.scanRegion(id, page, body.x, body.y, body.w, body.h, preferredUsername); req.user?.preferredUsername ?? null;
return this.inboxService.scanRegion(
id,
page,
body.x,
body.y,
body.w,
body.h,
preferredUsername,
);
} }
@Post(':id/source') @Post(':id/source')
@@ -174,7 +222,8 @@ export class InboxController {
@Body() body: { source: any }, @Body() body: { source: any },
@Request() req: any, @Request() req: any,
): Promise<void> { ): Promise<void> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null; const preferredUsername: string | null =
req.user?.preferredUsername ?? null;
await this.inboxService.updateSource(id, body.source, preferredUsername); await this.inboxService.updateSource(id, body.source, preferredUsername);
} }
@@ -185,11 +234,19 @@ export class InboxController {
@Request() req: any, @Request() req: any,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null; const preferredUsername: string | null =
const { buffer, filename } = await this.inboxService.getSegmentPdfBuffer(id, preferredUsername, body.pages ?? []); req.user?.preferredUsername ?? null;
const { buffer, filename } = await this.inboxService.getSegmentPdfBuffer(
id,
preferredUsername,
body.pages ?? [],
);
const { Readable } = await import('stream'); const { Readable } = await import('stream');
res.setHeader('Content-Type', 'application/pdf'); 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)); return new StreamableFile(Readable.from(buffer));
} }
@@ -197,7 +254,8 @@ export class InboxController {
@HttpCode(204) @HttpCode(204)
async sendEmail( async sendEmail(
@Param('id') id: string, @Param('id') id: string,
@Body() body: { @Body()
body: {
to: string; to: string;
subject: string; subject: string;
body: string; body: string;
@@ -207,10 +265,12 @@ export class InboxController {
}, },
@Request() req: any, @Request() req: any,
): Promise<void> { ): Promise<void> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null; const preferredUsername: string | null =
const smtpOverride = body.sender === 'user' req.user?.preferredUsername ?? null;
? await this.userSettingsService.getSmtpConfig(req.user.userId) const smtpOverride =
: null; body.sender === 'user'
? await this.userSettingsService.getSmtpConfig(req.user.userId)
: null;
await this.inboxService.sendAsEmail(id, preferredUsername, { await this.inboxService.sendAsEmail(id, preferredUsername, {
...body, ...body,
smtpOverride: smtpOverride ?? undefined, smtpOverride: smtpOverride ?? undefined,
+59 -15
View File
@@ -8,7 +8,10 @@ import {
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import * as fs from 'fs/promises'; 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 { PageCacheService } from '../barcode/page-cache.service';
import { import {
InboxDocument, InboxDocument,
@@ -48,7 +51,14 @@ export class InboxService {
async listFiles(preferredUsername: string | null): Promise<InboxFile[]> { async listFiles(preferredUsername: string | null): Promise<InboxFile[]> {
const where = preferredUsername 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 }]; : [{ Source: 'all' as InboxSource, IsScanned: true }];
const docs = await this.documentRepo.find({ const docs = await this.documentRepo.find({
@@ -64,7 +74,9 @@ export class InboxService {
source: doc.Source, source: doc.Source,
pageCount: doc.PageCount, pageCount: doc.PageCount,
deletedPages: [...(doc.DeletedPages ?? [])].sort((a, b) => a - b), 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 ?? {}) }, rotations: { ...(doc.Rotations ?? {}) },
barcodes: await this.barcodeScanner.getMatched(doc), barcodes: await this.barcodeScanner.getMatched(doc),
createdAt: doc.CreatedAt.toISOString(), createdAt: doc.CreatedAt.toISOString(),
@@ -73,7 +85,10 @@ export class InboxService {
return files; return files;
} }
async resolveDocument(id: string, preferredUsername: string | null): Promise<ResolvedDocument> { async resolveDocument(
id: string,
preferredUsername: string | null,
): Promise<ResolvedDocument> {
const doc = await this.documentRepo.findOne({ where: { Id: id } }); const doc = await this.documentRepo.findOne({ where: { Id: id } });
if (!doc) throw new NotFoundException('Dokument nicht gefunden'); if (!doc) throw new NotFoundException('Dokument nicht gefunden');
if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) { if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) {
@@ -85,7 +100,9 @@ export class InboxService {
const stat = await fs.stat(pdfPath); const stat = await fs.stat(pdfPath);
if (!stat.isFile()) throw new Error('not a file'); if (!stat.isFile()) throw new Error('not a file');
} catch (err: any) { } 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'); throw new NotFoundException('Dokument nicht gefunden');
} }
@@ -135,7 +152,7 @@ export class InboxService {
if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) { if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) {
throw new NotFoundException('Seite nicht gefunden'); 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<string, number> = { ...(doc.Rotations ?? {}) }; const next: Record<string, number> = { ...(doc.Rotations ?? {}) };
if (normalized === 0) { if (normalized === 0) {
delete next[String(page)]; delete next[String(page)];
@@ -149,7 +166,10 @@ export class InboxService {
/** /**
* Setzt alle markierten Bearbeitungen (DeletedPages, Rotations, ManualSplitPages) zurück. * Setzt alle markierten Bearbeitungen (DeletedPages, Rotations, ManualSplitPages) zurück.
*/ */
async resetEdits(id: string, preferredUsername: string | null): Promise<void> { async resetEdits(
id: string,
preferredUsername: string | null,
): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername); const { doc } = await this.resolveDocument(id, preferredUsername);
let changed = false; let changed = false;
if (doc.DeletedPages && doc.DeletedPages.length > 0) { if (doc.DeletedPages && doc.DeletedPages.length > 0) {
@@ -170,7 +190,11 @@ export class InboxService {
/** /**
* Setzt oder entfernt einen manuellen Trennpunkt vor der angegebenen Seite. * Setzt oder entfernt einen manuellen Trennpunkt vor der angegebenen Seite.
*/ */
async toggleManualSplit(id: string, page: number, preferredUsername: string | null): Promise<void> { async toggleManualSplit(
id: string,
page: number,
preferredUsername: string | null,
): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername); const { doc } = await this.resolveDocument(id, preferredUsername);
if (!Number.isInteger(page) || page < 2 || page > doc.PageCount) { if (!Number.isInteger(page) || page < 2 || page > doc.PageCount) {
throw new BadRequestException('Ungültige Seitennummer für Trennung'); throw new BadRequestException('Ungültige Seitennummer für Trennung');
@@ -185,14 +209,19 @@ export class InboxService {
await this.documentRepo.save(doc); await this.documentRepo.save(doc);
} }
async deleteDocument(id: string, preferredUsername: string | null): Promise<void> { async deleteDocument(
id: string,
preferredUsername: string | null,
): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername); const { doc } = await this.resolveDocument(id, preferredUsername);
const dir = this.pageCache.documentDir(doc.Id); const dir = this.pageCache.documentDir(doc.Id);
await this.documentRepo.delete(doc.Id); await this.documentRepo.delete(doc.Id);
try { try {
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
} catch (err: any) { } 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; doc.OwnerUsername = null;
} else { } else {
if (!preferredUsername) { 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.Source = 'user';
doc.OwnerUsername = preferredUsername; doc.OwnerUsername = preferredUsername;
@@ -260,7 +291,9 @@ export class InboxService {
): Promise<{ buffer: Buffer; filename: string }> { ): Promise<{ buffer: Buffer; filename: string }> {
const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername); const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername);
const deleted = new Set(doc.DeletedPages ?? []); 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); const buffer = await buildSegmentBuffer(doc, pdfPath, safePages);
return { buffer, filename: doc.OriginalName }; return { buffer, filename: doc.OriginalName };
} }
@@ -274,7 +307,14 @@ export class InboxService {
body: string; body: string;
html?: string; html?: string;
segments: { pages: number[]; filename: 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<void> { ): Promise<void> {
const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername); const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername);
@@ -282,9 +322,13 @@ export class InboxService {
const attachments = await Promise.all( const attachments = await Promise.all(
opts.segments.map(async (seg) => { 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 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 }; return { filename, content };
}), }),
); );
@@ -22,4 +22,3 @@ export class KontonummernController {
return this.kontonummernService.create(dto.correspondentId, dto.nummer); return this.kontonummernService.create(dto.correspondentId, dto.nummer);
} }
} }
@@ -32,7 +32,10 @@ export class LabelPrintAgentController {
@Body() body: { templateId: number; fieldValues?: Record<string, string> }, @Body() body: { templateId: number; fieldValues?: Record<string, string> },
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
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'); const { Readable } = await import('stream');
res.setHeader('Content-Type', 'image/png'); res.setHeader('Content-Type', 'image/png');
return new StreamableFile(Readable.from(buf)); return new StreamableFile(Readable.from(buf));
@@ -45,34 +48,41 @@ export class LabelPrintAgentController {
async createJob( async createJob(
@Body() body: { templateId: number; fieldValues?: Record<string, string> }, @Body() body: { templateId: number; fieldValues?: Record<string, string> },
) { ) {
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) }; return { jobId: String(job.Id) };
} }
// Agent: SSE-Stream für neue Druckaufträge // Agent: SSE-Stream für neue Druckaufträge
@Sse('events') @Sse('events')
sseEvents(@Res({ passthrough: true }) res: Response): Observable<MessageEvent> { sseEvents(
@Res({ passthrough: true }) res: Response,
): Observable<MessageEvent> {
res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('X-Accel-Buffering', 'no');
return this.service.newJob$.pipe( return this.service.newJob$.pipe(
map(() => ({ data: { type: 'label-job-available' } } as MessageEvent)), map(() => ({ data: { type: 'label-job-available' } }) as MessageEvent),
); );
} }
// Agent: nächsten Job abholen (Polling) // Agent: nächsten Job abholen (Polling)
@Get('jobs/next') @Get('jobs/next')
async getNextJob(@Query('agentId') agentId: string, @Res({ passthrough: true }) res: Response) { async getNextJob(@Query('agentId') agentId: string, @Res() res: Response) {
const job = await this.service.claimNextJob(agentId ?? 'unknown'); const job = await this.service.claimNextJob(agentId ?? 'unknown');
if (!job) { if (!job) {
res.status(HttpStatus.NO_CONTENT).send(); res.status(HttpStatus.NO_CONTENT).send();
return; return;
} }
return { res.status(HttpStatus.OK).json({
jobId: String(job.Id), jobId: String(job.Id),
labelImageBase64: job.LabelImageData ? job.LabelImageData.toString('base64') : null, labelImageBase64: job.LabelImageData
? job.LabelImageData.toString('base64')
: null,
labelImageContentType: 'image/png', labelImageContentType: 'image/png',
labelWidthMm: job.LabelWidthMm, labelWidthMm: job.LabelWidthMm,
labelHeightMm: job.LabelHeightMm, labelHeightMm: job.LabelHeightMm,
}; });
} }
// Agent: Bild separat abrufen // Agent: Bild separat abrufen
@@ -95,7 +105,11 @@ export class LabelPrintAgentController {
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() body: { agentId?: string; printerName?: string }, @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 }; return { ok: true };
} }
@@ -104,9 +118,15 @@ export class LabelPrintAgentController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async markError( async markError(
@Param('id', ParseIntPipe) id: number, @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 }; return { ok: true };
} }
} }
@@ -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 { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan, IsNull } from 'typeorm'; import { Repository, LessThan, IsNull } from 'typeorm';
import { Subject, Observable } from 'rxjs'; import { Subject, Observable } from 'rxjs';
@@ -56,9 +61,14 @@ export class LabelPrintAgentService {
templateId: number, templateId: number,
fieldValues: Record<string, string>, fieldValues: Record<string, string>,
): Promise<LabelPrintJob> { ): Promise<LabelPrintJob> {
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) 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 // Variablen aufbauen
const vars: Record<string, string> = { ...fieldValues }; const vars: Record<string, string> = { ...fieldValues };
@@ -127,7 +137,9 @@ export class LabelPrintAgentService {
// Lazy render // Lazy render
if (!candidate.LabelImageData) { 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) { if (template?.LabelLayout?.length) {
try { try {
candidate.LabelImageData = await this.renderer.render( candidate.LabelImageData = await this.renderer.render(
@@ -138,7 +150,9 @@ export class LabelPrintAgentService {
); );
await this.jobRepo.save(candidate); await this.jobRepo.save(candidate);
} catch (err: any) { } 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; return job?.LabelImageData ?? null;
} }
async markPrinted(jobId: number, agentId: string, printerName: string): Promise<void> { async markPrinted(
jobId: number,
agentId: string,
printerName: string,
): Promise<void> {
const job = await this.jobRepo.findOne({ where: { Id: jobId } }); const job = await this.jobRepo.findOne({ where: { Id: jobId } });
if (!job) throw new NotFoundException('Job nicht gefunden'); if (!job) throw new NotFoundException('Job nicht gefunden');
@@ -164,7 +182,12 @@ export class LabelPrintAgentService {
await this.callUrl('PRINTED', job); await this.callUrl('PRINTED', job);
} }
async markError(jobId: number, agentId: string, printerName: string, errorMessage: string): Promise<void> { async markError(
jobId: number,
agentId: string,
printerName: string,
errorMessage: string,
): Promise<void> {
const job = await this.jobRepo.findOne({ where: { Id: jobId } }); const job = await this.jobRepo.findOne({ where: { Id: jobId } });
if (!job) throw new NotFoundException('Job nicht gefunden'); if (!job) throw new NotFoundException('Job nicht gefunden');
@@ -177,10 +200,16 @@ export class LabelPrintAgentService {
await this.callUrl('RELEASE', job); await this.callUrl('RELEASE', job);
} }
async renderPreview(templateId: number, fieldValues: Record<string, string>): Promise<Buffer> { async renderPreview(
const template = await this.templateRepo.findOne({ where: { Id: templateId } }); templateId: number,
fieldValues: Record<string, string>,
): Promise<Buffer> {
const template = await this.templateRepo.findOne({
where: { Id: templateId },
});
if (!template) throw new NotFoundException('Template nicht gefunden'); 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<string, string> = { ...fieldValues }; const vars: Record<string, string> = { ...fieldValues };
for (const field of template.LabelInputFields ?? []) { for (const field of template.LabelInputFields ?? []) {
@@ -204,18 +233,26 @@ export class LabelPrintAgentService {
); );
} }
private async callUrl(type: 'PRINTED' | 'RELEASE', job: LabelPrintJob): Promise<void> { private async callUrl(
type: 'PRINTED' | 'RELEASE',
job: LabelPrintJob,
): Promise<void> {
const template = job.BarcodeTemplateId const template = job.BarcodeTemplateId
? await this.templateRepo.findOne({ where: { Id: job.BarcodeTemplateId } }) ? await this.templateRepo.findOne({
where: { Id: job.BarcodeTemplateId },
})
: null; : null;
if (!template) return; if (!template) return;
const urlTemplate = type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl; const urlTemplate =
type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl;
if (!urlTemplate) return; if (!urlTemplate) return;
const url = applyVars(urlTemplate, job.LabelVariables ?? {}); const url = applyVars(urlTemplate, job.LabelVariables ?? {});
if (!isSafeUrl(url)) { if (!isSafeUrl(url)) {
this.logger.warn(`${type}-URL übersprungen (ungültiges Protokoll): ${url}`); this.logger.warn(
`${type}-URL übersprungen (ungültiges Protokoll): ${url}`,
);
return; return;
} }
try { try {
@@ -10,7 +10,11 @@ function mm(v: number): number {
} }
function escape(s: string): string { function escape(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
} }
function applyVars(template: string, vars: Record<string, string>): string { function applyVars(template: string, vars: Record<string, string>): string {
@@ -49,8 +53,15 @@ export class LabelRendererService {
const fontSize = mm(el.fontSize); const fontSize = mm(el.fontSize);
const content = escape(applyVars(el.content, variables)); const content = escape(applyVars(el.content, variables));
const fontWeight = el.bold ? 'bold' : 'normal'; const fontWeight = el.bold ? 'bold' : 'normal';
const textAnchor = el.align === 'center' ? 'middle' : el.align === 'right' ? 'end' : 'start'; const textAnchor =
const maxWidthAttr = el.maxWidth ? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"` : ''; 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 // 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. // the stored coordinate is the top of the text, not the baseline.
const yBaseline = y + fontSize; const yBaseline = y + fontSize;
@@ -70,9 +81,13 @@ export class LabelRendererService {
errorCorrectionLevel: 'M', errorCorrectionLevel: 'M',
}); });
const b64 = qrBuffer.toString('base64'); const b64 = qrBuffer.toString('base64');
parts.push(`<image href="data:image/png;base64,${b64}" x="${x}" y="${y}" width="${size}" height="${size}"/>`); parts.push(
`<image href="data:image/png;base64,${b64}" x="${x}" y="${y}" width="${size}" height="${size}"/>`,
);
} catch (err: any) { } 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') { } else if (el.type === 'line') {
const x1 = mm(el.x1); const x1 = mm(el.x1);
@@ -80,7 +95,9 @@ export class LabelRendererService {
const x2 = mm(el.x2); const x2 = mm(el.x2);
const y2 = mm(el.y2); const y2 = mm(el.y2);
const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1; const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1;
parts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="black" stroke-width="${strokeWidth}"/>`); parts.push(
`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="black" stroke-width="${strokeWidth}"/>`,
);
} }
} }
+3 -1
View File
@@ -7,7 +7,9 @@ async function bootstrap(): Promise<void> {
const port = process.env.PORT ?? 3100; const port = process.env.PORT ?? 3100;
app.enableCors({ 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', methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
credentials: true, credentials: true,
}); });
@@ -16,32 +16,48 @@ export class PaperlessProcessorService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly paperlessService: PaperlessService, private readonly paperlessService: PaperlessService,
private readonly postprocessingService: PostprocessingService, private readonly postprocessingService: PostprocessingService,
@InjectRepository(DocumentType) private readonly docTypeRepo: Repository<DocumentType>, @InjectRepository(DocumentType)
@InjectRepository(DocumentField) private readonly docFieldRepo: Repository<DocumentField>, private readonly docTypeRepo: Repository<DocumentType>,
@InjectRepository(DocumentField)
private readonly docFieldRepo: Repository<DocumentField>,
) {} ) {}
@Cron(process.env.PAPERLESS_PROCESSOR_CRON || '0 * * * * *') @Cron(process.env.PAPERLESS_PROCESSOR_CRON || '0 * * * * *')
async processDocuments() { async processDocuments() {
try { try {
const response = await this.paperlessService.getDocuments({ tags__id__all: 16, page_size: 9999 }); const response = await this.paperlessService.getDocuments({
const documents: any[] = Array.isArray(response) ? response : (response?.results ?? []); tags__id__all: 16,
page_size: 9999,
});
const documents: any[] = Array.isArray(response)
? response
: (response?.results ?? []);
if (documents.length === 0) return; if (documents.length === 0) return;
const customFields = await this.paperlessService.getCustomFields(); const customFields = await this.paperlessService.getCustomFields();
const validFieldIds = new Set(customFields.map((f: any) => f.id)); 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) { for (const doc of documents) {
try { try {
const updatedDoc = await this.processSingleDocument(doc, validFieldIds); const updatedDoc = await this.processSingleDocument(
// Postprocessing nach dem Speichern evaluieren doc,
await this.postprocessingService.evaluate(updatedDoc || doc); validFieldIds,
);
// Postprocessing nach dem Speichern evaluieren
await this.postprocessingService.evaluate(updatedDoc || doc);
} catch (innerErr: any) { } catch (innerErr: any) {
this.logger.error(`Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`); this.logger.error(
if (innerErr.response?.data) { `Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`,
this.logger.error(`Paperless API Response: ${JSON.stringify(innerErr.response.data)}`); );
} if (innerErr.response?.data) {
this.logger.error(
`Paperless API Response: ${JSON.stringify(innerErr.response.data)}`,
);
}
} }
} }
} catch (err) { } catch (err) {
@@ -49,17 +65,24 @@ export class PaperlessProcessorService {
} }
} }
private async processSingleDocument(doc: any, validFieldIds: Set<number>): Promise<any> { private async processSingleDocument(
doc: any,
validFieldIds: Set<number>,
): Promise<any> {
this.logger.log(`Verarbeite Dokument ID: ${doc.id}`); this.logger.log(`Verarbeite Dokument ID: ${doc.id}`);
if (!doc.document_type) { 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<number>(doc.tags || []); const tagsSet = new Set<number>(doc.tags || []);
tagsSet.add(17); tagsSet.add(17);
if (!tagsSet.has(1)) { if (!tagsSet.has(1)) {
tagsSet.add(6); 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; return updated;
} }
@@ -68,7 +91,9 @@ export class PaperlessProcessorService {
}); });
if (!docTypeConfig) { 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; return null;
} }
@@ -77,9 +102,13 @@ export class PaperlessProcessorService {
}); });
if (fieldsConfig.length === 0) { 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 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; return updated;
} }
@@ -91,15 +120,19 @@ export class PaperlessProcessorService {
if (fieldConf.Type === 4) { if (fieldConf.Type === 4) {
const customFieldId = fieldConf.TypeIndex; const customFieldId = fieldConf.TypeIndex;
if (!customFieldId) continue; if (!customFieldId) continue;
if (!validFieldIds.has(customFieldId)) { 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; continue;
} }
const existingField = newCustomFields.find(f => f.field === customFieldId); const existingField = newCustomFields.find(
(f) => f.field === customFieldId,
);
let isFilled = false; let isFilled = false;
if (existingField) { if (existingField) {
isFilled = existingField.value !== null && existingField.value !== ''; isFilled = existingField.value !== null && existingField.value !== '';
} else { } else {
@@ -114,13 +147,16 @@ export class PaperlessProcessorService {
let isFilled = false; let isFilled = false;
switch (fieldConf.Type) { switch (fieldConf.Type) {
case 1: case 1:
isFilled = doc.correspondent !== null && doc.correspondent !== undefined; isFilled =
doc.correspondent !== null && doc.correspondent !== undefined;
break; break;
case 2: case 2:
isFilled = !!doc.created || !!doc.created_date; isFilled = !!doc.created || !!doc.created_date;
break; break;
case 3: 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; break;
case 5: case 5:
isFilled = !!doc.title; isFilled = !!doc.title;
@@ -136,13 +172,13 @@ export class PaperlessProcessorService {
} }
const tagsSet = new Set<number>(doc.tags || []); const tagsSet = new Set<number>(doc.tags || []);
if (isAllRequiredFilled) { if (isAllRequiredFilled) {
if (docTypeConfig.TagReady) tagsSet.add(docTypeConfig.TagReady); if (docTypeConfig.TagReady) tagsSet.add(docTypeConfig.TagReady);
if (docTypeConfig.TagNotReady) tagsSet.delete(docTypeConfig.TagNotReady); if (docTypeConfig.TagNotReady) tagsSet.delete(docTypeConfig.TagNotReady);
} else { } else {
if (docTypeConfig.TagNotReady) tagsSet.add(docTypeConfig.TagNotReady); if (docTypeConfig.TagNotReady) tagsSet.add(docTypeConfig.TagNotReady);
if (docTypeConfig.TagReady) tagsSet.delete(docTypeConfig.TagReady); if (docTypeConfig.TagReady) tagsSet.delete(docTypeConfig.TagReady);
} }
tagsSet.add(17); tagsSet.add(17);
@@ -163,7 +199,7 @@ export class PaperlessProcessorService {
while ((match = placeholderRegex.exec(title)) !== null) { while ((match = placeholderRegex.exec(title)) !== null) {
const fieldId = parseInt(match[1], 10); 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 === '') { if (!cf || cf.value == null || cf.value === '') {
allFilled = false; allFilled = false;
break; break;
@@ -174,7 +210,10 @@ export class PaperlessProcessorService {
for (const cf of newCustomFields) { for (const cf of newCustomFields) {
const placeholder = `{{CUSTOM[${cf.field}]}}`; const placeholder = `{{CUSTOM[${cf.field}]}}`;
if (title.includes(placeholder)) { 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; updatePayload.title = title;
} else { } 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; return updated;
} }
} }
@@ -26,10 +26,7 @@ export class PaperlessTaskProcessorService {
try { try {
// Fetch tasks that are not finished // Fetch tasks that are not finished
const tasks = await this.taskRepo.find({ const tasks = await this.taskRepo.find({
where: [ where: [{ Fertig: IsNull() }, { Fertig: 0 }],
{ Fertig: IsNull() },
{ Fertig: 0 },
],
take: 10, take: 10,
}); });
@@ -61,13 +58,17 @@ export class PaperlessTaskProcessorService {
// Fetch task status from Paperless // Fetch task status from Paperless
const paperlessTasks = await this.paperlessService.getTask(t.TaskId); 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) {
if (apiResponseTask.status === 'SUCCESS') { 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(); const now = new Date();
// Add 10 seconds buffer as in C# // Add 10 seconds buffer as in C#
if (dateDone.getTime() + 10000 < now.getTime()) { if (dateDone.getTime() + 10000 < now.getTime()) {
await this.processSuccessfulTask(t, apiResponseTask, parentTask); await this.processSuccessfulTask(t, apiResponseTask, parentTask);
@@ -87,38 +88,61 @@ export class PaperlessTaskProcessorService {
this.logger.log(`${toDelete.length} Tasks gelöscht`); this.logger.log(`${toDelete.length} Tasks gelöscht`);
} }
} catch (error) { } 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; 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) { if (!documentId) {
this.logger.error(`Kein Dokument für Task ${t.TaskId} gefunden.`); this.logger.error(`Kein Dokument für Task ${t.TaskId} gefunden.`);
return; return;
} }
try { 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); const document = await this.paperlessService.getDocument(documentId);
if (!document) { 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; 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 // Handle Duplicate Link
if (t.DuplikatZU) { if (t.DuplikatZU) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Verknüpfe Duplikat zu Dokument ${t.DuplikatZU}`); this.logger.log(
const duplikatDoc = await this.paperlessService.getDocument(t.DuplikatZU); `[Postprocessing] Task ${t.TaskId} - Verknüpfe Duplikat zu Dokument ${t.DuplikatZU}`,
);
const duplikatDoc = await this.paperlessService.getDocument(
t.DuplikatZU,
);
if (duplikatDoc) { if (duplikatDoc) {
// Update duplikatDoc metadata (Field 8 is for linked documents) // 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# // 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); const field8 = duplikatCustomFields.find((f: any) => f.field === 8);
if (field8) { if (field8) {
@@ -136,13 +160,21 @@ export class PaperlessTaskProcessorService {
document_type: 11, document_type: 11,
custom_fields: duplikatCustomFields, 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 // Update current document as well
const currentCustomFields = Array.isArray(document.custom_fields) ? [...document.custom_fields] : []; const currentCustomFields = Array.isArray(document.custom_fields)
const currentField8 = currentCustomFields.find((f: any) => f.field === 8); ? [...document.custom_fields]
: [];
const currentField8 = currentCustomFields.find(
(f: any) => f.field === 8,
);
if (currentField8) { if (currentField8) {
const values = Array.isArray(currentField8.value) ? currentField8.value : []; const values = Array.isArray(currentField8.value)
? currentField8.value
: [];
if (!values.includes(duplikatDoc.id)) { if (!values.includes(duplikatDoc.id)) {
values.push(duplikatDoc.id); values.push(duplikatDoc.id);
currentField8.value = values; currentField8.value = values;
@@ -152,70 +184,108 @@ export class PaperlessTaskProcessorService {
} }
document.custom_fields = currentCustomFields; document.custom_fields = currentCustomFields;
} else { } 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 // 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 = { 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 // CustomFieldsJson als Basis zuerst anwenden dedizierte Felder weiter unten überschreiben diese
if (t.CustomFieldsJson) { if (t.CustomFieldsJson) {
try { try {
const extra = JSON.parse(t.CustomFieldsJson) as Record<string, string>; const extra = JSON.parse(t.CustomFieldsJson) as Record<
string,
string
>;
for (const [k, v] of Object.entries(extra)) { for (const [k, v] of Object.entries(extra)) {
const fieldId = parseInt(k, 10); const fieldId = parseInt(k, 10);
if (!Number.isFinite(fieldId)) continue; 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; if (idx !== -1) updateData.custom_fields[idx].value = v;
else updateData.custom_fields.push({ field: fieldId, value: v }); else updateData.custom_fields.push({ field: fieldId, value: v });
} }
} catch { /* JSON-Parse-Fehler ignorieren */ } } catch {
/* JSON-Parse-Fehler ignorieren */
}
} }
if (t.Asn) { if (t.Asn) {
const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10); const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10);
if (!isNaN(asnNum)) { 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; updateData.archive_serial_number = asnNum;
} }
} }
if (t.InterneBelegnummer) { 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) { if (!t.Asn) {
const asnFromBelegnummer = parseInt(t.InterneBelegnummer.replace(/-/g, ''), 10); const asnFromBelegnummer = parseInt(
t.InterneBelegnummer.replace(/-/g, ''),
10,
);
if (!isNaN(asnFromBelegnummer)) { if (!isNaN(asnFromBelegnummer)) {
updateData.archive_serial_number = asnFromBelegnummer; updateData.archive_serial_number = asnFromBelegnummer;
} else { } 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) { if (existingField7) {
existingField7.value = t.InterneBelegnummer; existingField7.value = t.InterneBelegnummer;
} else { } else {
updateData.custom_fields.push({ field: 7, value: t.InterneBelegnummer }); updateData.custom_fields.push({
field: 7,
value: t.InterneBelegnummer,
});
} }
} }
if (t.externeBelegnummer) { if (t.externeBelegnummer) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze externeBelegnummer: ${t.externeBelegnummer}`); this.logger.log(
const existingField3 = updateData.custom_fields.find((f: any) => f.field === 3); `[Postprocessing] Task ${t.TaskId} - Setze externeBelegnummer: ${t.externeBelegnummer}`,
);
const existingField3 = updateData.custom_fields.find(
(f: any) => f.field === 3,
);
if (existingField3) { if (existingField3) {
existingField3.value = t.externeBelegnummer; existingField3.value = t.externeBelegnummer;
} else { } else {
updateData.custom_fields.push({ field: 3, value: t.externeBelegnummer }); updateData.custom_fields.push({
field: 3,
value: t.externeBelegnummer,
});
} }
} }
if (t.Eingangsdatum) { if (t.Eingangsdatum) {
const dateValue = new Date(t.Eingangsdatum).toISOString().split('T')[0]; const dateValue = new Date(t.Eingangsdatum).toISOString().split('T')[0];
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Eingangsdatum: ${dateValue}`); this.logger.log(
const existingField9 = updateData.custom_fields.find((f: any) => f.field === 9); `[Postprocessing] Task ${t.TaskId} - Setze Eingangsdatum: ${dateValue}`,
);
const existingField9 = updateData.custom_fields.find(
(f: any) => f.field === 9,
);
if (existingField9) { if (existingField9) {
existingField9.value = dateValue; existingField9.value = dateValue;
} else { } else {
@@ -224,24 +294,38 @@ export class PaperlessTaskProcessorService {
} }
if (t.DocumentType) { 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; updateData.document_type = t.DocumentType;
} }
// Parent Task / Attachment logic // Parent Task / Attachment logic
if (parentTask) { if (parentTask) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Verarbeite als Anlage zu ParentTask ${parentTask.TaskId}`); this.logger.log(
const parentPaperlessTasks = await this.paperlessService.getTask(parentTask.TaskId); `[Postprocessing] Task ${t.TaskId} - Verarbeite als Anlage zu ParentTask ${parentTask.TaskId}`,
const apiParentTask = Array.isArray(parentPaperlessTasks) ? parentPaperlessTasks[0] : null; );
const parentPaperlessTasks = await this.paperlessService.getTask(
parentTask.TaskId,
);
const apiParentTask = Array.isArray(parentPaperlessTasks)
? parentPaperlessTasks[0]
: null;
if (apiParentTask && apiParentTask.related_document) { 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) { 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.document_type = 5; // Anlage
updateData.title = `Anlage zu ${parentTask.InterneBelegnummer}`; 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) { if (field8) {
const values = Array.isArray(field8.value) ? field8.value : []; const values = Array.isArray(field8.value) ? field8.value : [];
if (!values.includes(parentDoc.id)) { if (!values.includes(parentDoc.id)) {
@@ -249,33 +333,50 @@ export class PaperlessTaskProcessorService {
field8.value = values; field8.value = values;
} }
} else { } else {
updateData.custom_fields.push({ field: 8, value: [parentDoc.id] }); updateData.custom_fields.push({
field: 8,
value: [parentDoc.id],
});
} }
} else { } else {
this.logger.warn(`[Postprocessing] Task ${t.TaskId} - Elterndokument nicht gefunden`); this.logger.warn(
`[Postprocessing] Task ${t.TaskId} - Elterndokument nicht gefunden`,
);
} }
} else { } 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) { 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(); updateData.created = t.Belegdatum.toISOString();
} }
if (t.BetriebID) { 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; updateData.owner = t.BetriebID;
} else { } 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; updateData.owner = null;
} }
// Tags // Tags
if (t.Tags) { if (t.Tags) {
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Setze Tags: ${t.Tags}`); this.logger.log(
const tagIds = t.Tags.split(',').map(id => parseInt(id.trim(), 10)).filter(id => !isNaN(id)); `[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 currentTags = document.tags || [];
const newTags = Array.from(new Set([...currentTags, ...tagIds])); const newTags = Array.from(new Set([...currentTags, ...tagIds]));
updateData.tags = newTags; updateData.tags = newTags;
@@ -284,46 +385,78 @@ export class PaperlessTaskProcessorService {
// Agrarmonitor Link (Skip API call for now, but save the link if needed) // Agrarmonitor Link (Skip API call for now, but save the link if needed)
if (t.EinkaufID) { if (t.EinkaufID) {
const link = `https://admin7.agrarmonitor.de/rechnungen/detail/${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) { 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 // 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); 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 // Add Notes
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Füge Notizen hinzu`); 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) { 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) { if (attachment) {
const rangePart = t.SourceAttachmentRange && t.SourceAttachmentRange !== 'full' const rangePart =
? ` | Seiten: ${t.SourceAttachmentRange}` 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}`); const messageId =
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Herkunfts-Notiz hinzugefügt`); 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) { if (t.BarcodeJson) {
await this.paperlessService.addNote(document.id, 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 // Sync local Documents table
this.logger.log(`[Postprocessing] Task ${t.TaskId} - Synchronisiere lokale Documents-Tabelle`); this.logger.log(
const metadata = await this.paperlessService.getDocumentMetadata(document.id); `[Postprocessing] Task ${t.TaskId} - Synchronisiere lokale Documents-Tabelle`,
let localDoc = await this.documentRepo.findOne({ where: { documentId: document.id } }); );
const metadata = await this.paperlessService.getDocumentMetadata(
document.id,
);
let localDoc = await this.documentRepo.findOne({
where: { documentId: document.id },
});
if (!localDoc) { 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({ localDoc = this.documentRepo.create({
documentId: document.id, documentId: document.id,
checksum: metadata.original_checksum, checksum: metadata.original_checksum,
@@ -331,34 +464,46 @@ export class PaperlessTaskProcessorService {
}); });
await this.documentRepo.save(localDoc); await this.documentRepo.save(localDoc);
} else { } 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.checksum = metadata.original_checksum;
localDoc.filename = metadata.original_filename; localDoc.filename = metadata.original_filename;
await this.documentRepo.save(localDoc); await this.documentRepo.save(localDoc);
} }
// Update Task status // 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.Fertig = 1;
t.PaperlessDocumentID = document.id; t.PaperlessDocumentID = document.id;
await this.taskRepo.save(t); await this.taskRepo.save(t);
// Update source attachment if linked // Update source attachment if linked
if (t.SourceAttachmentID && t.SourceAttachmentRange) { 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) { if (attachment) {
const ids = attachment.PaperlessDocumentIds || {}; const ids = attachment.PaperlessDocumentIds || {};
ids[t.SourceAttachmentRange] = document.id; ids[t.SourceAttachmentRange] = document.id;
attachment.PaperlessDocumentIds = ids; attachment.PaperlessDocumentIds = ids;
await this.attachmentRepo.save(attachment); 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) { } 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,
);
} }
} }
} }
@@ -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 type { Response } from 'express';
import { RequirePermissions } from '../auth/permissions.decorator'; import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
@@ -13,7 +29,10 @@ import { Task } from '../database/entities/task.entity';
import { Document } from '../database/entities/document.entity'; import { Document } from '../database/entities/document.entity';
import { DocumentField } from '../database/entities/document-field.entity'; import { DocumentField } from '../database/entities/document-field.entity';
import { DocumentType } from '../database/entities/document-type.entity'; import { DocumentType } from '../database/entities/document-type.entity';
import { Setting } from '../database/entities/setting.entity';
/** Setting.Tag-Schlüssel für die manuell gepflegte Steuertag-Liste */
export const STEUERTAG_SETTING_KEY = 'steuertag_ids';
@Controller('api/paperless') @Controller('api/paperless')
export class PaperlessController { export class PaperlessController {
@@ -29,14 +48,32 @@ export class PaperlessController {
private readonly documentFieldRepo: Repository<DocumentField>, private readonly documentFieldRepo: Repository<DocumentField>,
@InjectRepository(DocumentType) @InjectRepository(DocumentType)
private readonly documentTypeRepo: Repository<DocumentType>, private readonly documentTypeRepo: Repository<DocumentType>,
@InjectRepository(Setting)
private readonly settingRepo: Repository<Setting>,
) {} ) {}
/** Liest die als Steuertags markierten Tag-IDs aus den Einstellungen. */
private async getSteuertagIds(): Promise<number[]> {
const setting = await this.settingRepo.findOneBy({
Tag: STEUERTAG_SETTING_KEY,
});
return (setting?.Wert ?? '')
.split(',')
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
}
@Get('steuertags')
async getSteuertags() {
return { ids: await this.getSteuertagIds() };
}
@Post('checksum') @Post('checksum')
async checksumExists(@Body('checksum') checksum: string) { async checksumExists(@Body('checksum') checksum: string) {
const exists = await this.paperlessService.checksumExists(checksum); const exists = await this.paperlessService.checksumExists(checksum);
return { exists }; return { exists };
} }
@Get('documents') @Get('documents')
async getDocuments( async getDocuments(
@Query('search') search?: string, @Query('search') search?: string,
@@ -67,7 +104,7 @@ export class PaperlessController {
async getTag(@Param('id') id: string) { async getTag(@Param('id') id: string) {
// If the service doesn't have getTag(id), I should add it or just fetch all and find // If the service doesn't have getTag(id), I should add it or just fetch all and find
const tags = await this.paperlessService.getTags(); 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') @Get('document-types')
@@ -88,7 +125,7 @@ export class PaperlessController {
@Get('correspondents') @Get('correspondents')
async getCorrespondents(@Query('search') search?: string) { async getCorrespondents(@Query('search') search?: string) {
const params: any = { page_size: 100 }; const params: any = { page_size: 9999 };
if (search) { if (search) {
params.name__icontains = search; params.name__icontains = search;
} }
@@ -107,7 +144,11 @@ export class PaperlessController {
const documents = await this.paperlessService.getInboxDocuments(); const documents = await this.paperlessService.getInboxDocuments();
// In old C# logic: only return docs where archive_serial_number is not null // In old C# logic: only return docs where archive_serial_number is not null
return documents 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) => ({ .map((doc: any) => ({
id: doc.id, id: doc.id,
title: doc.title, title: doc.title,
@@ -127,7 +168,11 @@ export class PaperlessController {
async getManuellList() { async getManuellList() {
const documents = await this.paperlessService.getManuellDocuments(); const documents = await this.paperlessService.getManuellDocuments();
return documents 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) => ({ .map((doc: any) => ({
id: doc.id, id: doc.id,
title: doc.title, title: doc.title,
@@ -146,16 +191,24 @@ export class PaperlessController {
@Get('inbox/preview/:id') @Get('inbox/preview/:id')
async getInboxPreview(@Param('id') id: string, @Res() res: Response) { async getInboxPreview(@Param('id') id: string, @Res() res: Response) {
try { try {
const stream = await this.paperlessService.getDocumentPreviewStream(parseInt(id, 10)); const stream = await this.paperlessService.getDocumentPreviewStream(
parseInt(id, 10),
);
res.set({ res.set({
'Content-Type': 'image/png', 'Content-Type': 'image/png',
'Content-Disposition': `inline; filename="${id}preview.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); stream.pipe(res);
} catch (error) { } catch (error) {
if (!res.headersSent) { 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 +217,18 @@ export class PaperlessController {
@Get('inbox/pdf/:id') @Get('inbox/pdf/:id')
async getInboxPdf(@Param('id') id: string, @Res() res: Response) { async getInboxPdf(@Param('id') id: string, @Res() res: Response) {
try { try {
const stream = await this.paperlessService.getDocumentPdfStream(parseInt(id, 10)); const stream = await this.paperlessService.getDocumentPdfStream(
parseInt(id, 10),
);
res.set({ res.set({
'Content-Type': 'application/pdf', 'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${id}.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); stream.pipe(res);
} catch (error) { } catch (error) {
if (!res.headersSent) { if (!res.headersSent) {
@@ -179,25 +238,28 @@ export class PaperlessController {
} }
@Get('requirements/:id') @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 documentTypeId = parseInt(id, 10);
const isPosteingang = posteingang === '1'; const isPosteingang = posteingang === '1';
const requirements = await this.documentFieldRepo.find({ const requirements = await this.documentFieldRepo.find({
where: { DocumentType: documentTypeId }, where: { DocumentType: documentTypeId },
}); });
// Custom fields fetching inside here could be slow, but this is the simplest translation of the old API // 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. // Actually, getting all CFs doesn't take too long in Paperless API.
const customFields = await this.paperlessService.getCustomFields(); const customFields = await this.paperlessService.getCustomFields();
const retVal: any[] = []; const retVal: any[] = [];
for (const req of requirements) { for (const req of requirements) {
if (isPosteingang && !req.VisiblePosteingang) { if (isPosteingang && !req.VisiblePosteingang) {
continue; continue;
} }
const tmp: any = { const tmp: any = {
id: req.Id, id: req.Id,
feldId: req.Type + (req.TypeIndex !== null ? '-' + req.TypeIndex : ''), feldId: req.Type + (req.TypeIndex !== null ? '-' + req.TypeIndex : ''),
@@ -214,7 +276,7 @@ export class PaperlessController {
if (cf) { if (cf) {
tmp.feldName = cf.name; tmp.feldName = cf.name;
tmp.feldTyp = cf.id === 12 ? 'int' : cf.data_type; tmp.feldTyp = cf.id === 12 ? 'int' : cf.data_type;
if (cf.extra_data && cf.extra_data.select_options) { if (cf.extra_data && cf.extra_data.select_options) {
tmp.fieldOptions = cf.extra_data.select_options tmp.fieldOptions = cf.extra_data.select_options
.filter((o: any) => o !== null) .filter((o: any) => o !== null)
@@ -227,9 +289,14 @@ export class PaperlessController {
} else if (req.Type === 1) { } else if (req.Type === 1) {
tmp.feldName = 'Absender'; tmp.feldName = 'Absender';
tmp.feldTyp = 'select'; 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; 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) { } else if (req.Type === 2) {
tmp.feldName = 'Belegdatum'; tmp.feldName = 'Belegdatum';
tmp.feldTyp = 'date'; tmp.feldTyp = 'date';
@@ -246,7 +313,7 @@ export class PaperlessController {
retVal.push(tmp); retVal.push(tmp);
} }
return retVal; return retVal;
} }
@@ -265,34 +332,49 @@ export class PaperlessController {
if (body.date) { if (body.date) {
let docDate = new Date(body.date); let docDate = new Date(body.date);
if (docDate.getHours() > 22) { 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]; oldDocument.created_date = docDate.toISOString().split('T')[0];
} }
const cfDefinitions = await this.paperlessService.getCustomFields(); const cfDefinitions = await this.paperlessService.getCustomFields();
// update custom fields // update custom fields
if (body.customFields) { if (body.customFields) {
for (const [key, value] of Object.entries(body.customFields)) { for (const [key, value] of Object.entries(body.customFields)) {
const fieldId = parseInt(key, 10); const fieldId = parseInt(key, 10);
const cfDef = cfDefinitions.find((c: any) => c.id === fieldId); const cfDef = cfDefinitions.find((c: any) => c.id === fieldId);
let processedValue = value; 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]; 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 (existingFieldIndex !== -1) {
if (processedValue === null || processedValue === '') { if (processedValue === null || processedValue === '') {
oldDocument.custom_fields.splice(existingFieldIndex, 1); oldDocument.custom_fields.splice(existingFieldIndex, 1);
} else { } else {
oldDocument.custom_fields[existingFieldIndex].value = processedValue; oldDocument.custom_fields[existingFieldIndex].value =
processedValue;
} }
} else if (processedValue !== null && processedValue !== '') { } else if (processedValue !== null && processedValue !== '') {
oldDocument.custom_fields.push({ field: fieldId, value: processedValue }); oldDocument.custom_fields.push({
field: fieldId,
value: processedValue,
});
} }
} }
} }
@@ -301,7 +383,7 @@ export class PaperlessController {
const reqs = await this.documentFieldRepo.find({ const reqs = await this.documentFieldRepo.find({
where: { DocumentType: oldDocument.document_type }, where: { DocumentType: oldDocument.document_type },
}); });
let isReady = true; let isReady = true;
let isReadyPosteingang = true; let isReadyPosteingang = true;
@@ -309,12 +391,19 @@ export class PaperlessController {
let isFieldValid = false; let isFieldValid = false;
if (req.Type === 1) isFieldValid = oldDocument.correspondent !== null; if (req.Type === 1) isFieldValid = oldDocument.correspondent !== null;
if (req.Type === 2) isFieldValid = oldDocument.created_date !== null; if (req.Type === 2) isFieldValid = oldDocument.created_date !== null;
if (req.Type === 3) isFieldValid = oldDocument.archive_serial_number !== null; if (req.Type === 3)
if (req.Type === 4) isFieldValid = !!oldDocument.custom_fields.find((cf: any) => cf.field === req.TypeIndex && cf.value !== null && cf.value !== ''); isFieldValid = oldDocument.archive_serial_number !== null;
if (req.Type === 5) isFieldValid = oldDocument.title !== null && oldDocument.title !== ''; 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.IsRequired && !isFieldValid) isReady = false;
if (req.IsRequiredPosteingang && !isFieldValid) isReadyPosteingang = false; if (req.IsRequiredPosteingang && !isFieldValid)
isReadyPosteingang = false;
} }
const docType = await this.documentTypeRepo.findOne({ const docType = await this.documentTypeRepo.findOne({
@@ -323,9 +412,25 @@ export class PaperlessController {
oldDocument.tags = oldDocument.tags || []; oldDocument.tags = oldDocument.tags || [];
// Im Modal bearbeitbare Inhaltstags übernehmen: Steuertags bleiben
// unangetastet, die übrigen (Inhalts-)Tags werden durch die Auswahl ersetzt.
if (Array.isArray(body.tags)) {
const steuertagIds = new Set(await this.getSteuertagIds());
const preserved = oldDocument.tags.filter((t: number) =>
steuertagIds.has(t),
);
const selected = body.tags
.map((t: any) => Number(t))
.filter((t: number) => !isNaN(t) && !steuertagIds.has(t));
oldDocument.tags = Array.from(new Set([...preserved, ...selected]));
}
if (isReady) { if (isReady) {
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1); 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)) { if (docType?.TagReady && !oldDocument.tags.includes(docType.TagReady)) {
oldDocument.tags.push(docType.TagReady); oldDocument.tags.push(docType.TagReady);
} }
@@ -335,17 +440,24 @@ export class PaperlessController {
for (const cf of oldDocument.custom_fields) { for (const cf of oldDocument.custom_fields) {
const placeholder = `{{CUSTOM[${cf.field}]}}`; const placeholder = `{{CUSTOM[${cf.field}]}}`;
if (titleTemplate.includes(placeholder)) { 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; oldDocument.title = titleTemplate;
} }
} else { } else {
if (docType?.TagNotReady) { if (docType?.TagNotReady) {
if (isReadyPosteingang) { if (isReadyPosteingang) {
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1); 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 { } else {
if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1); if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1);
} }
@@ -353,7 +465,9 @@ export class PaperlessController {
if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1); if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1);
} }
if (docType?.TagReady) { 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 +505,21 @@ export class PaperlessController {
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Body() dto: UploadExternalDto, @Body() dto: UploadExternalDto,
) { ) {
this.logger.log(`Externer Upload gestartet: ${dto.interneBelegnummer} (${file?.originalname})`); this.logger.log(
`Externer Upload gestartet: ${dto.interneBelegnummer} (${file?.originalname})`,
);
try { try {
// 0. Check if ASN already exists // 0. Check if ASN already exists
await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer); await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer);
// 1. Forward to Paperless // 1. Forward to Paperless
const paperlessTaskId = await this.paperlessService.uploadDocument(file.path, { const paperlessTaskId = await this.paperlessService.uploadDocument(
title: `Beleg ${dto.interneBelegnummer}`, file.path,
}); {
title: `Beleg ${dto.interneBelegnummer}`,
},
);
// 2. Create local Task // 2. Create local Task
const task = this.taskRepo.create({ const task = this.taskRepo.create({
@@ -422,10 +541,15 @@ export class PaperlessController {
await this.taskRepo.save(task); 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; return task.TaskId;
} catch (err) { } 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( throw new HttpException(
`Fehler beim Verarbeiten des Dokumenten-Uploads: ${err.message}`, `Fehler beim Verarbeiten des Dokumenten-Uploads: ${err.message}`,
HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR,
@@ -9,17 +9,29 @@ import { DocumentField } from '../database/entities/document-field.entity';
import { Task } from '../database/entities/task.entity'; import { Task } from '../database/entities/task.entity';
import { Document } from '../database/entities/document.entity'; import { Document } from '../database/entities/document.entity';
import { Attachment } from '../database/entities/attachment.entity'; import { Attachment } from '../database/entities/attachment.entity';
import { Setting } from '../database/entities/setting.entity';
import { PostprocessingModule } from '../postprocessing/postprocessing.module'; import { PostprocessingModule } from '../postprocessing/postprocessing.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([DocumentType, DocumentField, Task, Document, Attachment]), TypeOrmModule.forFeature([
DocumentType,
DocumentField,
Task,
Document,
Attachment,
Setting,
]),
forwardRef(() => PostprocessingModule), forwardRef(() => PostprocessingModule),
AuthModule, AuthModule,
], ],
controllers: [PaperlessController], controllers: [PaperlessController],
providers: [PaperlessService, PaperlessProcessorService, PaperlessTaskProcessorService], providers: [
PaperlessService,
PaperlessProcessorService,
PaperlessTaskProcessorService,
],
exports: [PaperlessService], exports: [PaperlessService],
}) })
export class PaperlessModule {} export class PaperlessModule {}
@@ -11,7 +11,10 @@ export class PaperlessService {
private readonly client: AxiosInstance; private readonly client: AxiosInstance;
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
const baseURL = this.configService.get<string>('PAPERLESS_URL', 'http://localhost:8000'); const baseURL = this.configService.get<string>(
'PAPERLESS_URL',
'http://localhost:8000',
);
const token = this.configService.get<string>('PAPERLESS_TOKEN', ''); const token = this.configService.get<string>('PAPERLESS_TOKEN', '');
this.client = axios.create({ this.client = axios.create({
@@ -49,16 +52,22 @@ export class PaperlessService {
if (options?.title) form.append('title', options.title); if (options?.title) form.append('title', options.title);
if (options?.created) form.append('created', options.created); if (options?.created) form.append('created', options.created);
if (options?.documentType) form.append('document_type', String(options.documentType)); if (options?.documentType)
if (options?.correspondent) form.append('correspondent', String(options.correspondent)); form.append('document_type', String(options.documentType));
if (options?.storagePath) form.append('storage_path', String(options.storagePath)); 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) { if (options?.owner !== undefined && options.owner !== null) {
form.append('owner', String(options.owner)); form.append('owner', String(options.owner));
} }
if (options?.tags) { if (options?.tags) {
options.tags.forEach((tag) => form.append('tags', String(tag))); 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)); form.append('archive_serial_number', String(options.archiveSerialNumber));
} }
if (options?.customFields && Object.keys(options.customFields).length > 0) { if (options?.customFields && Object.keys(options.customFields).length > 0) {
@@ -92,27 +101,30 @@ export class PaperlessService {
async getInboxDocuments(): Promise<any[]> { async getInboxDocuments(): Promise<any[]> {
// API pagination to get large amount of inbox documents (assuming max 9999 like C# app) // API pagination to get large amount of inbox documents (assuming max 9999 like C# app)
const response = await this.client.get('/documents/', { const response = await this.client.get('/documents/', {
params: { params: {
page: 1, page: 1,
page_size: 9999, page_size: 9999,
ordering: '-added', ordering: '-added',
truncate_content: true, truncate_content: true,
tags__id__all: 1 tags__id__all: 1,
} },
}); });
return response.data.results; return response.data.results;
} }
async getManuellDocuments(): Promise<any[]> { async getManuellDocuments(): Promise<any[]> {
const errorTag = this.configService.get<number>('MANUELL_BEARBEITEN_TAG', 6); const errorTag = this.configService.get<number>(
'MANUELL_BEARBEITEN_TAG',
6,
);
const response = await this.client.get('/documents/', { const response = await this.client.get('/documents/', {
params: { params: {
page: 1, page: 1,
page_size: 9999, page_size: 9999,
ordering: '-added', ordering: '-added',
truncate_content: true, truncate_content: true,
tags__id__all: errorTag tags__id__all: errorTag,
} },
}); });
return response.data.results; return response.data.results;
} }
@@ -124,7 +136,9 @@ export class PaperlessService {
} catch (err: any) { } catch (err: any) {
const body = err?.response?.data; const body = err?.response?.data;
if (body) { if (body) {
this.logger.error(`Paperless updateDocument(${id}) Fehlerdetails: ${JSON.stringify(body)}`); this.logger.error(
`Paperless updateDocument(${id}) Fehlerdetails: ${JSON.stringify(body)}`,
);
} }
throw err; throw err;
} }
@@ -132,14 +146,14 @@ export class PaperlessService {
async getDocumentTypes(): Promise<any[]> { async getDocumentTypes(): Promise<any[]> {
const response = await this.client.get('/document_types/', { const response = await this.client.get('/document_types/', {
params: { page_size: 9999 } params: { page_size: 9999 },
}); });
return response.data.results; return response.data.results;
} }
async getTags(): Promise<any[]> { async getTags(): Promise<any[]> {
const response = await this.client.get('/tags/', { const response = await this.client.get('/tags/', {
params: { page_size: 9999 } params: { page_size: 9999 },
}); });
return response.data.results; return response.data.results;
} }
@@ -156,7 +170,7 @@ export class PaperlessService {
async getCustomFields(): Promise<any[]> { async getCustomFields(): Promise<any[]> {
const response = await this.client.get('/custom_fields/', { const response = await this.client.get('/custom_fields/', {
params: { page_size: 9999 } params: { page_size: 9999 },
}); });
return response.data.results; return response.data.results;
} }
@@ -184,10 +198,14 @@ export class PaperlessService {
await this.client.delete(`/correspondents/${id}/`); await this.client.delete(`/correspondents/${id}/`);
} }
async downloadDocument(id: number, type: 'original' | 'archive' = 'archive'): Promise<Buffer> { async downloadDocument(
const endpoint = type === 'original' id: number,
? `/documents/${id}/download/` type: 'original' | 'archive' = 'archive',
: `/documents/${id}/download/`; ): Promise<Buffer> {
const endpoint =
type === 'original'
? `/documents/${id}/download/`
: `/documents/${id}/download/`;
const response = await this.client.get(endpoint, { const response = await this.client.get(endpoint, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
params: type === 'original' ? { original: true } : {}, params: type === 'original' ? { original: true } : {},
@@ -204,10 +222,14 @@ export class PaperlessService {
return response.data; return response.data;
} }
async getDocumentPdfStream(id: number, type: 'original' | 'archive' = 'archive'): Promise<any> { async getDocumentPdfStream(
const endpoint = type === 'original' id: number,
? `/documents/${id}/download/` type: 'original' | 'archive' = 'archive',
: `/documents/${id}/download/`; ): Promise<any> {
const endpoint =
type === 'original'
? `/documents/${id}/download/`
: `/documents/${id}/download/`;
const response = await this.client.get(endpoint, { const response = await this.client.get(endpoint, {
responseType: 'stream', responseType: 'stream',
params: type === 'original' ? { original: true } : {}, params: type === 'original' ? { original: true } : {},
@@ -222,7 +244,9 @@ export class PaperlessService {
} }
async addNote(id: number, note: string): Promise<any> { async addNote(id: number, note: string): Promise<any> {
const response = await this.client.post(`/documents/${id}/notes/`, { note }); const response = await this.client.post(`/documents/${id}/notes/`, {
note,
});
return response.data; return response.data;
} }
@@ -248,7 +272,7 @@ export class PaperlessService {
*/ */
async validateAsnNotExists(interneBelegnummer: string): Promise<void> { async validateAsnNotExists(interneBelegnummer: string): Promise<void> {
if (!interneBelegnummer) return; if (!interneBelegnummer) return;
// Logic like in PaperlessTaskProcessorService // Logic like in PaperlessTaskProcessorService
const asnNum = parseInt(interneBelegnummer.replace(/-/g, ''), 10); const asnNum = parseInt(interneBelegnummer.replace(/-/g, ''), 10);
if (isNaN(asnNum)) return; if (isNaN(asnNum)) return;
@@ -271,7 +295,7 @@ export class PaperlessService {
params: { archive_serial_number: asn, page_size: 5 }, params: { archive_serial_number: asn, page_size: 5 },
}); });
if ((response.data.count ?? 0) === 0) return null; 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, (doc: any) => Number(doc.archive_serial_number) === asn,
); );
return match ? Number(match.id) : null; return match ? Number(match.id) : null;
@@ -281,7 +305,10 @@ export class PaperlessService {
* Liefert die Paperless-Doc-ID des passenden Dokuments oder null. * Liefert die Paperless-Doc-ID des passenden Dokuments oder null.
* Paperless kann den custom_fields-Filter ignorieren daher manuell verifizieren. * Paperless kann den custom_fields-Filter ignorieren daher manuell verifizieren.
*/ */
async findDocumentIdByCustomField(fieldId: number, value: string): Promise<number | null> { async findDocumentIdByCustomField(
fieldId: number,
value: string,
): Promise<number | null> {
const response = await this.client.get('/documents/', { const response = await this.client.get('/documents/', {
params: { params: {
[`custom_fields__${fieldId}__value__iexact`]: value, [`custom_fields__${fieldId}__value__iexact`]: value,
@@ -291,9 +318,14 @@ export class PaperlessService {
}); });
if ((response.data.count ?? 0) === 0) return null; if ((response.data.count ?? 0) === 0) return null;
const valueLower = value.toLowerCase(); const valueLower = value.toLowerCase();
const match = (response.data.results as any[] ?? []).find((doc: any) => const match = ((response.data.results as any[]) ?? []).find((doc: any) =>
(Array.isArray(doc.custom_fields) ? doc.custom_fields as any[] : []).some( (Array.isArray(doc.custom_fields)
(cf: any) => cf.field === fieldId && String(cf.value ?? '').toLowerCase() === valueLower, ? (doc.custom_fields as any[])
: []
).some(
(cf: any) =>
cf.field === fieldId &&
String(cf.value ?? '').toLowerCase() === valueLower,
), ),
); );
return match ? Number(match.id) : null; return match ? Number(match.id) : null;
@@ -10,10 +10,15 @@ export class ExportService {
private readonly logger = new Logger(ExportService.name); private readonly logger = new Logger(ExportService.name);
constructor( constructor(
@InjectRepository(ExportTarget) private readonly targetRepo: Repository<ExportTarget>, @InjectRepository(ExportTarget)
private readonly targetRepo: Repository<ExportTarget>,
) {} ) {}
async exportFile(targetId: number, filename: string, content: Buffer): Promise<void> { async exportFile(
targetId: number,
filename: string,
content: Buffer,
): Promise<void> {
const target = await this.targetRepo.findOneByOrFail({ Id: targetId }); const target = await this.targetRepo.findOneByOrFail({ Id: targetId });
if (!target.IsActive) { 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 }); const target = await this.targetRepo.findOneByOrFail({ Id: targetId });
try { try {
@@ -44,7 +51,10 @@ export class ExportService {
await this.testWebDav(target); await this.testWebDav(target);
break; break;
default: default:
return { success: false, message: `Unbekanntes Protokoll: ${target.Protocol}` }; return {
success: false,
message: `Unbekanntes Protokoll: ${target.Protocol}`,
};
} }
return { success: true, message: 'Verbindung erfolgreich.' }; return { success: true, message: 'Verbindung erfolgreich.' };
} catch (err: any) { } catch (err: any) {
@@ -52,7 +62,11 @@ export class ExportService {
} }
} }
private async uploadFtp(target: ExportTarget, filename: string, content: Buffer): Promise<void> { private async uploadFtp(
target: ExportTarget,
filename: string,
content: Buffer,
): Promise<void> {
const client = new ftp.Client(); const client = new ftp.Client();
try { try {
await client.access({ await client.access({
@@ -89,7 +103,11 @@ export class ExportService {
} }
} }
private async uploadWebDav(target: ExportTarget, filename: string, content: Buffer): Promise<void> { private async uploadWebDav(
target: ExportTarget,
filename: string,
content: Buffer,
): Promise<void> {
const client = this.createWebDavClient(target); const client = this.createWebDavClient(target);
const remotePath = `${target.RemotePath || '/'}/${filename}`; const remotePath = `${target.RemotePath || '/'}/${filename}`;
await client.putFileContents(remotePath, content); await client.putFileContents(remotePath, content);
@@ -25,12 +25,24 @@ export class MailService {
body: string; body: string;
html?: string; html?: string;
attachments?: { filename: string; content: Buffer }[]; 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<void> { }): Promise<void> {
let transporter = this.transporter; let transporter = this.transporter;
const globalFromEmail = this.configService.get<string>('SMTP_FROM', 'paperless@localhost'); const globalFromEmail = this.configService.get<string>(
'SMTP_FROM',
'paperless@localhost',
);
const globalFromName = this.configService.get<string>('SMTP_FROM_NAME', ''); const globalFromName = this.configService.get<string>('SMTP_FROM_NAME', '');
let from = globalFromName ? `"${globalFromName}" <${globalFromEmail}>` : globalFromEmail; let from = globalFromName
? `"${globalFromName}" <${globalFromEmail}>`
: globalFromEmail;
if (options.smtpOverride) { if (options.smtpOverride) {
const o = options.smtpOverride; const o = options.smtpOverride;
@@ -53,7 +65,7 @@ export class MailService {
subject: options.subject, subject: options.subject,
text: options.body, text: options.body,
html: options.html, html: options.html,
attachments: options.attachments?.map(a => ({ attachments: options.attachments?.map((a) => ({
filename: a.filename, filename: a.filename,
content: a.content, content: a.content,
})), })),
@@ -11,7 +11,12 @@ import { PaperlessModule } from '../paperless/paperless.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Postprocessing, PostprocessingAction, PostprocessingLog, ExportTarget]), TypeOrmModule.forFeature([
Postprocessing,
PostprocessingAction,
PostprocessingLog,
ExportTarget,
]),
forwardRef(() => PaperlessModule), forwardRef(() => PaperlessModule),
], ],
providers: [PostprocessingService, MailService, ExportService], providers: [PostprocessingService, MailService, ExportService],
@@ -6,12 +6,39 @@ import { PostprocessingAction } from '../database/entities/postprocessing-action
import { PaperlessService } from '../paperless/paperless.service'; import { PaperlessService } from '../paperless/paperless.service';
const mockRules: Partial<Postprocessing>[] = [ const mockRules: Partial<Postprocessing>[] = [
{ Id: 1, Name: 'Rule1', DocumentTypeId: 5, CorrespondentId: null, OwnerId: null, TagId: null, Order: 1, IsActive: true, NoFurther: false }, {
{ Id: 2, Name: 'StopRule', DocumentTypeId: null, CorrespondentId: null, OwnerId: null, TagId: null, Order: 2, IsActive: true, NoFurther: true }, Id: 1,
Name: 'Rule1',
DocumentTypeId: 5,
CorrespondentId: null,
OwnerId: null,
TagId: null,
Order: 1,
IsActive: true,
NoFurther: false,
},
{
Id: 2,
Name: 'StopRule',
DocumentTypeId: null,
CorrespondentId: null,
OwnerId: null,
TagId: null,
Order: 2,
IsActive: true,
NoFurther: true,
},
]; ];
const mockActions: Partial<PostprocessingAction>[] = [ const mockActions: Partial<PostprocessingAction>[] = [
{ 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', () => { describe('PostprocessingService', () => {
@@ -29,7 +56,10 @@ describe('PostprocessingService', () => {
providers: [ providers: [
PostprocessingService, PostprocessingService,
{ provide: getRepositoryToken(Postprocessing), useValue: ppRepo }, { provide: getRepositoryToken(Postprocessing), useValue: ppRepo },
{ provide: getRepositoryToken(PostprocessingAction), useValue: ppActionRepo }, {
provide: getRepositoryToken(PostprocessingAction),
useValue: ppActionRepo,
},
{ provide: PaperlessService, useValue: paperlessService }, { provide: PaperlessService, useValue: paperlessService },
], ],
}).compile(); }).compile();
@@ -58,7 +88,9 @@ describe('PostprocessingService', () => {
where: { PostprocessingId: 1, IsActive: true }, where: { PostprocessingId: 1, IsActive: true },
order: { Order: 'ASC' }, order: { Order: 'ASC' },
}); });
expect(paperlessService.updateDocument).toHaveBeenCalledWith(100, { tags: [99] }); expect(paperlessService.updateDocument).toHaveBeenCalledWith(100, {
tags: [99],
});
}); });
it('evaluate stops at NoFurther rule', async () => { it('evaluate stops at NoFurther rule', async () => {
@@ -72,7 +104,11 @@ describe('PostprocessingService', () => {
it('evaluate skips non-matching rules', async () => { it('evaluate skips non-matching rules', async () => {
// documentTypeId=999 doesn't match Rule1 (requires 5) but matches Rule2 (no filter) // documentTypeId=999 doesn't match Rule1 (requires 5) but matches Rule2 (no filter)
ppActionRepo.find.mockResolvedValue([]); 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 // Rule1 skipped, Rule2 matched → only 1 action lookup
expect(ppActionRepo.find).toHaveBeenCalledTimes(1); expect(ppActionRepo.find).toHaveBeenCalledTimes(1);
@@ -2,7 +2,11 @@ import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from '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 { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity'; import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
import { PaperlessService } from '../paperless/paperless.service'; import { PaperlessService } from '../paperless/paperless.service';
@@ -20,15 +24,22 @@ export class PostprocessingService {
private documentTypesCache: { data: any[]; expires: number } | null = null; private documentTypesCache: { data: any[]; expires: number } | null = null;
constructor( constructor(
@InjectRepository(Postprocessing) private readonly ppRepo: Repository<Postprocessing>, @InjectRepository(Postprocessing)
@InjectRepository(PostprocessingAction) private readonly actionRepo: Repository<PostprocessingAction>, private readonly ppRepo: Repository<Postprocessing>,
@InjectRepository(PostprocessingLog) private readonly logRepo: Repository<PostprocessingLog>, @InjectRepository(PostprocessingAction)
private readonly actionRepo: Repository<PostprocessingAction>,
@InjectRepository(PostprocessingLog)
private readonly logRepo: Repository<PostprocessingLog>,
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject(forwardRef(() => PaperlessService)) private readonly paperlessService: PaperlessService, @Inject(forwardRef(() => PaperlessService))
private readonly paperlessService: PaperlessService,
private readonly mailService: MailService, private readonly mailService: MailService,
private readonly exportService: ExportService, private readonly exportService: ExportService,
) { ) {
this.errorTagId = this.configService.get<number>('POSTPROCESSING_ERROR_TAG', 0); this.errorTagId = this.configService.get<number>(
'POSTPROCESSING_ERROR_TAG',
0,
);
} }
async evaluate(doc: any): Promise<void> { async evaluate(doc: any): Promise<void> {
@@ -40,19 +51,29 @@ export class PostprocessingService {
// Enrich doc with resolved names (once per evaluation) // Enrich doc with resolved names (once per evaluation)
await this.enrichDocWithNames(doc); 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) { for (const rule of rules) {
if (!this.hasConditions(rule.FilterJson)) { 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; 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); 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; 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({ const actions = await this.actionRepo.find({
where: { PostprocessingId: rule.Id, IsActive: true }, where: { PostprocessingId: rule.Id, IsActive: true },
@@ -63,10 +84,18 @@ export class PostprocessingService {
for (const action of actions) { for (const action of actions) {
try { try {
await this.executeAction(action, doc); 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) { } catch (err: any) {
hasError = true; 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); await this.log(rule.Id, action.Id, doc.id, 'error', err.message);
} }
} }
@@ -75,9 +104,13 @@ export class PostprocessingService {
try { try {
const currentTags = new Set<number>(doc.tags || []); const currentTags = new Set<number>(doc.tags || []);
currentTags.add(this.errorTagId); 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) { } 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 { private hasConditions(filter: FilterGroup): boolean {
if (!filter || !filter.rules || filter.rules.length === 0) return false; if (!filter || !filter.rules || filter.rules.length === 0) return false;
return filter.rules.some(rule => { return filter.rules.some((rule) => {
if ('combinator' in rule) return this.hasConditions(rule as FilterGroup); if ('combinator' in rule) return this.hasConditions(rule);
return true; return true;
}); });
} }
@@ -101,11 +134,11 @@ export class PostprocessingService {
private matchesFilter(filter: FilterGroup, doc: any): boolean { private matchesFilter(filter: FilterGroup, doc: any): boolean {
if (!filter || !filter.rules || filter.rules.length === 0) return false; 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) { 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' return filter.combinator === 'AND'
@@ -139,7 +172,9 @@ export class PostprocessingService {
if (cond.field === 'tag') { if (cond.field === 'tag') {
result = Array.isArray(actual) && actual.includes(Number(expected)); result = Array.isArray(actual) && actual.includes(Number(expected));
} else { } else {
result = String(actual ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase()); result = String(actual ?? '')
.toLowerCase()
.includes(String(expected ?? '').toLowerCase());
} }
break; break;
@@ -147,7 +182,9 @@ export class PostprocessingService {
if (cond.field === 'tag') { if (cond.field === 'tag') {
result = !Array.isArray(actual) || !actual.includes(Number(expected)); result = !Array.isArray(actual) || !actual.includes(Number(expected));
} else { } else {
result = !String(actual ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase()); result = !String(actual ?? '')
.toLowerCase()
.includes(String(expected ?? '').toLowerCase());
} }
break; break;
@@ -173,7 +210,7 @@ export class PostprocessingService {
} }
this.logger.debug( 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; return result;
} }
@@ -196,7 +233,9 @@ export class PostprocessingService {
// Custom field: "custom_field_<id>" // Custom field: "custom_field_<id>"
if (field.startsWith('custom_field_')) { if (field.startsWith('custom_field_')) {
const fieldId = parseInt(field.replace('custom_field_', ''), 10); 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 cf?.value ?? null;
} }
return null; return null;
@@ -205,7 +244,10 @@ export class PostprocessingService {
// ── Action Execution ───────────────────────────────────────────── // ── Action Execution ─────────────────────────────────────────────
private async executeAction(action: PostprocessingAction, doc: any): Promise<void> { private async executeAction(
action: PostprocessingAction,
doc: any,
): Promise<void> {
const content = action.Content; const content = action.Content;
switch (action.ActionType) { switch (action.ActionType) {
@@ -233,15 +275,26 @@ export class PostprocessingService {
} }
private async getCachedCorrespondents(): Promise<any[]> { private async getCachedCorrespondents(): Promise<any[]> {
if (!this.correspondentsCache || Date.now() > this.correspondentsCache.expires) { if (
const response = await this.paperlessService.getCorrespondents({ page_size: 9999 }); !this.correspondentsCache ||
this.correspondentsCache = { data: response.results, expires: Date.now() + CACHE_TTL_MS }; 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; return this.correspondentsCache.data;
} }
private async getCachedDocumentTypes(): Promise<any[]> { private async getCachedDocumentTypes(): Promise<any[]> {
if (!this.documentTypesCache || Date.now() > this.documentTypesCache.expires) { if (
!this.documentTypesCache ||
Date.now() > this.documentTypesCache.expires
) {
const data = await this.paperlessService.getDocumentTypes(); const data = await this.paperlessService.getDocumentTypes();
this.documentTypesCache = { data, expires: Date.now() + CACHE_TTL_MS }; this.documentTypesCache = { data, expires: Date.now() + CACHE_TTL_MS };
} }
@@ -272,13 +325,18 @@ export class PostprocessingService {
'{titel}': doc.title ?? '', '{titel}': doc.title ?? '',
'{korrespondent}': String(doc.correspondent ?? ''), '{korrespondent}': String(doc.correspondent ?? ''),
'{absender}': String(doc.correspondent ?? ''), '{absender}': String(doc.correspondent ?? ''),
'{korrespondent_name}': doc._correspondentName ?? String(doc.correspondent ?? ''), '{korrespondent_name}':
'{absender_name}': doc._correspondentName ?? String(doc.correspondent ?? ''), doc._correspondentName ?? String(doc.correspondent ?? ''),
'{absender_name}':
doc._correspondentName ?? String(doc.correspondent ?? ''),
'{dokumenttyp}': String(doc.document_type ?? ''), '{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 ?? ''), '{besitzer}': String(doc.owner ?? ''),
'{ablagenummer}': String(doc.archive_serial_number ?? ''), '{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()) : '', '{jahr}': created ? String(created.getFullYear()) : '',
'{monat}': created ? String(created.getMonth() + 1).padStart(2, '0') : '', '{monat}': created ? String(created.getMonth() + 1).padStart(2, '0') : '',
'{tag}': created ? String(created.getDate()).padStart(2, '0') : '', '{tag}': created ? String(created.getDate()).padStart(2, '0') : '',
@@ -289,7 +347,7 @@ export class PostprocessingService {
}; };
// Custom Fields: {custom_field_<id>} // Custom Fields: {custom_field_<id>}
for (const cf of (doc.custom_fields || [])) { for (const cf of doc.custom_fields || []) {
replacements[`{custom_field_${cf.field}}`] = String(cf.value ?? ''); replacements[`{custom_field_${cf.field}}`] = String(cf.value ?? '');
} }
@@ -310,16 +368,32 @@ export class PostprocessingService {
return `${doc.title || `document_${doc.id}`}.pdf`; return `${doc.title || `document_${doc.id}`}.pdf`;
} }
private async handleExport(content: Record<string, any>, doc: any): Promise<void> { private async handleExport(
content: Record<string, any>,
doc: any,
): Promise<void> {
const fileType = content.fileType || 'archive'; 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 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<string, any>, doc: any): Promise<void> { private async handleMail(
content: Record<string, any>,
doc: any,
): Promise<void> {
const fileType = content.fileType || 'archive'; 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 filename = this.buildFilename(content.filenameTemplate, doc);
const subject = content.subject const subject = content.subject
@@ -337,18 +411,26 @@ export class PostprocessingService {
}); });
} }
private async handleTags(content: Record<string, any>, doc: any): Promise<void> { private async handleTags(
content: Record<string, any>,
doc: any,
): Promise<void> {
const currentTags = new Set<number>(doc.tags || []); const currentTags = new Set<number>(doc.tags || []);
const addTags: number[] = content.addTags || []; const addTags: number[] = content.addTags || [];
const removeTags: number[] = content.removeTags || []; const removeTags: number[] = content.removeTags || [];
addTags.forEach(t => currentTags.add(t)); addTags.forEach((t) => currentTags.add(t));
removeTags.forEach(t => currentTags.delete(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<string, any>, doc: any): Promise<void> { private async handleCustomField(
content: Record<string, any>,
doc: any,
): Promise<void> {
const customFields = [...(doc.custom_fields || [])]; const customFields = [...(doc.custom_fields || [])];
const existing = customFields.find((f: any) => f.field === content.fieldId); 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 }); 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<string, any>, doc: any): Promise<void> { private async handleWebhook(
content: Record<string, any>,
doc: any,
): Promise<void> {
const method = (content.method || 'POST').toUpperCase(); const method = (content.method || 'POST').toUpperCase();
const headers = content.headers || {}; 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({ await axios({
method, method,
@@ -378,7 +470,10 @@ export class PostprocessingService {
this.logger.log(`Webhook ${method}${content.url}`); this.logger.log(`Webhook ${method}${content.url}`);
} }
private async handleNote(content: Record<string, any>, doc: any): Promise<void> { private async handleNote(
content: Record<string, any>,
doc: any,
): Promise<void> {
if (!content.note) return; if (!content.note) return;
const resolvedNote = this.resolveTemplate(content.note, doc); const resolvedNote = this.resolveTemplate(content.note, doc);
await this.paperlessService.addNote(doc.id, resolvedNote); await this.paperlessService.addNote(doc.id, resolvedNote);
@@ -386,7 +481,13 @@ export class PostprocessingService {
// ── Logging ────────────────────────────────────────────────────── // ── Logging ──────────────────────────────────────────────────────
private async log(ppId: number, actionId: number | null, docId: number, status: string, message: string): Promise<void> { private async log(
ppId: number,
actionId: number | null,
docId: number,
status: string,
message: string,
): Promise<void> {
const entry = this.logRepo.create({ const entry = this.logRepo.create({
PostprocessingId: ppId, PostprocessingId: ppId,
ActionId: actionId, ActionId: actionId,
@@ -51,18 +51,22 @@ export class DocumentPipelineService {
// 2. QR-Code auf erster Seite scannen // 2. QR-Code auf erster Seite scannen
const firstPageBuffer = await fs.readFile(images[0]); const firstPageBuffer = await fs.readFile(images[0]);
const qrResults = await this.qrCodeService.extractFromImage(firstPageBuffer); const qrResults =
await this.qrCodeService.extractFromImage(firstPageBuffer);
let barcodeData: Record<string, any> | null = null; let barcodeData: Record<string, any> | null = null;
if (qrResults.length > 0) { if (qrResults.length > 0) {
barcodeData = this.qrCodeService.parseBarcode(qrResults[0].data); barcodeData = this.qrCodeService.parseBarcode(qrResults[0].data);
if (barcodeData) { 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 // 3. OCR auf erster Seite
const ocrMarkdown = await this.ocrService.extractTextAsMarkdown(firstPageBuffer); const ocrMarkdown =
await this.ocrService.extractTextAsMarkdown(firstPageBuffer);
// 4. Task in DB erstellen // 4. Task in DB erstellen
const year = new Date().getFullYear(); const year = new Date().getFullYear();
@@ -9,7 +9,10 @@ export class OcrService {
private readonly ollamaModel: string; private readonly ollamaModel: string;
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
this.ollamaUrl = this.configService.get<string>('OLLAMA_URL', 'http://localhost:11434'); this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
'http://localhost:11434',
);
this.ollamaModel = this.configService.get<string>('OLLAMA_MODEL', 'llava'); this.ollamaModel = this.configService.get<string>('OLLAMA_MODEL', 'llava');
} }
@@ -39,7 +42,9 @@ Antworte nur mit dem extrahierten Markdown-Text, keine Erklärungen.`;
); );
const markdown = response.data.response?.trim() ?? ''; 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; return markdown;
} catch (error: any) { } catch (error: any) {
this.logger.error(`Ollama OCR fehlgeschlagen: ${error.message}`); this.logger.error(`Ollama OCR fehlgeschlagen: ${error.message}`);
@@ -60,18 +60,22 @@ export class PdfService {
const entries = await fs.readdir(tmpDir); const entries = await fs.readdir(tmpDir);
const images = entries const images = entries
.filter(f => f.endsWith('.png')) .filter((f) => f.endsWith('.png'))
.sort((a, b) => { .sort((a, b) => {
const numA = parseInt(a.match(/\d+/)?.[0] ?? '0', 10); const numA = parseInt(a.match(/\d+/)?.[0] ?? '0', 10);
const numB = parseInt(b.match(/\d+/)?.[0] ?? '0', 10); const numB = parseInt(b.match(/\d+/)?.[0] ?? '0', 10);
return numA - numB; return numA - numB;
}) })
.map(f => path.join(tmpDir, f)); .map((f) => path.join(tmpDir, f));
if (images.length === 0) { 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 { } 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; return images;
@@ -114,7 +118,9 @@ export class PdfService {
async cleanup(imagePaths: string[]): Promise<void> { async cleanup(imagePaths: string[]): Promise<void> {
const dirs = new Set<string>(); const dirs = new Set<string>();
for (const imgPath of imagePaths) { for (const imgPath of imagePaths) {
try { await fs.unlink(imgPath); } catch {} try {
await fs.unlink(imgPath);
} catch {}
dirs.add(path.dirname(imgPath)); dirs.add(path.dirname(imgPath));
} }
for (const dir of dirs) { for (const dir of dirs) {
@@ -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 { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@@ -31,7 +36,10 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
@InjectRepository(InboxDocument) @InjectRepository(InboxDocument)
private readonly documentRepo: Repository<InboxDocument>, private readonly documentRepo: Repository<InboxDocument>,
) { ) {
this.sourceRoot = this.configService.get<string>('SCANNER_WATCH_DIR', '/mnt/scans'); this.sourceRoot = this.configService.get<string>(
'SCANNER_WATCH_DIR',
'/mnt/scans',
);
} }
onModuleInit(): void { onModuleInit(): void {
@@ -67,7 +75,9 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
this.watcher this.watcher
.on('add', (filePath: string) => this.handleNewFile(filePath)) .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'); this.logger.log('Scanner-Watcher aktiv');
} }
@@ -82,11 +92,15 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
private async initialScan(silent = false): Promise<void> { private async initialScan(silent = false): Promise<void> {
let subdirs: string[]; let subdirs: string[];
try { 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); subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch (err: any) { } catch (err: any) {
if (!silent) { 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; return;
} }
@@ -99,7 +113,9 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
files = await fs.readdir(dir); files = await fs.readdir(dir);
} catch (err: any) { } catch (err: any) {
if (!silent) { if (!silent) {
this.logger.warn(`Scanner-Check: ${dir} nicht lesbar: ${err.message}`); this.logger.warn(
`Scanner-Check: ${dir} nicht lesbar: ${err.message}`,
);
} }
continue; continue;
} }
@@ -110,7 +126,9 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
if (!(await this.isStable(full))) { if (!(await this.isStable(full))) {
if (!silent) { 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; continue;
} }
@@ -204,10 +222,14 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
try { try {
await this.barcodeScanner.scanAndMatch(doc); await this.barcodeScanner.scanAndMatch(doc);
} catch (err: any) { } 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) { } catch (err: any) {
this.logger.error(`Übernahme fehlgeschlagen für ${filePath}: ${err.message}`); this.logger.error(
`Übernahme fehlgeschlagen für ${filePath}: ${err.message}`,
);
} finally { } finally {
this.processing.delete(filePath); this.processing.delete(filePath);
} }
@@ -35,10 +35,16 @@ describe('SettingsController', () => {
providers: [ providers: [
{ provide: getRepositoryToken(DocumentType), useValue: docTypeRepo }, { provide: getRepositoryToken(DocumentType), useValue: docTypeRepo },
{ provide: getRepositoryToken(Postprocessing), useValue: ppRepo }, { provide: getRepositoryToken(Postprocessing), useValue: ppRepo },
{ provide: getRepositoryToken(PostprocessingAction), useValue: ppActionRepo }, {
provide: getRepositoryToken(PostprocessingAction),
useValue: ppActionRepo,
},
{ provide: getRepositoryToken(UserClient), useValue: userClientRepo }, { provide: getRepositoryToken(UserClient), useValue: userClientRepo },
{ provide: getRepositoryToken(Client), useValue: makeRepo() }, { 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(); }).compile();
@@ -58,7 +64,9 @@ describe('SettingsController', () => {
it('updateDocumentType calls update + findOneByOrFail', async () => { it('updateDocumentType calls update + findOneByOrFail', async () => {
await controller.updateDocumentType('1', { TitelTemplate: 'New' }); 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 }); expect(docTypeRepo.findOneByOrFail).toHaveBeenCalledWith({ Id: 1 });
}); });
@@ -70,7 +78,9 @@ describe('SettingsController', () => {
}); });
it('createPostprocessingRule creates and saves', async () => { 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.create).toHaveBeenCalledWith({ Name: 'New' });
expect(ppRepo.save).toHaveBeenCalled(); expect(ppRepo.save).toHaveBeenCalled();
expect(result).toHaveProperty('Id', 99); expect(result).toHaveProperty('Id', 99);
@@ -89,7 +99,10 @@ describe('SettingsController', () => {
}); });
it('createUserClient creates', async () => { 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(userClientRepo.create).toHaveBeenCalled();
expect(result).toHaveProperty('Id', 99); expect(result).toHaveProperty('Id', 99);
}); });
@@ -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 { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { DocumentType } from '../database/entities/document-type.entity'; 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 { Client } from '../database/entities/client.entity';
import { Setting } from '../database/entities/setting.entity'; import { Setting } from '../database/entities/setting.entity';
import { CorrespondentSetting } from '../database/entities/correspondent-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 { ExportService } from '../postprocessing/export.service';
import { RequirePermissions } from '../auth/permissions.decorator'; import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
@@ -23,17 +36,27 @@ export class SettingsController {
private readonly logger = new Logger(SettingsController.name); private readonly logger = new Logger(SettingsController.name);
constructor( constructor(
@InjectRepository(DocumentType) private readonly docTypeRepo: Repository<DocumentType>, @InjectRepository(DocumentType)
@InjectRepository(Postprocessing) private readonly ppRepo: Repository<Postprocessing>, private readonly docTypeRepo: Repository<DocumentType>,
@InjectRepository(PostprocessingAction) private readonly ppActionRepo: Repository<PostprocessingAction>, @InjectRepository(Postprocessing)
@InjectRepository(PostprocessingLog) private readonly ppLogRepo: Repository<PostprocessingLog>, private readonly ppRepo: Repository<Postprocessing>,
@InjectRepository(ExportTarget) private readonly exportTargetRepo: Repository<ExportTarget>, @InjectRepository(PostprocessingAction)
@InjectRepository(UserClient) private readonly userClientRepo: Repository<UserClient>, private readonly ppActionRepo: Repository<PostprocessingAction>,
@InjectRepository(PostprocessingLog)
private readonly ppLogRepo: Repository<PostprocessingLog>,
@InjectRepository(ExportTarget)
private readonly exportTargetRepo: Repository<ExportTarget>,
@InjectRepository(UserClient)
private readonly userClientRepo: Repository<UserClient>,
@InjectRepository(Client) private readonly clientRepo: Repository<Client>, @InjectRepository(Client) private readonly clientRepo: Repository<Client>,
@InjectRepository(Setting) private readonly settingRepo: Repository<Setting>, @InjectRepository(Setting)
@InjectRepository(DocumentField) private readonly docFieldRepo: Repository<DocumentField>, private readonly settingRepo: Repository<Setting>,
@InjectRepository(CorrespondentSetting) private readonly corrSettingRepo: Repository<CorrespondentSetting>, @InjectRepository(DocumentField)
@InjectRepository(InboxPostprocessingAction) private readonly inboxActionRepo: Repository<InboxPostprocessingAction>, private readonly docFieldRepo: Repository<DocumentField>,
@InjectRepository(CorrespondentSetting)
private readonly corrSettingRepo: Repository<CorrespondentSetting>,
@InjectRepository(InboxPostprocessingAction)
private readonly inboxActionRepo: Repository<InboxPostprocessingAction>,
private readonly paperlessService: PaperlessService, private readonly paperlessService: PaperlessService,
private readonly exportService: ExportService, private readonly exportService: ExportService,
) {} ) {}
@@ -44,7 +67,7 @@ export class SettingsController {
try { try {
const paperlessTypes = await this.paperlessService.getDocumentTypes(); const paperlessTypes = await this.paperlessService.getDocumentTypes();
const existing = await this.docTypeRepo.find(); 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) { for (const pt of paperlessTypes) {
if (!existingIds.has(pt.id)) { if (!existingIds.has(pt.id)) {
@@ -58,14 +81,20 @@ export class SettingsController {
} }
} }
} catch (err) { } 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' } }); return this.docTypeRepo.find({ order: { Id: 'ASC' } });
} }
@Put('document-types/:id') @Put('document-types/:id')
async updateDocumentType(@Param('id') id: string, @Body() body: Partial<DocumentType>) { async updateDocumentType(
@Param('id') id: string,
@Body() body: Partial<DocumentType>,
) {
await this.docTypeRepo.update(parseInt(id, 10), body); await this.docTypeRepo.update(parseInt(id, 10), body);
return this.docTypeRepo.findOneByOrFail({ Id: parseInt(id, 10) }); return this.docTypeRepo.findOneByOrFail({ Id: parseInt(id, 10) });
} }
@@ -80,13 +109,22 @@ export class SettingsController {
} }
@Post('document-types/:id/fields') @Post('document-types/:id/fields')
async createDocumentField(@Param('id') id: string, @Body() body: Partial<DocumentField>) { async createDocumentField(
const field = this.docFieldRepo.create({ ...body, DocumentType: parseInt(id, 10) }); @Param('id') id: string,
@Body() body: Partial<DocumentField>,
) {
const field = this.docFieldRepo.create({
...body,
DocumentType: parseInt(id, 10),
});
return this.docFieldRepo.save(field); return this.docFieldRepo.save(field);
} }
@Put('document-fields/:fieldId') @Put('document-fields/:fieldId')
async updateDocumentField(@Param('fieldId') fieldId: string, @Body() body: Partial<DocumentField>) { async updateDocumentField(
@Param('fieldId') fieldId: string,
@Body() body: Partial<DocumentField>,
) {
await this.docFieldRepo.update(parseInt(fieldId, 10), body); await this.docFieldRepo.update(parseInt(fieldId, 10), body);
return this.docFieldRepo.findOneByOrFail({ Id: parseInt(fieldId, 10) }); return this.docFieldRepo.findOneByOrFail({ Id: parseInt(fieldId, 10) });
} }
@@ -110,7 +148,10 @@ export class SettingsController {
} }
@Put('postprocessing/:id') @Put('postprocessing/:id')
async updatePostprocessingRule(@Param('id') id: string, @Body() body: Partial<Postprocessing>) { async updatePostprocessingRule(
@Param('id') id: string,
@Body() body: Partial<Postprocessing>,
) {
await this.ppRepo.update(parseInt(id, 10), body); await this.ppRepo.update(parseInt(id, 10), body);
return this.ppRepo.findOneByOrFail({ Id: parseInt(id, 10) }); return this.ppRepo.findOneByOrFail({ Id: parseInt(id, 10) });
} }
@@ -123,16 +164,26 @@ export class SettingsController {
@Post('postprocessing/:id/duplicate') @Post('postprocessing/:id/duplicate')
async duplicatePostprocessingRule(@Param('id') id: string) { async duplicatePostprocessingRule(@Param('id') id: string) {
const original = await this.ppRepo.findOneByOrFail({ Id: parseInt(id, 10) }); const original = await this.ppRepo.findOneByOrFail({
const actions = await this.ppActionRepo.find({ where: { PostprocessingId: original.Id } }); Id: parseInt(id, 10),
});
const actions = await this.ppActionRepo.find({
where: { PostprocessingId: original.Id },
});
const { Id: _, ...ruleData } = original; 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); const saved = await this.ppRepo.save(clone);
for (const action of actions) { for (const action of actions) {
const { Id: __, ...actionData } = action; 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); await this.ppActionRepo.save(clonedAction);
} }
@@ -149,13 +200,22 @@ export class SettingsController {
} }
@Post('postprocessing/:id/actions') @Post('postprocessing/:id/actions')
async createAction(@Param('id') id: string, @Body() body: Partial<PostprocessingAction>) { async createAction(
const action = this.ppActionRepo.create({ ...body, PostprocessingId: parseInt(id, 10) }); @Param('id') id: string,
@Body() body: Partial<PostprocessingAction>,
) {
const action = this.ppActionRepo.create({
...body,
PostprocessingId: parseInt(id, 10),
});
return this.ppActionRepo.save(action); return this.ppActionRepo.save(action);
} }
@Put('postprocessing-actions/:actionId') @Put('postprocessing-actions/:actionId')
async updateAction(@Param('actionId') actionId: string, @Body() body: Partial<PostprocessingAction>) { async updateAction(
@Param('actionId') actionId: string,
@Body() body: Partial<PostprocessingAction>,
) {
await this.ppActionRepo.update(parseInt(actionId, 10), body); await this.ppActionRepo.update(parseInt(actionId, 10), body);
return this.ppActionRepo.findOneByOrFail({ Id: parseInt(actionId, 10) }); return this.ppActionRepo.findOneByOrFail({ Id: parseInt(actionId, 10) });
} }
@@ -184,7 +244,7 @@ export class SettingsController {
throw new Error('ActionType ist erforderlich'); throw new Error('ActionType ist erforderlich');
} }
const entity = this.inboxActionRepo.create({ const entity = this.inboxActionRepo.create({
ActionType: body.ActionType as InboxActionType, ActionType: body.ActionType,
Content: body.Content ?? {}, Content: body.Content ?? {},
Order: body.Order ?? 0, Order: body.Order ?? 0,
IsActive: body.IsActive ?? true, IsActive: body.IsActive ?? true,
@@ -205,7 +265,7 @@ export class SettingsController {
throw new Error('ActionType ist erforderlich'); throw new Error('ActionType ist erforderlich');
} }
const entity = this.inboxActionRepo.create({ const entity = this.inboxActionRepo.create({
ActionType: body.ActionType as InboxActionType, ActionType: body.ActionType,
Content: body.Content ?? {}, Content: body.Content ?? {},
Order: body.Order ?? 0, Order: body.Order ?? 0,
IsActive: body.IsActive ?? true, IsActive: body.IsActive ?? true,
@@ -214,10 +274,13 @@ export class SettingsController {
} }
@Put('inbox-actions/:id') @Put('inbox-actions/:id')
async updateInboxAction(@Param('id') id: string, @Body() body: Partial<InboxPostprocessingAction>) { async updateInboxAction(
@Param('id') id: string,
@Body() body: Partial<InboxPostprocessingAction>,
) {
const numId = parseInt(id, 10); const numId = parseInt(id, 10);
const existing = await this.inboxActionRepo.findOneByOrFail({ Id: numId }); 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.Content !== undefined) existing.Content = body.Content;
if (body.Order !== undefined) existing.Order = body.Order; if (body.Order !== undefined) existing.Order = body.Order;
if (body.IsActive !== undefined) existing.IsActive = body.IsActive; if (body.IsActive !== undefined) existing.IsActive = body.IsActive;
@@ -243,7 +306,10 @@ export class SettingsController {
} }
@Put('export-targets/:id') @Put('export-targets/:id')
async updateExportTarget(@Param('id') id: string, @Body() body: Partial<ExportTarget>) { async updateExportTarget(
@Param('id') id: string,
@Body() body: Partial<ExportTarget>,
) {
await this.exportTargetRepo.update(parseInt(id, 10), body); await this.exportTargetRepo.update(parseInt(id, 10), body);
return this.exportTargetRepo.findOneByOrFail({ Id: parseInt(id, 10) }); return this.exportTargetRepo.findOneByOrFail({ Id: parseInt(id, 10) });
} }
@@ -300,11 +366,44 @@ export class SettingsController {
} }
@Put('general/:id') @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 }); await this.settingRepo.update(parseInt(id, 10), { Wert: body.value });
return this.settingRepo.findOneByOrFail({ ID: parseInt(id, 10) }); return this.settingRepo.findOneByOrFail({ ID: parseInt(id, 10) });
} }
// === Steuertags ===
@Get('steuertags')
async getSteuertags() {
const setting = await this.settingRepo.findOneBy({ Tag: 'steuertag_ids' });
const ids = (setting?.Wert ?? '')
.split(',')
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
return { ids };
}
@Put('steuertags')
async updateSteuertags(@Body() body: { ids: number[] }) {
const ids = (body.ids ?? [])
.map((id) => Number(id))
.filter((n) => !isNaN(n));
let setting = await this.settingRepo.findOneBy({ Tag: 'steuertag_ids' });
if (!setting) {
setting = this.settingRepo.create({
Typ: 0,
Tag: 'steuertag_ids',
Wert: ids.join(','),
});
} else {
setting.Wert = ids.join(',');
}
await this.settingRepo.save(setting);
return { ids };
}
// === Korrespondenten === // === Korrespondenten ===
@Get('correspondents') @Get('correspondents')
async getCorrespondents( async getCorrespondents(
@@ -323,13 +422,18 @@ export class SettingsController {
const response = await this.paperlessService.getCorrespondents(params); const response = await this.paperlessService.getCorrespondents(params);
const paperlessCorrs = response.results; const paperlessCorrs = response.results;
const settings = await this.corrSettingRepo.find(); 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 const newSettings = paperlessCorrs
.filter((pc: any) => !settingsMap.has(pc.id)) .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) { if (newSettings.length > 0) {
await this.corrSettingRepo.insert(newSettings); await this.corrSettingRepo.insert(newSettings);
} }
@@ -338,7 +442,9 @@ export class SettingsController {
const finalSettings = await this.corrSettingRepo.find(); const finalSettings = await this.corrSettingRepo.find();
const merged = paperlessCorrs.map((pc: any) => ({ const merged = paperlessCorrs.map((pc: any) => ({
...pc, ...pc,
agrarmonitorId: finalSettings.find(s => s.CorrespondentId === pc.id)?.AgrarmonitorId || null, agrarmonitorId:
finalSettings.find((s) => s.CorrespondentId === pc.id)
?.AgrarmonitorId || null,
})); }));
return { return {
@@ -346,7 +452,10 @@ export class SettingsController {
total: response.count, total: response.count,
}; };
} catch (err) { } 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 }; return { data: [], total: 0 };
} }
} }
@@ -361,24 +470,32 @@ export class SettingsController {
owner: null, // User said 0, but null is standard for public in Paperless. I'll use null. owner: null, // User said 0, but null is standard for public in Paperless. I'll use null.
}; };
const created = await this.paperlessService.addCorrespondent(data); const created = await this.paperlessService.addCorrespondent(data);
// Also ensure setting entry exists // Also ensure setting entry exists
const newSetting = this.corrSettingRepo.create({ const newSetting = this.corrSettingRepo.create({
CorrespondentId: created.id, CorrespondentId: created.id,
AgrarmonitorId: null, AgrarmonitorId: null,
}); });
await this.corrSettingRepo.save(newSetting); await this.corrSettingRepo.save(newSetting);
return { ...created, agrarmonitorId: null }; return { ...created, agrarmonitorId: null };
} }
@Put('correspondents/:id') @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); const corrId = parseInt(id, 10);
let setting = await this.corrSettingRepo.findOneBy({ CorrespondentId: corrId }); let setting = await this.corrSettingRepo.findOneBy({
CorrespondentId: corrId,
});
if (!setting) { if (!setting) {
setting = this.corrSettingRepo.create({ CorrespondentId: corrId, AgrarmonitorId: body.agrarmonitorId }); setting = this.corrSettingRepo.create({
CorrespondentId: corrId,
AgrarmonitorId: body.agrarmonitorId,
});
} else { } else {
setting.AgrarmonitorId = body.agrarmonitorId; setting.AgrarmonitorId = body.agrarmonitorId;
} }
@@ -393,9 +510,14 @@ export class SettingsController {
} }
@Put('clients/:id') @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); 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 }); return this.clientRepo.findOneByOrFail({ Id: clientId });
} }
} }
@@ -1,90 +1,12 @@
import { Controller, Get, Request, Logger } from '@nestjs/common'; import { Controller, Get, Request } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { StatsService } from './stats.service';
import { Repository } from 'typeorm';
import { Email } from '../database/entities/email.entity';
import { InboxService } from '../inbox/inbox.service';
import { PaperlessService } from '../paperless/paperless.service';
import { ConfigService } from '@nestjs/config';
@Controller('api/stats') @Controller('api/stats')
export class StatsController { export class StatsController {
private readonly logger = new Logger(StatsController.name); constructor(private readonly statsService: StatsService) {}
constructor(
@InjectRepository(Email) private readonly emailRepo: Repository<Email>,
private readonly inboxService: InboxService,
private readonly paperlessService: PaperlessService,
private readonly configService: ConfigService,
) {}
@Get('counts') @Get('counts')
async getCounts(@Request() req: any) { async getCounts(@Request() req: any) {
let inboxCount = 0; return this.statsService.getDashboardCounts(req.user?.preferredUsername);
let posteingangCount = 0;
let manuellCount = 0;
let mailpostfachCount = 0;
let agrarmonitorCount = 0;
// 1. Eingangsbox (Dateien aus /mnt/scans)
if (req.user) {
try {
const files = await this.inboxService.listFiles(req.user.preferredUsername ?? null);
inboxCount = files.length;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Inbox-Stats: ' + err.message);
}
}
// 2. Posteingang (Paperless tag 1)
try {
const response1 = await this.paperlessService.getDocuments({
page: 1,
page_size: 1,
tags__id__all: 1,
});
posteingangCount = response1.count || 0;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Posteingang-Stats: ' + err.message);
}
// 3. Manuell bearbeiten (Paperless tag errorTag)
try {
const errorTag = this.configService.get<number>('MANUELL_BEARBEITEN_TAG', 6);
const response2 = await this.paperlessService.getDocuments({
page: 1,
page_size: 1,
tags__id__all: errorTag,
});
manuellCount = response2.count || 0;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Manuell-Stats: ' + err.message);
}
// 4. Mailpostfach (Emails with status 0)
try {
mailpostfachCount = await this.emailRepo.count({ where: { Status: 0 } });
} catch (err) {
this.logger.error('Fehler beim Abrufen der E-Mail-Stats: ' + err.message);
}
// 5. Agrarmonitor (Paperless tag 3)
try {
const agrarmonitorResponse = await this.paperlessService.getDocuments({
page: 1,
page_size: 1,
tags__id__all: 3,
});
agrarmonitorCount = agrarmonitorResponse.count || 0;
} catch (err) {
this.logger.error('Fehler beim Abrufen der Agrarmonitor-Stats: ' + err.message);
}
return {
inbox: inboxCount,
posteingang: posteingangCount,
manuell: manuellCount,
mailpostfach: mailpostfachCount,
agrarmonitor: agrarmonitorCount,
};
} }
} }
+4 -5
View File
@@ -1,16 +1,15 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { StatsController } from './stats.controller'; import { StatsController } from './stats.controller';
import { StatsService } from './stats.service';
import { Email } from '../database/entities/email.entity'; import { Email } from '../database/entities/email.entity';
import { InboxModule } from '../inbox/inbox.module'; import { InboxModule } from '../inbox/inbox.module';
import { PaperlessModule } from '../paperless/paperless.module'; import { PaperlessModule } from '../paperless/paperless.module';
@Module({ @Module({
imports: [ imports: [TypeOrmModule.forFeature([Email]), InboxModule, PaperlessModule],
TypeOrmModule.forFeature([Email]),
InboxModule,
PaperlessModule,
],
controllers: [StatsController], controllers: [StatsController],
providers: [StatsService],
exports: [StatsService],
}) })
export class StatsModule {} export class StatsModule {}
@@ -0,0 +1,103 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { Email } from '../database/entities/email.entity';
import { InboxService } from '../inbox/inbox.service';
import { PaperlessService } from '../paperless/paperless.service';
export interface DashboardCounts {
inbox: number;
posteingang: number;
manuell: number;
mailpostfach: number;
agrarmonitor: number;
}
@Injectable()
export class StatsService {
private readonly logger = new Logger(StatsService.name);
constructor(
@InjectRepository(Email) private readonly emailRepo: Repository<Email>,
private readonly inboxService: InboxService,
private readonly paperlessService: PaperlessService,
private readonly configService: ConfigService,
) {}
async getDashboardCounts(
preferredUsername?: string,
): Promise<DashboardCounts> {
let inboxCount = 0;
let posteingangCount = 0;
let manuellCount = 0;
let mailpostfachCount = 0;
let agrarmonitorCount = 0;
try {
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,
});
posteingangCount = response.count || 0;
} catch (err) {
this.logger.error(
'Fehler beim Abrufen der Posteingang-Stats: ' + err.message,
);
}
try {
const errorTag = this.configService.get<number>(
'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,
);
}
try {
mailpostfachCount = await this.emailRepo.count({ where: { Status: 0 } });
} catch (err) {
this.logger.error('Fehler beim Abrufen der E-Mail-Stats: ' + err.message);
}
try {
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,
);
}
return {
inbox: inboxCount,
posteingang: posteingangCount,
manuell: manuellCount,
mailpostfach: mailpostfachCount,
agrarmonitor: agrarmonitorCount,
};
}
}
@@ -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 { UserSettingsService } from './user-settings.service';
import { RequirePermissions } from '../auth/permissions.decorator'; import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
@@ -9,12 +17,23 @@ export class UserSettingsController {
@Get() @Get()
async getSettings(@Request() req: any) { async getSettings(@Request() req: any) {
return this.userSettingsService.getSettings(req.user.userId); return this.userSettingsService.getSettings(
req.user.userId,
req.user.email,
req.user.preferredUsername,
req.user.groups,
);
} }
@Put() @Put()
async updateSettings(@Request() req: any, @Body() body: any) { async updateSettings(@Request() req: any, @Body() body: any) {
return this.userSettingsService.updateSettings(req.user.userId, body); return this.userSettingsService.updateSettings(
req.user.userId,
body,
req.user.email,
req.user.preferredUsername,
req.user.groups,
);
} }
@Get('senders') @Get('senders')
@@ -25,7 +44,16 @@ export class UserSettingsController {
@Post('test-smtp') @Post('test-smtp')
@HttpCode(200) @HttpCode(200)
@RequirePermissions(Permission.MANAGE_SETTINGS) @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); return this.userSettingsService.testSmtp(body);
} }
} }
@@ -19,6 +19,7 @@ export interface UserSettingsDto {
mailSignatureHtml: string | null; mailSignatureHtml: string | null;
defaultLabelTemplateId: number | null; defaultLabelTemplateId: number | null;
emailRecipientHistory: string[] | null; emailRecipientHistory: string[] | null;
dailyDigestEnabled: boolean;
} }
@Injectable() @Injectable()
@@ -46,7 +47,10 @@ export class UserSettingsService {
if (!this.encKey) return plaintext; if (!this.encKey) return plaintext;
const iv = crypto.randomBytes(16); const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, this.encKey, iv); 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(); const authTag = cipher.getAuthTag();
return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
} }
@@ -61,11 +65,28 @@ export class UserSettingsService {
const encrypted = Buffer.from(encryptedHex, 'hex'); const encrypted = Buffer.from(encryptedHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, this.encKey, iv); const decipher = crypto.createDecipheriv(ALGORITHM, this.encKey, iv);
decipher.setAuthTag(authTag); 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): Promise<UserSettingsDto> { async getSettings(
const entity = await this.repo.findOne({ where: { UserId: userId } }); userId: string,
email?: string,
preferredUsername?: string,
groups?: string[],
): Promise<UserSettingsDto> {
let entity = await this.repo.findOne({ where: { UserId: userId } });
if (email || preferredUsername || groups) {
if (!entity) {
entity = this.repo.create({ UserId: userId });
}
if (email) entity.UserEmail = email;
if (preferredUsername) entity.UserPreferredUsername = preferredUsername;
if (groups) entity.UserGroups = groups;
await this.repo.save(entity);
}
return this.toDto(entity); return this.toDto(entity);
} }
@@ -82,7 +103,11 @@ export class UserSettingsService {
mailSignatureHtml?: string | null; mailSignatureHtml?: string | null;
defaultLabelTemplateId?: number | null; defaultLabelTemplateId?: number | null;
emailRecipientHistory?: string[] | null; emailRecipientHistory?: string[] | null;
dailyDigestEnabled?: boolean;
}, },
email?: string,
preferredUsername?: string,
groups?: string[],
): Promise<UserSettingsDto> { ): Promise<UserSettingsDto> {
let entity = await this.repo.findOne({ where: { UserId: userId } }); let entity = await this.repo.findOne({ where: { UserId: userId } });
if (!entity) { if (!entity) {
@@ -93,14 +118,27 @@ export class UserSettingsService {
if (data.smtpPort !== undefined) entity.SmtpPort = data.smtpPort; if (data.smtpPort !== undefined) entity.SmtpPort = data.smtpPort;
if (data.smtpSecure !== undefined) entity.SmtpSecure = data.smtpSecure; if (data.smtpSecure !== undefined) entity.SmtpSecure = data.smtpSecure;
if (data.smtpUser !== undefined) entity.SmtpUser = data.smtpUser; 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); entity.SmtpPass = this.encrypt(data.smtpPass);
} }
if (data.smtpFrom !== undefined) entity.SmtpFrom = data.smtpFrom; if (data.smtpFrom !== undefined) entity.SmtpFrom = data.smtpFrom;
if (data.smtpFromName !== undefined) entity.SmtpFromName = data.smtpFromName; if (data.smtpFromName !== undefined)
if (data.mailSignatureHtml !== undefined) entity.MailSignatureHtml = data.mailSignatureHtml; entity.SmtpFromName = data.smtpFromName;
if (data.defaultLabelTemplateId !== undefined) entity.DefaultLabelTemplateId = data.defaultLabelTemplateId; if (data.mailSignatureHtml !== undefined)
if (data.emailRecipientHistory !== undefined) entity.EmailRecipientHistory = data.emailRecipientHistory; 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;
await this.repo.save(entity); await this.repo.save(entity);
return this.toDto(entity); return this.toDto(entity);
@@ -128,12 +166,19 @@ export class UserSettingsService {
} }
async getSmtpConfig(userId: string): Promise<{ 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> { } | null> {
const entity = await this.repo.findOne({ where: { UserId: userId } }); const entity = await this.repo.findOne({ where: { UserId: userId } });
if (!entity?.SmtpHost || !entity?.SmtpPass) return null; if (!entity?.SmtpHost || !entity?.SmtpPass) return null;
const fromEmail = entity.SmtpFrom ?? entity.SmtpUser ?? ''; const fromEmail = entity.SmtpFrom ?? entity.SmtpUser ?? '';
const from = entity.SmtpFromName ? `"${entity.SmtpFromName}" <${fromEmail}>` : fromEmail; const from = entity.SmtpFromName
? `"${entity.SmtpFromName}" <${fromEmail}>`
: fromEmail;
return { return {
host: entity.SmtpHost, host: entity.SmtpHost,
port: entity.SmtpPort ?? 587, port: entity.SmtpPort ?? 587,
@@ -144,16 +189,35 @@ export class UserSettingsService {
}; };
} }
async getAvailableSenders(userId: string): Promise<{ id: string; label: string }[]> { async findAllDigestSubscribers(): Promise<UserSettings[]> {
const defaultEmail = this.configService.get<string>('SMTP_FROM', 'paperless@localhost'); 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<string>(
'SMTP_FROM',
'paperless@localhost',
);
const defaultName = this.configService.get<string>('SMTP_FROM_NAME', ''); const defaultName = this.configService.get<string>('SMTP_FROM_NAME', '');
const defaultLabel = defaultName ? `${defaultName} <${defaultEmail}>` : defaultEmail; const defaultLabel = defaultName
const senders: { id: string; label: string }[] = [{ id: 'default', label: defaultLabel }]; ? `${defaultName} <${defaultEmail}>`
: defaultEmail;
const senders: { id: string; label: string }[] = [
{ id: 'default', label: defaultLabel },
];
const entity = await this.repo.findOne({ where: { UserId: userId } }); const entity = await this.repo.findOne({ where: { UserId: userId } });
if (entity?.SmtpHost && entity?.SmtpPass) { if (entity?.SmtpHost && entity?.SmtpPass) {
const userEmail = entity.SmtpFrom ?? entity.SmtpUser ?? ''; 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 }); senders.push({ id: 'user', label: userLabel });
} }
@@ -166,12 +230,13 @@ export class UserSettingsService {
smtpPort: entity?.SmtpPort ?? null, smtpPort: entity?.SmtpPort ?? null,
smtpSecure: entity?.SmtpSecure ?? false, smtpSecure: entity?.SmtpSecure ?? false,
smtpUser: entity?.SmtpUser ?? null, smtpUser: entity?.SmtpUser ?? null,
smtpPassSet: !!(entity?.SmtpPass), smtpPassSet: !!entity?.SmtpPass,
smtpFrom: entity?.SmtpFrom ?? null, smtpFrom: entity?.SmtpFrom ?? null,
smtpFromName: entity?.SmtpFromName ?? null, smtpFromName: entity?.SmtpFromName ?? null,
mailSignatureHtml: entity?.MailSignatureHtml ?? null, mailSignatureHtml: entity?.MailSignatureHtml ?? null,
defaultLabelTemplateId: entity?.DefaultLabelTemplateId ?? null, defaultLabelTemplateId: entity?.DefaultLabelTemplateId ?? null,
emailRecipientHistory: entity?.EmailRecipientHistory ?? null, emailRecipientHistory: entity?.EmailRecipientHistory ?? null,
dailyDigestEnabled: entity?.DailyDigestEnabled ?? false,
}; };
} }
} }
@@ -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'; import { Public } from '../auth/public.decorator';
export interface PaperlessWebhookPayload { export interface PaperlessWebhookPayload {
document_id: number; document_id: number;
@@ -13,8 +20,12 @@ export class WebhookController {
@Public() @Public()
@Post('paperless') @Post('paperless')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload): Promise<{ status: string }> { async handlePaperlessWebhook(
this.logger.log(`Webhook empfangen: action=${payload.action}, document=${payload.document_id}`); @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 // TODO: Business-Logik für verschiedene Webhook-Events
// - document_updated → Felder prüfen, Postprocessing auslösen // - document_updated → Felder prüfen, Postprocessing auslösen
+2
View File
@@ -20,6 +20,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const UserSettingsPage = lazy(() => import('./pages/UserSettingsPage')); const UserSettingsPage = lazy(() => import('./pages/UserSettingsPage'));
const LoginPage = lazy(() => import('./pages/LoginPage')); const LoginPage = lazy(() => import('./pages/LoginPage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage')); const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const FreigabePage = lazy(() => import('./pages/FreigabePage'));
import { Permission } from './auth/permissions'; import { Permission } from './auth/permissions';
function UnauthorizedPage() { function UnauthorizedPage() {
@@ -131,6 +132,7 @@ function ThemedApp() {
<Route path="/mailpostfach" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailpostfachPage /></PermissionRoute>} /> <Route path="/mailpostfach" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailpostfachPage /></PermissionRoute>} />
<Route path="/mailpostfach/:id" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailDetailPage /></PermissionRoute>} /> <Route path="/mailpostfach/:id" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailDetailPage /></PermissionRoute>} />
<Route path="/settings" element={<PermissionRoute permission={Permission.MANAGE_SETTINGS}><SettingsPage /></PermissionRoute>} /> <Route path="/settings" element={<PermissionRoute permission={Permission.MANAGE_SETTINGS}><SettingsPage /></PermissionRoute>} />
<Route path="/freigabe" element={<PermissionRoute permission={Permission.VIEW_FREIGABE}><FreigabePage /></PermissionRoute>} />
<Route path="/user-settings" element={<UserSettingsPage />} /> <Route path="/user-settings" element={<UserSettingsPage />} />
</Route> </Route>
</Routes> </Routes>
+11 -2
View File
@@ -62,10 +62,19 @@ export const emailImportApi = {
return res.data.isDuplicate; return res.data.isDuplicate;
}, },
executeImport: async (emailDate: string, attachments: AttachmentImportData[]): Promise<{ success: boolean; results: any[] }> => { executeImport: async (emailDate: string, attachments: AttachmentImportData[], jobId?: string): Promise<{ success: boolean; results: any[] }> => {
const res = await api.post('/api/email-import/execute', { emailDate, attachments }); const res = await api.post('/api/email-import/execute', { emailDate, attachments, jobId }, { timeout: 300_000 });
return res.data; return res.data;
}, },
getJobStatus: async (jobId: string): Promise<{ message: string; done: boolean } | null> => {
try {
const res = await api.get(`/api/email-import/jobs/${jobId}/status`);
return res.data;
} catch {
return null;
}
},
ensurePreviews: async (emailId: number): Promise<void> => { ensurePreviews: async (emailId: number): Promise<void> => {
await api.post(`/api/email-import/emails/${emailId}/ensure-previews`); await api.post(`/api/email-import/emails/${emailId}/ensure-previews`);
+40
View File
@@ -0,0 +1,40 @@
import api from './client';
export interface FreigabeDocument {
id: number;
title: string;
created: string;
created_date: string;
correspondent: number | null;
document_type: number | null;
archive_serial_number: number | null;
tags: number[];
custom_fields: { field: number; value: any }[];
}
export interface FreigabeOption {
id: string;
label: string;
}
export interface FreigabeResult {
count: number;
results: FreigabeDocument[];
}
export const freigabeApi = {
getDocuments: (page = 1, pageSize = 25, nurNichtFreigegeben = true) =>
api
.get<FreigabeResult>('/api/freigabe/documents', {
params: { page, pageSize, nurNichtFreigegeben },
})
.then((r) => r.data),
setFreigabe: (docId: number, value: string | null) =>
api
.put<{ success: boolean }>(`/api/freigabe/documents/${docId}/freigabe`, { value })
.then((r) => r.data),
getOptions: () =>
api.get<FreigabeOption[]>('/api/freigabe/options').then((r) => r.data),
};
+1
View File
@@ -49,6 +49,7 @@ export interface PaperlessUser {
export const paperlessApi = { export const paperlessApi = {
getTags: () => api.get<PaperlessTag[]>('/api/paperless/tags').then(r => r.data), getTags: () => api.get<PaperlessTag[]>('/api/paperless/tags').then(r => r.data),
getSteuertagIds: () => api.get<{ ids: number[] }>('/api/paperless/steuertags').then(r => r.data.ids),
getDocumentTypes: () => api.get<PaperlessDocType[]>('/api/paperless/document-types').then(r => r.data), getDocumentTypes: () => api.get<PaperlessDocType[]>('/api/paperless/document-types').then(r => r.data),
getCustomFields: () => api.get<PaperlessCustomField[]>('/api/paperless/custom-fields').then(r => r.data), getCustomFields: () => api.get<PaperlessCustomField[]>('/api/paperless/custom-fields').then(r => r.data),
getCorrespondents: (search?: string) => api.get<PaperlessCorrespondent[]>('/api/paperless/correspondents', { params: { search } }).then(r => r.data), getCorrespondents: (search?: string) => api.get<PaperlessCorrespondent[]>('/api/paperless/correspondents', { params: { search } }).then(r => r.data),
+7
View File
@@ -6,6 +6,7 @@ export interface SettingDocType {
TitelTemplate: string; TitelTemplate: string;
TagNotReady: number | null; TagNotReady: number | null;
TagReady: number | null; TagReady: number | null;
FreigabeErforderlich?: boolean | null;
} }
export interface SettingDocField { export interface SettingDocField {
@@ -144,6 +145,11 @@ export const settingsApi = {
deleteUserClient: (id: number) => deleteUserClient: (id: number) =>
api.delete(`/api/settings/user-clients/${id}`).then(r => r.data), api.delete(`/api/settings/user-clients/${id}`).then(r => r.data),
// Steuertags
getSteuertagIds: () => api.get<{ ids: number[] }>('/api/settings/steuertags').then(r => r.data.ids),
updateSteuertagIds: (ids: number[]) =>
api.put<{ ids: number[] }>('/api/settings/steuertags', { ids }).then(r => r.data.ids),
// Korrespondenten // Korrespondenten
getCorrespondents: (page = 1, pageSize = 50, search?: string) => getCorrespondents: (page = 1, pageSize = 50, search?: string) =>
api.get<{ data: any[], total: number }>('/api/settings/correspondents', { params: { page, pageSize, search } }).then(r => r.data), api.get<{ data: any[], total: number }>('/api/settings/correspondents', { params: { page, pageSize, search } }).then(r => r.data),
@@ -201,6 +207,7 @@ export interface AgrarmonitorPollingConfig {
tagVerbucht: string; tagVerbucht: string;
tagHochgeladen: string; tagHochgeladen: string;
linkField: string; linkField: string;
tagManuell: string;
} }
export interface AgrarmonitorPollingResult { export interface AgrarmonitorPollingResult {
@@ -11,6 +11,7 @@ export interface UserSettingsData {
mailSignatureHtml: string | null; mailSignatureHtml: string | null;
defaultLabelTemplateId: number | null; defaultLabelTemplateId: number | null;
emailRecipientHistory: string[] | null; emailRecipientHistory: string[] | null;
dailyDigestEnabled: boolean;
} }
export interface SenderOption { export interface SenderOption {
@@ -31,4 +32,7 @@ export const userSettingsApi = {
getSenders: () => getSenders: () =>
api.get<SenderOption[]>('/api/user-settings/senders').then((r) => r.data), api.get<SenderOption[]>('/api/user-settings/senders').then((r) => r.data),
sendDigestNow: () =>
api.post<{ ok: boolean; error?: string }>('/api/daily-digest/send-now').then((r) => r.data),
}; };
@@ -5,6 +5,7 @@ export const Permission = {
VIEW_INBOX: 'VIEW_INBOX', VIEW_INBOX: 'VIEW_INBOX',
VIEW_SCANNER: 'VIEW_SCANNER', VIEW_SCANNER: 'VIEW_SCANNER',
MANAGE_SETTINGS: 'MANAGE_SETTINGS', MANAGE_SETTINGS: 'MANAGE_SETTINGS',
VIEW_FREIGABE: 'VIEW_FREIGABE',
} as const; } as const;
export type Permission = typeof Permission[keyof typeof Permission]; export type Permission = typeof Permission[keyof typeof Permission];
@@ -24,6 +25,7 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per
permissions.add(Permission.VIEW_INBOX); permissions.add(Permission.VIEW_INBOX);
permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.VIEW_SCANNER);
permissions.add(Permission.MANAGE_SETTINGS); permissions.add(Permission.MANAGE_SETTINGS);
permissions.add(Permission.VIEW_FREIGABE);
return Array.from(permissions); return Array.from(permissions);
} }
@@ -39,6 +41,9 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per
if (groups.includes('PM_Scanner')) { if (groups.includes('PM_Scanner')) {
permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.VIEW_SCANNER);
} }
if (groups.includes('PM_Freigabe')) {
permissions.add(Permission.VIEW_FREIGABE);
}
return Array.from(permissions); return Array.from(permissions);
} }
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider } from 'antd'; import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider, Tag } from 'antd';
import { PlusOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons'; import { PlusOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang'; import { posteingangApi } from '../api/posteingang';
@@ -7,7 +7,7 @@ import type { DocumentRequirement, PosteingangDocument, Kontonummer } from '../a
import { clientsApi } from '../api/inbox'; import { clientsApi } from '../api/inbox';
import type { Client } from '../api/inbox'; import type { Client } from '../api/inbox';
import { paperlessApi } from '../api/paperless'; import { paperlessApi } from '../api/paperless';
import type { PaperlessDocType, PaperlessCorrespondent } from '../api/paperless'; import type { PaperlessDocType, PaperlessCorrespondent, PaperlessTag } from '../api/paperless';
import { getEnv } from '../utils/env'; import { getEnv } from '../utils/env';
import { AuthIframe, openAuthUrl } from '../utils/auth-resource'; import { AuthIframe, openAuthUrl } from '../utils/auth-resource';
import DocumentSearchModal from './DocumentSearchModal'; import DocumentSearchModal from './DocumentSearchModal';
@@ -35,6 +35,11 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]); const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
const [requirements, setRequirements] = useState<DocumentRequirement[]>([]); const [requirements, setRequirements] = useState<DocumentRequirement[]>([]);
// Tags: alle Tags + als Steuertags markierte IDs; bearbeitbar sind nur Inhaltstags
const [allTags, setAllTags] = useState<PaperlessTag[]>([]);
const [steuertagIds, setSteuertagIds] = useState<number[]>([]);
const contentTags = allTags.filter(t => !steuertagIds.includes(t.id));
const [kontonummerMissing, setKontonummerMissing] = useState<{ correspondentId: number, nummer: string } | null>(null); const [kontonummerMissing, setKontonummerMissing] = useState<{ correspondentId: number, nummer: string } | null>(null);
const [docTitles, setDocTitles] = useState<Record<number, string>>({}); const [docTitles, setDocTitles] = useState<Record<number, string>>({});
const [searchModalOpen, setSearchModalOpen] = useState<{ field: string, reqId: number } | null>(null); const [searchModalOpen, setSearchModalOpen] = useState<{ field: string, reqId: number } | null>(null);
@@ -140,6 +145,14 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
} }
}, [document, form, open]); }, [document, form, open]);
// Tags getrennt setzen: nur Inhaltstags (Steuertags werden nicht angezeigt/bearbeitet)
useEffect(() => {
if (open && document) {
const contentTagIds = (document.tags || []).filter(id => !steuertagIds.includes(id));
form.setFieldValue('tags', contentTagIds);
}
}, [document, open, steuertagIds, form]);
const ensureCorrespondentInList = async (correspondentId: number | null | undefined) => { const ensureCorrespondentInList = async (correspondentId: number | null | undefined) => {
if (!correspondentId) return; if (!correspondentId) return;
@@ -178,14 +191,18 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
const loadInitialData = async () => { const loadInitialData = async () => {
setLoading(true); setLoading(true);
try { try {
const [clientsData, docTypesData, correspondentsData] = await Promise.all([ const [clientsData, docTypesData, correspondentsData, tagsData, steuertagData] = await Promise.all([
clientsApi.getMyClients(), clientsApi.getMyClients(),
paperlessApi.getDocumentTypes(), paperlessApi.getDocumentTypes(),
paperlessApi.getCorrespondents() paperlessApi.getCorrespondents(),
paperlessApi.getTags(),
paperlessApi.getSteuertagIds(),
]); ]);
setClients(clientsData); setClients(clientsData);
setDocumentTypes(docTypesData); setDocumentTypes(docTypesData);
setCorrespondents(correspondentsData); setCorrespondents(correspondentsData);
setAllTags(tagsData);
setSteuertagIds(steuertagData);
// If document is already there, ensure its correspondent is in the list // If document is already there, ensure its correspondent is in the list
if (document?.correspondent) { if (document?.correspondent) {
@@ -258,6 +275,7 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
correspondent: values.correspondent, correspondent: values.correspondent,
title: values.title, title: values.title,
date: values.belegdatum ? values.belegdatum.format('YYYY-MM-DD') : null, date: values.belegdatum ? values.belegdatum.format('YYYY-MM-DD') : null,
tags: values.tags || [],
customFields: customFieldsObj, customFields: customFieldsObj,
}; };
@@ -373,6 +391,36 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" /> <DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
</Form.Item> </Form.Item>
<Form.Item name="tags" label="Tags">
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="Tags auswählen"
options={contentTags.map(t => ({ value: t.id, label: t.name }))}
tagRender={({ value, closable, onClose }) => {
const tag = allTags.find(t => t.id === value);
return (
<Tag
color={tag?.color}
style={{ color: tag?.text_color, marginInlineEnd: 4 }}
closable={closable}
onClose={onClose}
>
{tag?.name ?? value}
</Tag>
);
}}
optionRender={(opt) => {
const tag = contentTags.find(t => t.id === opt.value);
return tag ? (
<Tag color={tag.color} style={{ color: tag.text_color }}>{tag.name}</Tag>
) : opt.label;
}}
/>
</Form.Item>
{/* Rendering dynamic requirements based on DocumentType */} {/* Rendering dynamic requirements based on DocumentType */}
{requirements.map(req => { {requirements.map(req => {
if (req.feldId === '2' || (req.isCustomField && req.customFieldIndex === 9)) { if (req.feldId === '2' || (req.isCustomField && req.customFieldIndex === 9)) {
@@ -44,6 +44,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
// Step 3 specific state // Step 3 specific state
const [importSuccess, setImportSuccess] = useState(false); const [importSuccess, setImportSuccess] = useState(false);
const [importStatus, setImportStatus] = useState('');
useEffect(() => { useEffect(() => {
if (visible && attachments.length > 0) { if (visible && attachments.length > 0) {
@@ -315,6 +316,14 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
const executeImport = async () => { const executeImport = async () => {
setLoading(true); setLoading(true);
setImportStatus('Import wird gestartet...');
const jobId = crypto.randomUUID ? crypto.randomUUID() : `job-${Date.now()}`;
const statusPoll = setInterval(async () => {
try {
const status = await emailImportApi.getJobStatus(jobId);
if (status?.message) setImportStatus(status.message);
} catch {}
}, 1500);
try { try {
const finalData = []; const finalData = [];
@@ -358,12 +367,14 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
}); });
} }
await emailImportApi.executeImport(email.Date, finalData); await emailImportApi.executeImport(email.Date, finalData, jobId);
setImportSuccess(true); setImportSuccess(true);
setCurrentStep(2); setCurrentStep(2);
} catch (e: any) { } catch (e: any) {
message.error(`Fehler beim Import: ${e.message}`); message.error(`Fehler beim Import: ${e.message}`);
} finally { } finally {
clearInterval(statusPoll);
setImportStatus('');
setLoading(false); setLoading(false);
} }
}; };
@@ -747,7 +758,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
style={{ marginBottom: 24 }} style={{ marginBottom: 24 }}
/> />
<Spin spinning={loading}> <Spin spinning={loading} tip={importStatus || undefined}>
<div style={{ minHeight: 300 }}> <div style={{ minHeight: 300 }}>
{currentStep === 0 && renderStep1()} {currentStep === 0 && renderStep1()}
{currentStep === 1 && renderStep2()} {currentStep === 1 && renderStep2()}
@@ -13,6 +13,7 @@ import {
MoonOutlined, MoonOutlined,
AppstoreOutlined, AppstoreOutlined,
GlobalOutlined, GlobalOutlined,
CheckCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { useTheme } from '../theme/ThemeContext'; import { useTheme } from '../theme/ThemeContext';
@@ -38,6 +39,7 @@ const allMenuItems: MenuItemDef[] = [
{ key: '/manuell', icon: <EditOutlined />, label: 'Manuell bearbeiten', permission: Permission.PROCESS_MANUALLY, countKey: 'manuell' }, { key: '/manuell', icon: <EditOutlined />, label: 'Manuell bearbeiten', permission: Permission.PROCESS_MANUALLY, countKey: 'manuell' },
{ key: '/mailpostfach', icon: <MailOutlined />, label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' }, { key: '/mailpostfach', icon: <MailOutlined />, label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' },
{ key: 'agrarmonitor', icon: <GlobalOutlined />, label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' }, { key: 'agrarmonitor', icon: <GlobalOutlined />, label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' },
{ key: '/freigabe', icon: <CheckCircleOutlined />, label: 'Freigabe', permission: Permission.VIEW_FREIGABE },
{ key: '/settings', icon: <SettingOutlined />, label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS }, { key: '/settings', icon: <SettingOutlined />, label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS },
]; ];
@@ -0,0 +1,218 @@
import { useEffect, useState, useCallback } from 'react';
import {
Table, Typography, Tag, Button, Modal, Select, message, Space, Radio,
} from 'antd';
import { CheckCircleOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { freigabeApi, type FreigabeDocument, type FreigabeOption } from '../api/freigabe';
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
const { Title } = Typography;
const FREIGABE_FIELD_ID = 15;
export default function FreigabePage() {
const [data, setData] = useState<FreigabeDocument[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [nurNichtFreigegeben, setNurNichtFreigegeben] = useState(true);
const [docTypes, setDocTypes] = useState<PaperlessDocType[]>([]);
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
const [freigabeOptions, setFreigabeOptions] = useState<FreigabeOption[]>([]);
const [selectedDoc, setSelectedDoc] = useState<FreigabeDocument | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
Promise.all([
paperlessApi.getDocumentTypes(),
paperlessApi.getCorrespondents(),
freigabeApi.getOptions(),
]).then(([dts, corrs, opts]) => {
setDocTypes(dts);
setCorrespondents(corrs);
setFreigabeOptions(opts);
});
}, []);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await freigabeApi.getDocuments(page, pageSize, nurNichtFreigegeben);
setData(result.results ?? []);
setTotal(result.count ?? 0);
} catch {
message.error('Fehler beim Laden der Belege');
} finally {
setLoading(false);
}
}, [page, pageSize, nurNichtFreigegeben]);
useEffect(() => {
fetchData();
}, [fetchData]);
const getDocTypeName = (id: number | null) => {
if (!id) return '—';
return docTypes.find((d) => d.id === id)?.name ?? String(id);
};
const getCorrespondentName = (id: number | null) => {
if (!id) return '—';
return correspondents.find((c) => c.id === id)?.name ?? String(id);
};
const toCfString = (value: any): string | null => {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'object') return String(value?.id ?? value?.value ?? value?.label ?? '') || null;
return String(value);
};
const getFreigabeValue = (doc: FreigabeDocument) => {
const cf = doc.custom_fields?.find((f) => f.field === FREIGABE_FIELD_ID);
const cfStr = toCfString(cf?.value);
if (!cfStr) return <Tag color="warning">Nicht gesetzt</Tag>;
const opt = freigabeOptions.find((o) => o.id === cfStr);
return <Tag color="success">{opt?.label ?? cfStr}</Tag>;
};
const openModal = (doc: FreigabeDocument) => {
const cf = doc.custom_fields?.find((f) => f.field === FREIGABE_FIELD_ID);
setSelectedDoc(doc);
setSelectedValue(toCfString(cf?.value));
setModalOpen(true);
};
const handleFreigabe = async () => {
if (!selectedDoc) return;
setSaving(true);
try {
await freigabeApi.setFreigabe(selectedDoc.id, selectedValue);
message.success('Freigabe gesetzt');
setModalOpen(false);
setSelectedDoc(null);
fetchData();
} catch {
message.error('Fehler beim Speichern der Freigabe');
} finally {
setSaving(false);
}
};
const columns: ColumnsType<FreigabeDocument> = [
{
title: 'Dokumenttyp',
dataIndex: 'document_type',
key: 'doctype',
render: getDocTypeName,
},
{
title: 'Titel',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: 'Erstellt',
dataIndex: 'created_date',
key: 'created',
width: 110,
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
},
{
title: 'Absender',
dataIndex: 'correspondent',
key: 'correspondent',
render: getCorrespondentName,
},
{
title: 'Freigabe',
key: 'freigabe',
width: 140,
render: (_, doc) => getFreigabeValue(doc),
},
{
title: '',
key: 'action',
width: 130,
render: (_, doc) => (
<Button
icon={<CheckCircleOutlined />}
size="small"
type="primary"
onClick={() => openModal(doc)}
>
Freigabe setzen
</Button>
),
},
];
return (
<>
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Freigabe</Title>
<Space style={{ marginBottom: 16 }}>
<Radio.Group
value={nurNichtFreigegeben}
onChange={(e) => {
setPage(1);
setNurNichtFreigegeben(e.target.value);
}}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value={true}>Nicht freigegeben</Radio.Button>
<Radio.Button value={false}>Alle</Radio.Button>
</Radio.Group>
</Space>
<Table<FreigabeDocument>
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['25', '50', '100'],
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
showTotal: (t) => `${t} Belege`,
}}
/>
<Modal
title="Freigabe setzen"
open={modalOpen}
onOk={handleFreigabe}
onCancel={() => { setModalOpen(false); setSelectedDoc(null); }}
okText="Speichern"
cancelText="Abbrechen"
confirmLoading={saving}
>
<p style={{ marginBottom: 12 }}>
<strong>{selectedDoc?.title}</strong>
</p>
<Select
style={{ width: '100%' }}
placeholder="Freigabe-Status wählen"
allowClear
value={selectedValue ?? undefined}
onChange={(v) => setSelectedValue(v ?? null)}
options={freigabeOptions.map((o) => ({ value: o.id, label: o.label }))}
/>
</Modal>
</>
);
}
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Table, Popover, Button, Space, message, Tooltip, Typography } from 'antd'; import { Table, Popover, Button, Space, message, Tooltip, Typography, Tag } from 'antd';
const { Title } = Typography; const { Title } = Typography;
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang'; import { posteingangApi } from '../api/posteingang';
import type { PosteingangDocument } from '../api/posteingang'; import type { PosteingangDocument } from '../api/posteingang';
import { paperlessApi } from '../api/paperless';
import type { PaperlessTag } from '../api/paperless';
import DocumentEditModal from '../components/DocumentEditModal'; import DocumentEditModal from '../components/DocumentEditModal';
import { getEnv } from '../utils/env'; import { getEnv } from '../utils/env';
import { AuthImage } from '../utils/auth-resource'; import { AuthImage } from '../utils/auth-resource';
@@ -15,6 +17,8 @@ export default function ManuellBearbeitenPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<PosteingangDocument | null>(null); const [selectedDoc, setSelectedDoc] = useState<PosteingangDocument | null>(null);
const [allTags, setAllTags] = useState<PaperlessTag[]>([]);
const [steuertagIds, setSteuertagIds] = useState<number[]>([]);
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
@@ -28,6 +32,15 @@ export default function ManuellBearbeitenPage() {
} }
}; };
useEffect(() => {
Promise.all([paperlessApi.getTags(), paperlessApi.getSteuertagIds()])
.then(([tags, ids]) => {
setAllTags(tags);
setSteuertagIds(ids);
})
.catch(() => { /* Tags optional; Chips bleiben dann leer */ });
}, []);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
// Refresh interval every 30 seconds // Refresh interval every 30 seconds
@@ -84,6 +97,26 @@ export default function ManuellBearbeitenPage() {
dataIndex: 'title', dataIndex: 'title',
key: 'title', key: 'title',
width: '35%', width: '35%',
render: (_: any, record: PosteingangDocument) => {
const contentTags = (record.tags || [])
.filter(id => !steuertagIds.includes(id))
.map(id => allTags.find(t => t.id === id))
.filter((t): t is PaperlessTag => !!t);
return (
<div>
<div>{record.title}</div>
{contentTags.length > 0 && (
<Space size={[4, 4]} wrap style={{ marginTop: 4 }}>
{contentTags.map(t => (
<Tag key={t.id} color={t.color} style={{ color: t.text_color, margin: 0 }}>
{t.name}
</Tag>
))}
</Space>
)}
</div>
);
},
}, },
{ {
title: 'Eingangsdatum', title: 'Eingangsdatum',
@@ -9,6 +9,7 @@ import {
PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined, PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined,
HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined, HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined,
QrcodeOutlined, UnorderedListOutlined, PrinterOutlined, GlobalOutlined, QrcodeOutlined, UnorderedListOutlined, PrinterOutlined, GlobalOutlined,
TagsOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd'; import type { FormInstance } from 'antd';
@@ -579,6 +580,12 @@ function DocTypesTab() {
{ title: 'Titel-Template', dataIndex: 'TitelTemplate', key: 'template', ellipsis: true }, { title: 'Titel-Template', dataIndex: 'TitelTemplate', key: 'template', ellipsis: true },
{ title: 'Tag (nicht bereit)', dataIndex: 'TagNotReady', key: 'tagNR', render: getTagName }, { title: 'Tag (nicht bereit)', dataIndex: 'TagNotReady', key: 'tagNR', render: getTagName },
{ title: 'Tag (bereit)', dataIndex: 'TagReady', key: 'tagR', render: getTagName }, { title: 'Tag (bereit)', dataIndex: 'TagReady', key: 'tagR', render: getTagName },
{
title: 'Freigabe erf.',
dataIndex: 'FreigabeErforderlich',
key: 'freigabe',
render: (v: boolean | null) => v ? <Tag color="blue">Ja</Tag> : '—',
},
{ {
title: '', title: '',
key: 'actions', key: 'actions',
@@ -624,6 +631,9 @@ function DocTypesTab() {
{tags.map(t => <Select.Option key={t.id} value={t.id}>{t.name}</Select.Option>)} {tags.map(t => <Select.Option key={t.id} value={t.id}>{t.name}</Select.Option>)}
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item name="FreigabeErforderlich" valuePropName="checked" label="Freigabe erforderlich">
<Checkbox />
</Form.Item>
</Form> </Form>
{editing && ( {editing && (
@@ -2579,6 +2589,9 @@ function AgrarmonitorTab() {
<Form.Item name="tagHochgeladen" label="Tag-ID: Hochgeladen in Agrarmonitor"> <Form.Item name="tagHochgeladen" label="Tag-ID: Hochgeladen in Agrarmonitor">
<Input placeholder="3" style={{ width: 120 }} /> <Input placeholder="3" style={{ width: 120 }} />
</Form.Item> </Form.Item>
<Form.Item name="tagManuell" label="Tag-ID: Manuell bearbeiten (bei fehlendem AM-Beleg)">
<Input placeholder="" style={{ width: 120 }} />
</Form.Item>
<Form.Item name="linkField" label="Custom Field: Agrarmonitor-Link"> <Form.Item name="linkField" label="Custom Field: Agrarmonitor-Link">
<Select allowClear placeholder="Kein Feld ausgewählt" style={{ width: 280 }}> <Select allowClear placeholder="Kein Feld ausgewählt" style={{ width: 280 }}>
{customFields.map(f => ( {customFields.map(f => (
@@ -2651,6 +2664,86 @@ function AgrarmonitorTab() {
} }
// ═══════════════════════════════════════════════════════════════════
// Steuertags Tab
// ═══════════════════════════════════════════════════════════════════
function SteuertagsTab() {
const [tags, setTags] = useState<PaperlessTag[]>([]);
const [selected, setSelected] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [fetchedTags, steuertagIds] = await Promise.all([
paperlessApi.getTags(),
settingsApi.getSteuertagIds(),
]);
setTags(fetchedTags);
setSelected(steuertagIds);
} catch {
message.error('Tags konnten nicht geladen werden.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const handleSave = async () => {
setSaving(true);
try {
await settingsApi.updateSteuertagIds(selected);
message.success('Steuertags gespeichert.');
} catch {
message.error('Steuertags konnten nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
return (
<div>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Steuertags"
description="Als Steuertag markierte Tags dienen der Workflow-Steuerung (z. B. Fertig/Nicht fertig, manuell bearbeiten) und werden NICHT als bearbeitbare Chips im Dokument angezeigt. Alle übrigen Tags gelten als inhaltliche Tags und erscheinen unter dem Titel sowie als Auswahlfeld im Bearbeiten-Dialog."
/>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
loading={loading}
style={{ width: '100%', maxWidth: 700 }}
placeholder="Steuertags auswählen"
value={selected}
onChange={setSelected}
options={tags.map((t) => ({ value: t.id, label: t.name }))}
optionRender={(opt) => {
const tag = tags.find((t) => t.id === opt.value);
return tag ? (
<Tag color={tag.color} style={{ color: tag.text_color }}>{tag.name}</Tag>
) : (
opt.label
);
}}
/>
<div style={{ marginTop: 16 }}>
<Button type="primary" loading={saving} onClick={handleSave}>
Speichern
</Button>
</div>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// Settings Page // Settings Page
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -2687,6 +2780,11 @@ export default function SettingsPage() {
label: <span><UserOutlined /> Korrespondenten</span>, label: <span><UserOutlined /> Korrespondenten</span>,
children: <CorrespondentsTab />, children: <CorrespondentsTab />,
}, },
{
key: 'steuertags',
label: <span><TagsOutlined /> Steuertags</span>,
children: <SteuertagsTab />,
},
{ {
key: 'export-targets', key: 'export-targets',
label: <span><CloudUploadOutlined /> Export-Ziele</span>, label: <span><CloudUploadOutlined /> Export-Ziele</span>,
@@ -197,6 +197,71 @@ function MailSettingsTab() {
); );
} }
function NotificationsTab() {
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [sending, setSending] = useState(false);
useEffect(() => {
userSettingsApi.get()
.then((data) => setEnabled(data.dailyDigestEnabled ?? false))
.catch(() => message.error('Einstellungen konnten nicht geladen werden'))
.finally(() => setLoading(false));
}, []);
const handleSave = async () => {
setSaving(true);
try {
await userSettingsApi.update({ dailyDigestEnabled: enabled });
message.success('Einstellungen gespeichert');
} catch {
message.error('Speichern fehlgeschlagen');
} finally {
setSaving(false);
}
};
const handleSendNow = async () => {
setSending(true);
try {
const result = await userSettingsApi.sendDigestNow();
if (result.ok) {
message.success('Tagesübersicht wurde gesendet');
} else {
message.error(result.error ?? 'Senden fehlgeschlagen');
}
} catch {
message.error('Senden fehlgeschlagen');
} finally {
setSending(false);
}
};
if (loading) return null;
return (
<Form layout="vertical" style={{ maxWidth: 600 }}>
<Form.Item
label="Tägliche E-Mail-Zusammenfassung"
extra="Sie erhalten jeden Morgen eine E-Mail mit der Übersicht aller offenen Vorgänge aus dem Dashboard."
>
<Switch checked={enabled} onChange={setEnabled} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" loading={saving} onClick={handleSave}>
Speichern
</Button>
<Button loading={sending} onClick={handleSendNow}>
Jetzt senden
</Button>
</Space>
</Form.Item>
</Form>
);
}
export default function UserSettingsPage() { export default function UserSettingsPage() {
return ( return (
<div> <div>
@@ -214,6 +279,11 @@ export default function UserSettingsPage() {
label: 'Etikettendruck', label: 'Etikettendruck',
children: <LabelSettingsTab />, children: <LabelSettingsTab />,
}, },
{
key: 'notifications',
label: 'Benachrichtigungen',
children: <NotificationsTab />,
},
]} ]}
/> />
</Card> </Card>