refactor: Paperless-Dokumentrouten unter /api/paperless/documents/:id

„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 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-11-04 10:46:57 +01:00
1 parent 04fca933ee
commit e0a6598b35
9 files changed
+138 -53

No files matched your search

@@ -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',
});
});
});
@@ -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,
};
}
@@ -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<any> {
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() {
+1 -1
View File
@@ -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<Blob>(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data),
api.get<Blob>(`/api/paperless/documents/${id}/pdf`, { responseType: 'blob' }).then(r => r.data),
pruefeBelegnummer: (belegnummer: string) =>
api.get<BelegnummerPruefung>('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data),
};
+2 -2
View File
@@ -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<Kontonummer[]>(`/api/kontonummern/FromCorrespondent/${correspondentId}`).then(r => r.data),
@@ -73,5 +73,5 @@ export const posteingangApi = {
api.post<Kontonummer>('/api/kontonummern', data).then(r => r.data),
getErechnung: (id: number) =>
api.get<ERechnungVorschlag>(`/api/paperless/inbox/${id}/erechnung`).then(r => r.data),
api.get<ERechnungVorschlag>(`/api/paperless/documents/${id}/erechnung`).then(r => r.data),
};
@@ -634,7 +634,7 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
icon={<EyeOutlined />}
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={<ExportOutlined />}
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
</Button>
)}
<AuthIframe
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${documentId}/pdf#toolbar=0`}
style={{ width: '100%', height: isMobile ? '50vh' : '100%' }}
title="PDF Preview"
/>
@@ -90,7 +90,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
<List.Item.Meta
avatar={
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${doc.id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${doc.id}/preview`}
width={80}
height={110}
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }}
@@ -75,7 +75,7 @@ export default function ManuellBearbeitenPage() {
const popoverContent = (id: number) => (
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${id}/preview`}
width={400}
/>
);
@@ -108,7 +108,7 @@ export default function ManuellBearbeitenPage() {
render: (_: any, record: PosteingangDocument) => (
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={100}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
/>
@@ -172,7 +172,7 @@ export default function ManuellBearbeitenPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 12 }}>
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={72}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
/>
@@ -62,7 +62,7 @@ export default function PosteingangPage() {
const popoverContent = (id: number) => (
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${id}/preview`}
width={400}
/>
);
@@ -80,7 +80,7 @@ export default function PosteingangPage() {
render: (_: any, record: PosteingangDocument) => (
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={100}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
/>
@@ -136,7 +136,7 @@ export default function PosteingangPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 12 }}>
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={72}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
/>