From e0a6598b35738b7b852bc59b070f4c537698d06e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Wed, 4 Nov 2026 09:42:57 +0100 Subject: [PATCH] refactor: Paperless-Dokumentrouten unter /api/paperless/documents/:id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit „inbox“ war doppelt belegt (/api/inbox = Scanner, /api/paperless/inbox = Paperless-Posteingang) und passte nicht zur Manuell-Seite, die dieselben Routen nutzt. Umbenannt (ohne Alias, nur das eigene Frontend nutzt sie): PUT paperless/inbox/:id → PUT paperless/documents/:id GET paperless/inbox/pdf/:id → GET paperless/documents/:id/pdf GET paperless/inbox/preview/:id → GET paperless/documents/:id/preview GET paperless/inbox/:id/erechnung → GET paperless/documents/:id/erechnung Dabei: - Dokumentsuche (GET documents) wieder für den Posteingang (VIEW_INBOX) statt nur Admin – die Verknüpfungssuche im Bearbeiten-Dialog nutzt sie; die Einschränkung aus dem vorigen Commit hatte sie gebrochen. - Suche und GET documents/:id liefern nur noch id, Titel, ASN, Datum (kein OCR-Volltext, keine Statusfelder). - documents/:id/pdf auch für VIEW_SCANNER (Duplikatvergleich im Scanner). - E-Rechnungs-Vorschlag prüft wie das Speichern pro Dokument (Tag 1 bzw. Manuell-Tag); Vorschau/PDF bewusst nicht (Suchergebnisse, Duplikate). Co-Authored-By: Claude Opus 5.5 --- .../paperless/dokument-zugriff.util.spec.ts | 22 ++++ .../src/paperless/dokument-zugriff.util.ts | 26 ++++ .../src/paperless/paperless.controller.ts | 117 ++++++++++++------ paperless-frontend/src/api/paperless.ts | 2 +- paperless-frontend/src/api/posteingang.ts | 4 +- .../src/components/DocumentEditModal.tsx | 6 +- .../src/components/DocumentSearchModal.tsx | 2 +- .../src/pages/ManuellBearbeitenPage.tsx | 6 +- .../src/pages/PosteingangPage.tsx | 6 +- 9 files changed, 138 insertions(+), 53 deletions(-) diff --git a/paperless-backend/src/paperless/dokument-zugriff.util.spec.ts b/paperless-backend/src/paperless/dokument-zugriff.util.spec.ts index 7edffc7..225d9ac 100644 --- a/paperless-backend/src/paperless/dokument-zugriff.util.spec.ts +++ b/paperless-backend/src/paperless/dokument-zugriff.util.spec.ts @@ -2,6 +2,7 @@ import { Permission } from '../auth/permissions.enum'; import { POSTEINGANG_TAG_ID, darfDokumentBearbeiten, + dokumentKurzinfo, } from './dokument-zugriff.util'; const MANUELL = 6; @@ -43,3 +44,24 @@ describe('darfDokumentBearbeiten', () => { ).toBe(false); }); }); + +describe('dokumentKurzinfo', () => { + it('liefert nur Kennung, Titel, ASN und Datum – keinen OCR-Volltext', () => { + const doc = { + id: 5, + title: 'Rechnung', + archive_serial_number: 42, + created: '2026-01-02', + content: 'IBAN DE00 …', + owner: 3, + custom_fields: [{ field: 15, value: 'x' }], + notes: [{ note: 'intern' }], + }; + expect(dokumentKurzinfo(doc)).toEqual({ + id: 5, + title: 'Rechnung', + archive_serial_number: 42, + created: '2026-01-02', + }); + }); +}); diff --git a/paperless-backend/src/paperless/dokument-zugriff.util.ts b/paperless-backend/src/paperless/dokument-zugriff.util.ts index 5a43eac..61bb758 100644 --- a/paperless-backend/src/paperless/dokument-zugriff.util.ts +++ b/paperless-backend/src/paperless/dokument-zugriff.util.ts @@ -23,3 +23,29 @@ export function darfDokumentBearbeiten( permissions.includes(Permission.PROCESS_MANUALLY)) ); } + +export interface DokumentKurzinfo { + id: number; + title: string; + archive_serial_number: number | null; + created: string; +} + +/** + * Für Suche und Verknüpfungsanzeige genügen Kennung, Titel, ASN und Datum. + * Das vollständige Paperless-Dokument enthält u.a. den OCR-Volltext und + * Statusfelder – die sollen über diese Routen nicht abrufbar sein. + */ +export function dokumentKurzinfo(doc: { + id: number; + title: string; + archive_serial_number?: number | null; + created: string; +}): DokumentKurzinfo { + return { + id: doc.id, + title: doc.title, + archive_serial_number: doc.archive_serial_number ?? null, + created: doc.created, + }; +} diff --git a/paperless-backend/src/paperless/paperless.controller.ts b/paperless-backend/src/paperless/paperless.controller.ts index c900118..89ecd51 100644 --- a/paperless-backend/src/paperless/paperless.controller.ts +++ b/paperless-backend/src/paperless/paperless.controller.ts @@ -18,12 +18,16 @@ import { Query, Request, ForbiddenException, + ParseIntPipe, } from '@nestjs/common'; import type { Response } from 'express'; import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; import { entferneGeschuetzteStatusfelder } from './status-felder.util'; -import { darfDokumentBearbeiten } from './dokument-zugriff.util'; +import { + darfDokumentBearbeiten, + dokumentKurzinfo, +} from './dokument-zugriff.util'; import { FileInterceptor } from '@nestjs/platform-express'; import { InjectRepository } from '@nestjs/typeorm'; @@ -42,6 +46,15 @@ import { ErechnungService } from '../erechnung/erechnung.service'; /** Setting.Tag-Schlüssel für die manuell gepflegte Steuertag-Liste */ export const STEUERTAG_SETTING_KEY = 'steuertag_ids'; +/** Von JwtStrategy gesetzt; die globalen Guards lassen nur Angemeldete durch. */ +interface BenutzerRequest { + user: { + userId: string; + permissions?: Permission[]; + preferredUsername?: string; + }; +} + @Controller('api/paperless') export class PaperlessController { private readonly logger = new Logger(PaperlessController.name); @@ -94,8 +107,9 @@ export class PaperlessController { return this.paperlessService.pruefeBelegnummer(belegnummer ?? ''); } + /** Dokumentsuche für Verknüpfungsfelder im Bearbeiten-Dialog. */ @Get('documents') - @RequirePermissions(Permission.MANAGE_SETTINGS) + @RequirePermissions(Permission.VIEW_INBOX) async getDocuments( @Query('search') search?: string, @Query('page') page?: string, @@ -108,13 +122,18 @@ export class PaperlessController { if (search) { params.query = search; // Global search in Paperless } - return this.paperlessService.getDocuments(params); + const ergebnis = await this.paperlessService.getDocuments(params); + return { + count: ergebnis.count, + results: (ergebnis.results ?? []).map(dokumentKurzinfo), + }; } + /** Titel verknüpfter Dokumente – bewusst ohne Prüfung pro Dokument. */ @Get('documents/:id') - @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) - async getDocument(@Param('id') id: string) { - return this.paperlessService.getDocument(parseInt(id, 10)); + @RequirePermissions(Permission.VIEW_INBOX) + async getDocument(@Param('id', ParseIntPipe) id: number) { + return dokumentKurzinfo(await this.paperlessService.getDocument(id)); } @Get('tags') @@ -226,9 +245,13 @@ export class PaperlessController { })); } + /** + * Vorschaubild – auch für Suchergebnisse außerhalb des Posteingangs, + * deshalb ohne Prüfung pro Dokument. + */ + @Get('documents/:id/preview') @RequirePermissions(Permission.VIEW_INBOX) - @Get('inbox/preview/:id') - async getInboxPreview(@Param('id') id: string, @Res() res: Response) { + async getDocumentPreview(@Param('id') id: string, @Res() res: Response) { try { const stream = await this.paperlessService.getDocumentPreviewStream( parseInt(id, 10), @@ -252,9 +275,14 @@ export class PaperlessController { } } - @RequirePermissions(Permission.VIEW_INBOX) - @Get('inbox/pdf/:id') - async getInboxPdf(@Param('id') id: string, @Res() res: Response) { + /** + * Original-PDF – für den Bearbeiten-Dialog und den Duplikatvergleich im + * Scanner (dort ein beliebiges vorhandenes Dokument), deshalb ohne Prüfung + * pro Dokument. + */ + @Get('documents/:id/pdf') + @RequirePermissions(Permission.VIEW_INBOX, Permission.VIEW_SCANNER) + async getDocumentPdf(@Param('id') id: string, @Res() res: Response) { try { const stream = await this.paperlessService.getDocumentPdfStream( parseInt(id, 10), @@ -362,22 +390,22 @@ export class PaperlessController { * Mandant und Belegart vor. Liefert `erechnung: false`, wenn das Dokument * keine auswertbare E-Rechnung enthält. */ - @Get('inbox/:id/erechnung') + @Get('documents/:id/erechnung') @RequirePermissions(Permission.VIEW_INBOX) - async getInboxErechnung(@Param('id') id: string, @Request() req: any) { - return this.erechnungService.analysiereDokument( - parseInt(id, 10), - req.user?.userId, - ); + async getDocumentErechnung( + @Param('id', ParseIntPipe) id: number, + @Request() req: BenutzerRequest, + ) { + await this.ladeBearbeitbaresDokument(id, req); + return this.erechnungService.analysiereDokument(id, req.user.userId); } - @Put('inbox/:id') + @Put('documents/:id') @RequirePermissions(Permission.VIEW_INBOX) - async putInboxDocument( + async putDocument( @Param('id') id: string, @Body() body: any, - @Request() - req: { user?: { permissions?: Permission[]; preferredUsername?: string } }, + @Request() req: BenutzerRequest, ) { const documentId = parseInt(id, 10); @@ -391,25 +419,7 @@ export class PaperlessController { ); } body.customFields = felder; - // Fetch from paperless - const oldDocument = await this.paperlessService.getDocument(documentId); - - // Nur Dokumente aus dem eigenen Arbeitsbereich (Posteingang bzw. Manuell) - // – sonst ließen sich Mandant/Felder beliebiger Dokumente ändern. - if ( - !darfDokumentBearbeiten( - oldDocument.tags as number[] | undefined, - req.user?.permissions ?? [], - this.paperlessService.getManuellTagId(), - ) - ) { - this.logger.warn( - `Dokument ${documentId}: Bearbeitung abgelehnt – nicht im Posteingang/Manuell-Bereich des Benutzers (${req.user?.preferredUsername ?? 'unbekannt'})`, - ); - throw new ForbiddenException( - 'Dieses Dokument liegt nicht in Ihrem Posteingang bzw. Ihrer Bearbeitungsliste', - ); - } + const oldDocument = await this.ladeBearbeitbaresDokument(documentId, req); oldDocument.document_type = body.documentType ?? oldDocument.document_type; oldDocument.owner = body.mandant ?? oldDocument.owner; @@ -574,6 +584,33 @@ export class PaperlessController { return { success: true }; } + /** + * Lädt ein Dokument, das der Benutzer bearbeiten darf: Posteingang-Rolle nur + * mit Posteingang-Tag, Buchhaltung zusätzlich mit Manuell-Tag. Das Backend + * arbeitet mit dem Service-Token, Paperless prüft hier also nichts. + */ + private async ladeBearbeitbaresDokument( + documentId: number, + req: BenutzerRequest, + ): Promise { + const dokument = await this.paperlessService.getDocument(documentId); + if ( + !darfDokumentBearbeiten( + dokument.tags as number[] | undefined, + req.user?.permissions ?? [], + this.paperlessService.getManuellTagId(), + ) + ) { + this.logger.warn( + `Dokument ${documentId}: Zugriff abgelehnt – nicht im Posteingang/Manuell-Bereich des Benutzers (${req.user?.preferredUsername ?? 'unbekannt'})`, + ); + throw new ForbiddenException( + 'Dieses Dokument liegt nicht in Ihrem Posteingang bzw. Ihrer Bearbeitungsliste', + ); + } + return dokument; + } + @Get('tasks') @RequirePermissions(Permission.MANAGE_SETTINGS) async getAllTasks() { diff --git a/paperless-frontend/src/api/paperless.ts b/paperless-frontend/src/api/paperless.ts index e4c8f3f..11821eb 100644 --- a/paperless-frontend/src/api/paperless.ts +++ b/paperless-frontend/src/api/paperless.ts @@ -81,7 +81,7 @@ export const paperlessApi = { checksumExists: (checksum: string) => api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists), getDocumentPdfBlob: (id: number) => - api.get(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data), + api.get(`/api/paperless/documents/${id}/pdf`, { responseType: 'blob' }).then(r => r.data), pruefeBelegnummer: (belegnummer: string) => api.get('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data), }; diff --git a/paperless-frontend/src/api/posteingang.ts b/paperless-frontend/src/api/posteingang.ts index ebc6634..e734df7 100644 --- a/paperless-frontend/src/api/posteingang.ts +++ b/paperless-frontend/src/api/posteingang.ts @@ -64,7 +64,7 @@ export const posteingangApi = { .then((r) => r.data), updateDocument: (id: number, data: any) => - api.put(`/api/paperless/inbox/${id}`, data).then((r) => r.data), + api.put(`/api/paperless/documents/${id}`, data).then((r) => r.data), getKontonummern: (correspondentId: number) => api.get(`/api/kontonummern/FromCorrespondent/${correspondentId}`).then(r => r.data), @@ -73,5 +73,5 @@ export const posteingangApi = { api.post('/api/kontonummern', data).then(r => r.data), getErechnung: (id: number) => - api.get(`/api/paperless/inbox/${id}/erechnung`).then(r => r.data), + api.get(`/api/paperless/documents/${id}/erechnung`).then(r => r.data), }; diff --git a/paperless-frontend/src/components/DocumentEditModal.tsx b/paperless-frontend/src/components/DocumentEditModal.tsx index c7467e1..f5b4f48 100644 --- a/paperless-frontend/src/components/DocumentEditModal.tsx +++ b/paperless-frontend/src/components/DocumentEditModal.tsx @@ -634,7 +634,7 @@ export default function DocumentEditModal({ documentId, document, open, onClose, icon={} onClick={(e) => { e.stopPropagation(); - openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${currentId}`); + openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/documents/${currentId}/pdf`); }} /> )} @@ -679,13 +679,13 @@ export default function DocumentEditModal({ documentId, document, open, onClose, icon={} block style={{ marginBottom: 8 }} - onClick={() => openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}`)} + onClick={() => openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/documents/${documentId}/pdf`)} > PDF in neuem Tab öffnen )} diff --git a/paperless-frontend/src/components/DocumentSearchModal.tsx b/paperless-frontend/src/components/DocumentSearchModal.tsx index 57d804e..1d4c795 100644 --- a/paperless-frontend/src/components/DocumentSearchModal.tsx +++ b/paperless-frontend/src/components/DocumentSearchModal.tsx @@ -90,7 +90,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props) ( ); @@ -108,7 +108,7 @@ export default function ManuellBearbeitenPage() { render: (_: any, record: PosteingangDocument) => ( @@ -172,7 +172,7 @@ export default function ManuellBearbeitenPage() {
diff --git a/paperless-frontend/src/pages/PosteingangPage.tsx b/paperless-frontend/src/pages/PosteingangPage.tsx index 4188ee1..7a3171c 100644 --- a/paperless-frontend/src/pages/PosteingangPage.tsx +++ b/paperless-frontend/src/pages/PosteingangPage.tsx @@ -62,7 +62,7 @@ export default function PosteingangPage() { const popoverContent = (id: number) => ( ); @@ -80,7 +80,7 @@ export default function PosteingangPage() { render: (_: any, record: PosteingangDocument) => ( @@ -136,7 +136,7 @@ export default function PosteingangPage() {