From f4428afb9b343c3027f0e47af32ef925fc657bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sat, 9 May 2026 09:04:20 +0200 Subject: [PATCH] feat: add SSE event stream for print jobs, implement batch printing in frontend, and update API documentation. --- docs/BACKEND_API.md | 228 ++++++++++-------- .../label-print-agent.controller.ts | 12 + .../label-print-agent.service.ts | 12 +- paperless-frontend/src/pages/InboxPage.tsx | 29 ++- 4 files changed, 181 insertions(+), 100 deletions(-) diff --git a/docs/BACKEND_API.md b/docs/BACKEND_API.md index fa48065..f00aa88 100644 --- a/docs/BACKEND_API.md +++ b/docs/BACKEND_API.md @@ -1,40 +1,94 @@ -# Backend API für LabelPrintAgent +# Backend API – LabelPrintAgent -Diese Datei beschreibt die Endpunkte, die der PaperlessManager bereitstellen muss, damit der LabelPrintAgent Etiketten abholen, drucken und das Ergebnis zurückmelden kann. +Diese Datei beschreibt die Endpunkte des PaperlessManager-Backends für den LabelPrintAgent. -Der LabelPrintAgent rendert keine Layouts selbst. Das Backend liefert ein fertiges Etikettbild. +Das Backend rendert das fertige Etikettbild (SVG → PNG via resvg-js). Der Agent ist nur lokaler Druck-Connector. ## Authentifizierung -Alle Endpunkte sollten denselben Bearer Token akzeptieren: +Alle Endpunkte erfordern einen Bearer Token (JWT oder API-Key): ```http -Authorization: Bearer {apiToken} +Authorization: Bearer {token} ``` -Der Token wird im Agent lokal verschlüsselt gespeichert. +`/jobs/next`, `/jobs/:id/image`, `/jobs/:id/printed` und `/jobs/:id/error` benötigen **keine** spezifische Permission (nur gültigen Token). `POST /jobs` und `POST /preview` erfordern `VIEW_SCANNER`. -## 1. Nächsten Druckjob abrufen +--- + +## 1. Job manuell anlegen (Frontend → Backend) + +```http +POST /api/label-print-agent/jobs +Content-Type: application/json +``` + +### Request Body + +```json +{ + "templateId": 3, + "fieldValues": { + "datum": "2026-05-09" + } +} +``` + +| Feld | Pflicht | Beschreibung | +|---|---|---| +| `templateId` | ja | ID des BarcodeTemplates | +| `fieldValues` | nein | Feldwerte für Platzhalter; Datumsfelder im Format `YYYY-MM-DD` | + +### Antwort + +```http +201 Created +``` +```json +{ "jobId": "42" } +``` + +--- + +## 2. Vorschau-Bild rendern (kein Job, keine Nummer-Reservierung) + +```http +POST /api/label-print-agent/preview +Content-Type: application/json +``` + +### Request Body + +Identisch mit `POST /jobs`. `{number}` wird immer als `1` gerendert — die GET-URL wird **nicht** aufgerufen. + +### Antwort + +```http +200 OK +Content-Type: image/png +``` + +Body: binäres PNG-Bild. + +--- + +## 3. Nächsten Druckjob abrufen (Agent-Polling) ```http GET /api/label-print-agent/jobs/next?agentId={agentId} ``` -Der Agent ruft diesen Endpunkt alle X Sekunden auf. +| Parameter | Pflicht | Beschreibung | +|---|---|---| +| `agentId` | nein | Eindeutige Agent-ID (z. B. Rechnername); Fallback: `"unknown"` | -### Query-Parameter - -| Name | Pflicht | Beschreibung | -| --- | --- | --- | -| `agentId` | ja | Eindeutige ID des Agents, z. B. Rechnername | - -### Antwort, wenn kein Job vorhanden ist +### Antwort – kein Job vorhanden ```http 204 No Content ``` -### Antwort mit Bild direkt im JSON +### Antwort – Job vorhanden ```http 200 OK @@ -43,7 +97,7 @@ Content-Type: application/json ```json { - "jobId": "12345", + "jobId": "42", "labelImageBase64": "iVBORw0KGgoAAAANSUhEUgAA...", "labelImageContentType": "image/png", "labelWidthMm": 57, @@ -51,34 +105,20 @@ Content-Type: application/json } ``` -### Antwort mit separater Bild-URL +| Feld | Beschreibung | +|---|---| +| `jobId` | Job-ID für Rückmeldungen | +| `labelImageBase64` | Base64-PNG; `null` wenn Rendering fehlgeschlagen | +| `labelImageContentType` | Immer `"image/png"` | +| `labelWidthMm` / `labelHeightMm` | Etikettenmaß in mm | -```json -{ - "jobId": "12345", - "labelImageUrl": "/api/label-print-agent/jobs/12345/image", - "labelImageContentType": "image/png", - "labelWidthMm": 57, - "labelHeightMm": 32 -} -``` +Das Backend setzt beim Ausliefern einen Lock (5-Minuten-TTL). Jobs mit abgelaufenem Lock werden erneut angeboten. -### Felder +--- -| Feld | Pflicht | Beschreibung | -| --- | --- | --- | -| `jobId` | ja | Eindeutige Job-ID für Rückmeldungen | -| `labelImageBase64` | bedingt | Base64-kodiertes Etikettbild | -| `labelImageUrl` | bedingt | URL zum Nachladen des Etikettbilds | -| `labelImageContentType` | empfohlen | z. B. `image/png` | -| `labelWidthMm` | ja | Etikettenbreite in mm, z. B. `57` | -| `labelHeightMm` | ja | Etikettenhöhe in mm, z. B. `32` | +## 4. Etikettbild separat abrufen -`labelImageBase64` oder `labelImageUrl` muss gesetzt sein. - -## 2. Etikettbild nachladen - -Nur erforderlich, wenn `labelImageUrl` verwendet wird. +Alternativ zum Base64-Feld in `jobs/next`. ```http GET /api/label-print-agent/jobs/{jobId}/image @@ -91,11 +131,15 @@ GET /api/label-print-agent/jobs/{jobId}/image Content-Type: image/png ``` -Body: Binärdaten des fertigen Etikettbilds. +Body: binäres PNG-Bild. -Empfehlung: PNG, schwarz/weiß, passend zum Etikettenformat, z. B. 57 x 32 mm bei 300 dpi. +```http +404 Not Found – Job oder Bild nicht vorhanden +``` -## 3. Erfolgreichen Druck melden +--- + +## 5. Erfolgreichen Druck melden ```http POST /api/label-print-agent/jobs/{jobId}/printed @@ -111,21 +155,22 @@ Content-Type: application/json } ``` +Alle Felder optional; Fallback jeweils `""` / `"unknown"`. + ### Antwort ```http 200 OK ``` - -oder: - -```http -204 No Content +```json +{ "ok": true } ``` -Das Backend sollte den Job erst hier endgültig als gedruckt markieren. +Das Backend setzt den Job auf `printed`, speichert Zeitstempel und ruft die konfigurierte `LabelPrintedUrl` des Templates auf (`POST`). -## 4. Fehler melden +--- + +## 6. Druckfehler melden ```http POST /api/label-print-agent/jobs/{jobId}/error @@ -138,7 +183,7 @@ Content-Type: application/json { "agentId": "PC-BUERO", "printerName": "DYMO LabelWriter 450", - "errorMessage": "Drucker ist nicht verfügbar." + "errorMessage": "Drucker nicht verfügbar." } ``` @@ -147,64 +192,57 @@ Content-Type: application/json ```http 200 OK ``` - -oder: - -```http -204 No Content +```json +{ "ok": true } ``` -Das Backend entscheidet danach, ob der Job erneut angeboten wird oder auf Fehler bleibt. +Das Backend setzt den Job auf `error` und ruft die konfigurierte `LabelReleaseUrl` des Templates auf (`POST`). -## Backend-Verhalten +--- -Empfohlener Ablauf im Backend: +## Job-Lebenszyklus -1. Job erstellen und serverseitig Layout, Nummern, QR-Code und Bild erzeugen. -2. Job bleibt wartend, bis ein Agent ihn abholt. -3. `jobs/next` liefert jeweils höchstens einen Job. -4. Backend reserviert oder lockt den Job beim Ausliefern, damit zwei Agents ihn nicht parallel drucken. -5. Agent druckt lokal. -6. Agent meldet `printed` oder `error`. -7. Backend setzt den finalen Status. +``` +createJob() + │ + ▼ + pending ──── jobs/next ──── Lock (5 Min TTL) + │ + Agent druckt + │ + ┌───────┴───────┐ + printed error + (PrintedUrl) (ReleaseUrl) +``` -## Empfohlene Statuscodes - -| Situation | Status | -| --- | --- | -| Kein Job vorhanden | `204 No Content` | -| Job vorhanden | `200 OK` | -| Token fehlt/ungültig | `401 Unauthorized` | -| Agent darf nicht drucken | `403 Forbidden` | -| Job-ID unbekannt | `404 Not Found` | -| Backend-Fehler | `500 Internal Server Error` | - -## Server-Sent Events optional - -Später kann das Backend zusätzlich einen Event-Endpunkt anbieten: +## 7. Server-Sent Events – neue Druckaufträge (Push) ```http -GET /api/label-print-agent/events?agentId={agentId} +GET /api/label-print-agent/events +Authorization: Bearer {token} Accept: text/event-stream ``` -Beispiel: +Der Agent verbindet sich einmalig. Sobald ein neuer Druckauftrag erstellt wird, sendet das Backend: -```text -event: label-job-available -data: {"count":1} +``` +data: {"type":"label-job-available"} ``` -Der Agent könnte dann bei einem Event sofort `jobs/next` aufrufen. Polling bleibt trotzdem als Fallback sinnvoll. +Der Agent ruft daraufhin sofort `GET /jobs/next` auf. Polling bleibt als Fallback sinnvoll (z. B. alle 30 s), falls die SSE-Verbindung unterbrochen wurde. -## Wichtige Designentscheidung +Es werden keine agentId-Parameter ausgewertet – alle verbundenen Agents erhalten das Event. -Der Agent kennt keine fachlichen Layouts mehr: +--- -- keine `layout_key` -- keine lokalen LabelTemplates -- keine MySQL-Verbindung -- keine Nummernreservierung -- kein QR-Code-Rendering +## Statuscodes -Das Backend liefert ein fertiges Bild. Der Agent ist nur noch lokaler Windows-Druck-Connector. +| Situation | Status | +|---|---| +| Kein Job vorhanden | `204 No Content` | +| Job / Bild vorhanden | `200 OK` | +| Job erstellt | `201 Created` | +| Token fehlt / ungültig | `401 Unauthorized` | +| Fehlende Permission | `403 Forbidden` | +| Job-ID unbekannt | `404 Not Found` | +| Backend-Fehler | `500 Internal Server Error` | diff --git a/paperless-backend/src/label-print-agent/label-print-agent.controller.ts b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts index 3a26d5d..651da48 100644 --- a/paperless-backend/src/label-print-agent/label-print-agent.controller.ts +++ b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts @@ -4,16 +4,20 @@ import { Get, HttpCode, HttpStatus, + MessageEvent, NotFoundException, Param, ParseIntPipe, Post, Query, Res, + Sse, StreamableFile, UseGuards, } from '@nestjs/common'; import type { Response } from 'express'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { JwtOrApiKeyGuard } from '../auth/jwt-or-apikey.guard'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @@ -48,6 +52,14 @@ export class LabelPrintAgentController { return { jobId: String(job.Id) }; } + // Agent: SSE-Stream für neue Druckaufträge + @Sse('events') + sseEvents(): Observable { + return this.service.newJob$.pipe( + map(() => ({ data: { type: 'label-job-available' } } as MessageEvent)), + ); + } + // Agent: nächsten Job abholen (Polling) @Get('jobs/next') async getNextJob(@Query('agentId') agentId: string, @Res({ passthrough: true }) res: Response) { diff --git a/paperless-backend/src/label-print-agent/label-print-agent.service.ts b/paperless-backend/src/label-print-agent/label-print-agent.service.ts index 5f0194b..78481f4 100644 --- a/paperless-backend/src/label-print-agent/label-print-agent.service.ts +++ b/paperless-backend/src/label-print-agent/label-print-agent.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, LessThan, IsNull, Or } from 'typeorm'; +import { Repository, LessThan, IsNull } from 'typeorm'; +import { Subject, Observable } from 'rxjs'; import { LabelPrintJob } from '../database/entities/label-print-job.entity'; import { BarcodeTemplate } from '../database/entities/barcode-template.entity'; import { LabelRendererService } from './label-renderer.service'; @@ -28,6 +29,11 @@ function lockExpiry(): Date { @Injectable() export class LabelPrintAgentService { private readonly logger = new Logger(LabelPrintAgentService.name); + private readonly jobCreated$ = new Subject(); + + get newJob$(): Observable { + return this.jobCreated$.asObservable(); + } constructor( @InjectRepository(LabelPrintJob) @@ -85,7 +91,9 @@ export class LabelPrintAgentService { LockedByAgent: null, }); - return this.jobRepo.save(job); + const saved = await this.jobRepo.save(job); + this.jobCreated$.next(); + return saved; } async claimNextJob(agentId: string): Promise { diff --git a/paperless-frontend/src/pages/InboxPage.tsx b/paperless-frontend/src/pages/InboxPage.tsx index 4ad2b01..af556b9 100644 --- a/paperless-frontend/src/pages/InboxPage.tsx +++ b/paperless-frontend/src/pages/InboxPage.tsx @@ -125,6 +125,15 @@ function DocumentPreviewPopover({ record, children }: { record: InboxFile; child ); } +function buildInitialFieldValues(template: BarcodeTemplate | null): Record { + const today = dayjs().format('YYYY-MM-DD'); + const values: Record = {}; + for (const field of template?.LabelInputFields ?? []) { + if (field.type === 'date') values[field.name] = today; + } + return values; +} + export default function InboxPage() { const navigate = useNavigate(); const [files, setFiles] = useState([]); @@ -137,6 +146,7 @@ export default function InboxPage() { const [selectedTemplate, setSelectedTemplate] = useState(null); const [fieldValues, setFieldValues] = useState>({}); const [printing, setPrinting] = useState(false); + const [labelCount, setLabelCount] = useState(1); const openPrintDialog = async () => { try { @@ -148,21 +158,24 @@ export default function InboxPage() { } setSelectedTemplate(null); setFieldValues({}); + setLabelCount(1); setPrintDialogOpen(true); }; const handleTemplateSelect = (id: number) => { const t = labelTemplates.find((t) => t.Id === id) ?? null; setSelectedTemplate(t); - setFieldValues({}); + setFieldValues(buildInitialFieldValues(t)); }; const handlePrint = async () => { if (!selectedTemplate) return; setPrinting(true); try { - await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues); - message.success('Druckauftrag erstellt'); + for (let i = 0; i < labelCount; i++) { + await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues); + } + message.success(labelCount === 1 ? 'Druckauftrag erstellt' : `${labelCount} Druckaufträge erstellt`); setPrintDialogOpen(false); } catch (err: any) { message.error(err?.response?.data?.message ?? 'Druckauftrag fehlgeschlagen'); @@ -406,6 +419,16 @@ export default function InboxPage() { /> + + setLabelCount(v ?? 1)} + style={{ width: '100%' }} + /> + + {selectedTemplate?.LabelInputFields?.map((field) => ( {field.type === 'date' ? (