import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as fs from 'fs/promises'; import { BarcodeScannerService, type MatchedBarcode, } from '../barcode/barcode-scanner.service'; import { PageCacheService } from '../barcode/page-cache.service'; import { InboxDocument, type InboxSource, } from '../database/entities/inbox-document.entity'; import { MailService } from '../postprocessing/mail.service'; import { buildSegmentBuffer } from '../inbox-postprocessor/edit-applier'; export interface InboxFile { id: string; name: string; source: InboxSource; pageCount: number; deletedPages: number[]; manualSplitPages: number[]; rotations: Record; barcodes: MatchedBarcode[]; createdAt: string; } export interface ResolvedDocument { doc: InboxDocument; pdfPath: string; } @Injectable() export class InboxService { private readonly logger = new Logger(InboxService.name); constructor( private readonly barcodeScanner: BarcodeScannerService, private readonly pageCache: PageCacheService, @InjectRepository(InboxDocument) private readonly documentRepo: Repository, private readonly mailService: MailService, ) {} async listFiles(preferredUsername: string | null): Promise { const where = preferredUsername ? [ { Source: 'all' as InboxSource, IsScanned: true }, { Source: 'user' as InboxSource, OwnerUsername: preferredUsername, IsScanned: true, }, ] : [{ Source: 'all' as InboxSource, IsScanned: true }]; const docs = await this.documentRepo.find({ where, order: { CreatedAt: 'DESC' }, }); const files: InboxFile[] = []; for (const doc of docs) { files.push({ id: doc.Id, name: doc.OriginalName, source: doc.Source, pageCount: doc.PageCount, deletedPages: [...(doc.DeletedPages ?? [])].sort((a, b) => a - b), manualSplitPages: [...(doc.ManualSplitPages ?? [])].sort( (a, b) => a - b, ), rotations: { ...(doc.Rotations ?? {}) }, barcodes: await this.barcodeScanner.getMatched(doc), createdAt: doc.CreatedAt.toISOString(), }); } return files; } async resolveDocument( id: string, preferredUsername: string | null, ): Promise { const doc = await this.documentRepo.findOne({ where: { Id: id } }); if (!doc) throw new NotFoundException('Dokument nicht gefunden'); if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) { throw new NotFoundException('Dokument nicht gefunden'); } const pdfPath = this.pageCache.documentPdfPath(doc.Id); try { const stat = await fs.stat(pdfPath); if (!stat.isFile()) throw new Error('not a file'); } catch (err: any) { this.logger.warn( `Datei fehlt trotz DB-Eintrag (${doc.Id}): ${err.message}`, ); throw new NotFoundException('Dokument nicht gefunden'); } return { doc, pdfPath }; } /** * Markiert eine Seite virtuell zum Löschen. Die PDF und der Page-Cache * bleiben unverändert; die eigentliche Anwendung passiert später bei * der Weiterverarbeitung. */ async deletePage( id: string, page: number, preferredUsername: string | null, ): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) { throw new NotFoundException('Seite nicht gefunden'); } const deleted = new Set(doc.DeletedPages ?? []); if (deleted.has(page)) return; // schon markiert const remaining = doc.PageCount - deleted.size; if (remaining <= 1) { throw new ConflictException('Mindestens eine Seite muss übrig bleiben'); } deleted.add(page); doc.DeletedPages = Array.from(deleted).sort((a, b) => a - b); await this.documentRepo.save(doc); } /** * Setzt eine Seitenrotation virtuell. Wert wird auf 0/90/180/270 * normalisiert; 0 entfernt den Eintrag. */ async setPageRotation( id: string, page: number, rotation: number, preferredUsername: string | null, ): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) { throw new NotFoundException('Seite nicht gefunden'); } const normalized = (((Math.round(rotation / 90) * 90) % 360) + 360) % 360; const next: Record = { ...(doc.Rotations ?? {}) }; if (normalized === 0) { delete next[String(page)]; } else { next[String(page)] = normalized; } doc.Rotations = next; await this.documentRepo.save(doc); } /** * Setzt alle markierten Bearbeitungen (DeletedPages, Rotations, ManualSplitPages) zurück. */ async resetEdits( id: string, preferredUsername: string | null, ): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); let changed = false; if (doc.DeletedPages && doc.DeletedPages.length > 0) { doc.DeletedPages = []; changed = true; } if (doc.Rotations && Object.keys(doc.Rotations).length > 0) { doc.Rotations = {}; changed = true; } if (doc.ManualSplitPages && doc.ManualSplitPages.length > 0) { doc.ManualSplitPages = []; changed = true; } if (changed) await this.documentRepo.save(doc); } /** * Setzt oder entfernt einen manuellen Trennpunkt vor der angegebenen Seite. */ async toggleManualSplit( id: string, page: number, preferredUsername: string | null, ): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); if (!Number.isInteger(page) || page < 2 || page > doc.PageCount) { throw new BadRequestException('Ungültige Seitennummer für Trennung'); } const splits = new Set(doc.ManualSplitPages ?? []); if (splits.has(page)) { splits.delete(page); } else { splits.add(page); } doc.ManualSplitPages = Array.from(splits).sort((a, b) => a - b); await this.documentRepo.save(doc); } async deleteDocument( id: string, preferredUsername: string | null, ): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); const dir = this.pageCache.documentDir(doc.Id); await this.documentRepo.delete(doc.Id); try { await fs.rm(dir, { recursive: true, force: true }); } catch (err: any) { this.logger.warn( `Dokument-Ordner konnte nicht gelöscht werden (${dir}): ${err.message}`, ); } } async resolvePageImage( id: string, page: number, variant: 'preview' | 'thumbnail', preferredUsername: string | null, ): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) { throw new NotFoundException('Seite nicht gefunden'); } const filePath = variant === 'preview' ? this.pageCache.previewPath(doc.Id, page) : this.pageCache.thumbnailPath(doc.Id, page); try { await fs.access(filePath); } catch { throw new NotFoundException('Seite nicht gefunden'); } return filePath; } async updateSource( id: string, source: InboxSource, preferredUsername: string | null, ): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); if (source === 'all') { doc.Source = 'all'; doc.OwnerUsername = null; } else { if (!preferredUsername) { throw new BadRequestException( 'Benutzername erforderlich für persönlichen Scan', ); } doc.Source = 'user'; doc.OwnerUsername = preferredUsername; } await this.documentRepo.save(doc); } async scanRegion( id: string, page: number, x: number, y: number, w: number, h: number, preferredUsername: string | null, ): Promise<{ found: string[] }> { const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername); return this.barcodeScanner.scanRegion(doc, pdfPath, page, x, y, w, h); } async getSegmentPdfBuffer( id: string, preferredUsername: string | null, pages: number[], ): Promise<{ buffer: Buffer; filename: string }> { const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername); const deleted = new Set(doc.DeletedPages ?? []); const safePages = pages.filter( (p) => p >= 1 && p <= doc.PageCount && !deleted.has(p), ); const buffer = await buildSegmentBuffer(doc, pdfPath, safePages); return { buffer, filename: doc.OriginalName }; } async sendAsEmail( id: string, preferredUsername: string | null, opts: { to: string; subject: string; body: string; html?: string; segments: { pages: number[]; filename: string }[]; smtpOverride?: { host: string; port: number; secure: boolean; user: string; pass: string; from: string; }; }, ): Promise { const { doc, pdfPath } = await this.resolveDocument(id, preferredUsername); const deleted = new Set(doc.DeletedPages ?? []); const attachments = await Promise.all( opts.segments.map(async (seg) => { const safePages = seg.pages.filter( (p) => p >= 1 && p <= doc.PageCount && !deleted.has(p), ); const content = await buildSegmentBuffer(doc, pdfPath, safePages); const filename = seg.filename.endsWith('.pdf') ? seg.filename : `${seg.filename}.pdf`; return { filename, content }; }), ); await this.mailService.sendMail({ to: opts.to, subject: opts.subject, body: opts.body, html: opts.html, attachments, smtpOverride: opts.smtpOverride, }); } }