import { Router, Response } from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import prisma from '../lib/prisma.js'; import { authenticate, requirePermission } from '../middleware/auth.js'; import { AuthRequest } from '../types/index.js'; import { logChange } from '../services/audit.service.js'; import { canAccessContract } from '../utils/accessControl.js'; const router = Router(); // Uploads-Verzeichnis erstellen falls nicht vorhanden const uploadsDir = path.join(process.cwd(), 'uploads'); if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } // Multer-Konfiguration const storage = multer.diskStorage({ destination: (req, file, cb) => { const subDir = (req as any).uploadSubDir || 'misc'; const targetDir = path.join(uploadsDir, subDir); if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } cb(null, targetDir); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const ext = path.extname(file.originalname); cb(null, `${uniqueSuffix}${ext}`); }, }); const fileFilter = ( req: Express.Request, file: Express.Multer.File, cb: multer.FileFilterCallback ) => { // PDFs + gängige Web-Bildformate. WebP + GIF nachgezogen 2026-05-30 // (Pentest INFO: WebP/GIF lieferten 500 statt sauberem 4xx, weil // erlaubter MIME-Type fehlte und der fileFilter dann throwte). const allowedTypes = [ 'application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', ]; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Nur PDF, JPG, PNG, GIF und WebP-Dateien sind erlaubt')); } }; const upload = multer({ storage, fileFilter, limits: { fileSize: 10 * 1024 * 1024, // 10MB max }, }); // Middleware um Subdirectory zu setzen function setUploadDir(subDir: string) { return (req: AuthRequest, res: Response, next: Function) => { (req as any).uploadSubDir = subDir; next(); }; } /** * Post-Upload-Validierung: prüft die Magic-Bytes der gerade geschriebenen * Datei und vergleicht mit dem fileFilter-Whitelist. Bei Mismatch * (Pentest 2026-05-30 LOW 39.3: WebP/GIF/JPG/PDF-Spoofing) wird die * Datei sofort gelöscht + 415 zurück. * * Zusätzlich (39.4): die Datei wird auf eine kanonische Endung umbenannt, * die aus dem ERKANNTEN Typ abgeleitet ist – nicht aus dem * client-gemeldeten file.originalname. Damit verschwindet die * `evil.gif.php`-Doppel-Endung; gespeicherter Name ist * `.` (z.B. `.pdf` / `.png`). */ const PDF_MAGIC = Buffer.from('%PDF-', 'latin1'); const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const GIF87 = Buffer.from('GIF87a', 'latin1'); const GIF89 = Buffer.from('GIF89a', 'latin1'); function detectType(buf: Buffer): { mime: string; ext: string } | null { if (buf.length >= 5 && buf.subarray(0, 5).equals(PDF_MAGIC)) return { mime: 'application/pdf', ext: '.pdf' }; if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return { mime: 'image/png', ext: '.png' }; if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { mime: 'image/jpeg', ext: '.jpg' }; if (buf.length >= 6 && (buf.subarray(0, 6).equals(GIF87) || buf.subarray(0, 6).equals(GIF89))) return { mime: 'image/gif', ext: '.gif' }; if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') return { mime: 'image/webp', ext: '.webp' }; return null; } function validateUploadedFile(req: AuthRequest, res: Response, next: Function) { if (!req.file) return next(); try { const fd = fs.openSync(req.file.path, 'r'); const head = Buffer.alloc(12); fs.readSync(fd, head, 0, 12, 0); fs.closeSync(fd); const detected = detectType(head); if (!detected) { try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } res.status(415).json({ success: false, error: 'Datei-Inhalt entspricht keinem zulässigen Typ (PDF, JPG, PNG, GIF, WebP).', }); return; } // Filename auf kanonische Extension normalisieren. Multer hat // `.gif.php` o.ä. geschrieben – wir wollen `.gif`. const dir = path.dirname(req.file.path); const base = path.basename(req.file.path).replace(/\.[^./]+(\.[^./]+)*$/, ''); const newName = base + detected.ext; const newPath = path.join(dir, newName); if (newPath !== req.file.path) { try { fs.renameSync(req.file.path, newPath); req.file.path = newPath; req.file.filename = newName; } catch (e) { // Rename hat seltene Edge-Cases (Cross-Device). Sicherheit // geht vor – sollte das fehlschlagen, werfen wir lieber 500 // und putzen die alte Datei. try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } console.error('Upload-Rename fehlgeschlagen:', e); res.status(500).json({ success: false, error: 'Upload konnte nicht abgeschlossen werden' }); return; } } // Mimetype mit dem ERKANNTEN überschreiben, damit die Controller // den korrekten Typ persistieren (falls sie ihn weiterreichen). req.file.mimetype = detected.mime; next(); } catch (e) { console.error('Magic-Byte-Check fehlgeschlagen:', e); try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } res.status(500).json({ success: false, error: 'Upload konnte nicht geprüft werden' }); } } // Upload für Bankkarten-Dokumente router.post( '/bank-cards/:id', authenticate, requirePermission('customers:update'), setUploadDir('bank-cards'), upload.single('document'), validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) { res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' }); return; } const bankCardId = parseInt(req.params.id); const relativePath = `/uploads/bank-cards/${req.file.filename}`; // Bankkarte in der DB aktualisieren await prisma.bankCard.update({ where: { id: bankCardId }, data: { documentPath: relativePath }, }); res.json({ success: true, data: { path: relativePath, filename: req.file.filename, originalName: req.file.originalname, size: req.file.size, }, }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' }); } } ); // Upload für Ausweis-Dokumente router.post( '/documents/:id', authenticate, requirePermission('customers:update'), setUploadDir('documents'), upload.single('document'), validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) { res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' }); return; } const documentId = parseInt(req.params.id); const relativePath = `/uploads/documents/${req.file.filename}`; // Ausweis in der DB aktualisieren await prisma.identityDocument.update({ where: { id: documentId }, data: { documentPath: relativePath }, }); res.json({ success: true, data: { path: relativePath, filename: req.file.filename, originalName: req.file.originalname, size: req.file.size, }, }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' }); } } ); // Löschen von Bankkarten-Dokumenten router.delete( '/bank-cards/:id', authenticate, requirePermission('customers:update'), async (req: AuthRequest, res: Response) => { try { const bankCardId = parseInt(req.params.id); // Bankkarte aus DB holen um Dateipfad zu bekommen const bankCard = await prisma.bankCard.findUnique({ where: { id: bankCardId }, }); if (!bankCard) { res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' }); return; } if (!bankCard.documentPath) { res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' }); return; } // Datei von Festplatte löschen const filePath = path.join(process.cwd(), bankCard.documentPath); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } // documentPath in DB auf null setzen await prisma.bankCard.update({ where: { id: bankCardId }, data: { documentPath: null }, }); res.json({ success: true }); } catch (error) { console.error('Delete error:', error); res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' }); } } ); // Löschen von Ausweis-Dokumenten router.delete( '/documents/:id', authenticate, requirePermission('customers:update'), async (req: AuthRequest, res: Response) => { try { const documentId = parseInt(req.params.id); // Ausweis aus DB holen um Dateipfad zu bekommen const document = await prisma.identityDocument.findUnique({ where: { id: documentId }, }); if (!document) { res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' }); return; } if (!document.documentPath) { res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' }); return; } // Datei von Festplatte löschen const filePath = path.join(process.cwd(), document.documentPath); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } // documentPath in DB auf null setzen await prisma.identityDocument.update({ where: { id: documentId }, data: { documentPath: null }, }); res.json({ success: true }); } catch (error) { console.error('Delete error:', error); res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' }); } } ); // ==================== FIRMEN-DOKUMENTE ==================== // Upload für Gewerbeanmeldung router.post( '/customers/:id/business-registration', authenticate, requirePermission('customers:update'), setUploadDir('business-registrations'), upload.single('document'), validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) { res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' }); return; } const customerId = parseInt(req.params.id); const relativePath = `/uploads/business-registrations/${req.file.filename}`; // Alte Datei löschen falls vorhanden const customer = await prisma.customer.findUnique({ where: { id: customerId } }); if (customer?.businessRegistrationPath) { const oldPath = path.join(process.cwd(), customer.businessRegistrationPath); if (fs.existsSync(oldPath)) { fs.unlinkSync(oldPath); } } // Kunde in der DB aktualisieren await prisma.customer.update({ where: { id: customerId }, data: { businessRegistrationPath: relativePath }, }); res.json({ success: true, data: { path: relativePath, filename: req.file.filename, originalName: req.file.originalname, size: req.file.size, }, }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' }); } } ); // Upload für Handelsregisterauszug router.post( '/customers/:id/commercial-register', authenticate, requirePermission('customers:update'), setUploadDir('commercial-registers'), upload.single('document'), validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) { res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' }); return; } const customerId = parseInt(req.params.id); const relativePath = `/uploads/commercial-registers/${req.file.filename}`; // Alte Datei löschen falls vorhanden const customer = await prisma.customer.findUnique({ where: { id: customerId } }); if (customer?.commercialRegisterPath) { const oldPath = path.join(process.cwd(), customer.commercialRegisterPath); if (fs.existsSync(oldPath)) { fs.unlinkSync(oldPath); } } // Kunde in der DB aktualisieren await prisma.customer.update({ where: { id: customerId }, data: { commercialRegisterPath: relativePath }, }); res.json({ success: true, data: { path: relativePath, filename: req.file.filename, originalName: req.file.originalname, size: req.file.size, }, }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' }); } } ); // Löschen der Gewerbeanmeldung router.delete( '/customers/:id/business-registration', authenticate, requirePermission('customers:update'), async (req: AuthRequest, res: Response) => { try { const customerId = parseInt(req.params.id); const customer = await prisma.customer.findUnique({ where: { id: customerId } }); if (!customer) { res.status(404).json({ success: false, error: 'Kunde nicht gefunden' }); return; } if (!customer.businessRegistrationPath) { res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' }); return; } // Datei löschen const filePath = path.join(process.cwd(), customer.businessRegistrationPath); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } // Pfad in DB auf null setzen await prisma.customer.update({ where: { id: customerId }, data: { businessRegistrationPath: null }, }); res.json({ success: true }); } catch (error) { console.error('Delete error:', error); res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' }); } } ); // Löschen des Handelsregisterauszugs router.delete( '/customers/:id/commercial-register', authenticate, requirePermission('customers:update'), async (req: AuthRequest, res: Response) => { try { const customerId = parseInt(req.params.id); const customer = await prisma.customer.findUnique({ where: { id: customerId } }); if (!customer) { res.status(404).json({ success: false, error: 'Kunde nicht gefunden' }); return; } if (!customer.commercialRegisterPath) { res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' }); return; } // Datei löschen const filePath = path.join(process.cwd(), customer.commercialRegisterPath); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } // Pfad in DB auf null setzen await prisma.customer.update({ where: { id: customerId }, data: { commercialRegisterPath: null }, }); res.json({ success: true }); } catch (error) { console.error('Delete error:', error); res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' }); } } ); // ==================== DATENSCHUTZERKLÄRUNG (für alle Kunden) ==================== // Upload für Datenschutzerklärung router.post( '/customers/:id/privacy-policy', authenticate, requirePermission('customers:update'), setUploadDir('privacy-policies'), upload.single('document'), validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) { res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' }); return; } const customerId = parseInt(req.params.id); const relativePath = `/uploads/privacy-policies/${req.file.filename}`; // Alte Datei löschen falls vorhanden const customer = await prisma.customer.findUnique({ where: { id: customerId } }); if (customer?.privacyPolicyPath) { const oldPath = path.join(process.cwd(), customer.privacyPolicyPath); if (fs.existsSync(oldPath)) { fs.unlinkSync(oldPath); } } // Kunde in der DB aktualisieren await prisma.customer.update({ where: { id: customerId }, data: { privacyPolicyPath: relativePath }, }); // Alle Consents auf GRANTED setzen (PDF = vollständige Einwilligung) const consentTypes = ['DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER'] as const; for (const consentType of consentTypes) { await prisma.customerConsent.upsert({ where: { customerId_consentType: { customerId, consentType } }, update: { status: 'GRANTED', grantedAt: new Date(), source: 'papier' }, create: { customerId, consentType, status: 'GRANTED', grantedAt: new Date(), source: 'papier', createdBy: (req as any).user?.email || 'admin' }, }); } // Audit const cust = await prisma.customer.findUnique({ where: { id: customerId }, select: { firstName: true, lastName: true } }); await logChange({ req, action: 'CREATE', resourceType: 'CustomerConsent', label: `Datenschutzerklärung-PDF hochgeladen für ${cust?.firstName} ${cust?.lastName} – alle Einwilligungen erteilt`, details: { aktion: 'PDF hochgeladen', einwilligungen: 'alle erteilt', quelle: 'papier' }, customerId, }); res.json({ success: true, data: { path: relativePath, filename: req.file.filename, originalName: req.file.originalname, size: req.file.size, }, }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' }); } } ); // Löschen der Datenschutzerklärung router.delete( '/customers/:id/privacy-policy', authenticate, requirePermission('customers:update'), async (req: AuthRequest, res: Response) => { try { const customerId = parseInt(req.params.id); const customer = await prisma.customer.findUnique({ where: { id: customerId } }); if (!customer) { res.status(404).json({ success: false, error: 'Kunde nicht gefunden' }); return; } if (!customer.privacyPolicyPath) { res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' }); return; } // Datei löschen const filePath = path.join(process.cwd(), customer.privacyPolicyPath); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } // Pfad in DB auf null setzen await prisma.customer.update({ where: { id: customerId }, data: { privacyPolicyPath: null }, }); // Nur Consents widerrufen die per Papier erteilt wurden await prisma.customerConsent.updateMany({ where: { customerId, status: 'GRANTED', source: 'papier' }, data: { status: 'WITHDRAWN', withdrawnAt: new Date() }, }); // Audit const cust = await prisma.customer.findUnique({ where: { id: customerId }, select: { firstName: true, lastName: true } }); await logChange({ req, action: 'DELETE', resourceType: 'CustomerConsent', label: `Datenschutzerklärung-PDF gelöscht für ${cust?.firstName} ${cust?.lastName} – Papier-Einwilligungen widerrufen`, details: { aktion: 'PDF gelöscht', einwilligungen: 'papier-basierte widerrufen' }, customerId, }); res.json({ success: true }); } catch (error) { console.error('Delete error:', error); res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' }); } } ); // ==================== VERTRAGS-DOKUMENTE ==================== // Generische Funktion für Vertrags-Dokument Upload async function handleContractDocumentUpload( req: AuthRequest, res: Response, fieldName: 'cancellationLetterPath' | 'cancellationConfirmationPath' | 'cancellationLetterOptionsPath' | 'cancellationConfirmationOptionsPath', subDir: string ) { try { if (!req.file) { res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' }); return; } const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const relativePath = `/uploads/${subDir}/${req.file.filename}`; // Alte Datei löschen falls vorhanden const contract = await prisma.contract.findUnique({ where: { id: contractId } }); if (!contract) { res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' }); return; } const oldPath = contract[fieldName]; if (oldPath) { const fullPath = path.join(process.cwd(), oldPath); if (fs.existsSync(fullPath)) { fs.unlinkSync(fullPath); } } // Bei Kündigungsbestätigung(s-Optionen): optionales Datum aus multipart // übernehmen. Ohne Angabe: falls Feld noch leer → heute, sonst nicht anfassen. const updateData: Record = { [fieldName]: relativePath }; if (fieldName === 'cancellationConfirmationPath' || fieldName === 'cancellationConfirmationOptionsPath') { const dateField = fieldName === 'cancellationConfirmationPath' ? 'cancellationConfirmationDate' : 'cancellationConfirmationOptionsDate'; const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null; let target: Date | null = null; if (provided) { const parsed = new Date(provided); if (!isNaN(parsed.getTime())) target = parsed; } if (target) { updateData[dateField] = target; } else if (!contract[dateField]) { updateData[dateField] = new Date(); } } // Vertrag in der DB aktualisieren await prisma.contract.update({ where: { id: contractId }, data: updateData, }); // Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und // der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log. // "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen. if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') { await prisma.contract.update({ where: { id: contractId }, data: { status: 'CANCELLED' }, }); await logChange({ req, action: 'UPDATE', resourceType: 'Contract', resourceId: contractId.toString(), label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`, details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' }, customerId: contract.customerId, }); } res.json({ success: true, data: { path: relativePath, filename: req.file.filename, originalName: req.file.originalname, size: req.file.size, }, }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' }); } } // Generische Funktion für Vertrags-Dokument Löschen async function handleContractDocumentDelete( req: AuthRequest, res: Response, fieldName: 'cancellationLetterPath' | 'cancellationConfirmationPath' | 'cancellationLetterOptionsPath' | 'cancellationConfirmationOptionsPath' ) { try { const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const contract = await prisma.contract.findUnique({ where: { id: contractId } }); if (!contract) { res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' }); return; } const documentPath = contract[fieldName]; if (!documentPath) { res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' }); return; } // Datei löschen const filePath = path.join(process.cwd(), documentPath); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } // Pfad in DB auf null setzen await prisma.contract.update({ where: { id: contractId }, data: { [fieldName]: null }, }); res.json({ success: true }); } catch (error) { console.error('Delete error:', error); res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' }); } } // Kündigungsschreiben router.post( '/contracts/:id/cancellation-letter', authenticate, requirePermission('contracts:update'), setUploadDir('cancellation-letters'), upload.single('document'), validateUploadedFile, (req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterPath', 'cancellation-letters') ); router.delete( '/contracts/:id/cancellation-letter', authenticate, requirePermission('contracts:update'), (req: AuthRequest, res: Response) => handleContractDocumentDelete(req, res, 'cancellationLetterPath') ); // Kündigungsbestätigung router.post( '/contracts/:id/cancellation-confirmation', authenticate, requirePermission('contracts:update'), setUploadDir('cancellation-confirmations'), upload.single('document'), validateUploadedFile, (req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationPath', 'cancellation-confirmations') ); router.delete( '/contracts/:id/cancellation-confirmation', authenticate, requirePermission('contracts:update'), (req: AuthRequest, res: Response) => handleContractDocumentDelete(req, res, 'cancellationConfirmationPath') ); // Kündigungsschreiben Optionen router.post( '/contracts/:id/cancellation-letter-options', authenticate, requirePermission('contracts:update'), setUploadDir('cancellation-letters-options'), upload.single('document'), validateUploadedFile, (req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterOptionsPath', 'cancellation-letters-options') ); router.delete( '/contracts/:id/cancellation-letter-options', authenticate, requirePermission('contracts:update'), (req: AuthRequest, res: Response) => handleContractDocumentDelete(req, res, 'cancellationLetterOptionsPath') ); // Kündigungsbestätigung Optionen router.post( '/contracts/:id/cancellation-confirmation-options', authenticate, requirePermission('contracts:update'), setUploadDir('cancellation-confirmations-options'), upload.single('document'), validateUploadedFile, (req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationOptionsPath', 'cancellation-confirmations-options') ); router.delete( '/contracts/:id/cancellation-confirmation-options', authenticate, requirePermission('contracts:update'), (req: AuthRequest, res: Response) => handleContractDocumentDelete(req, res, 'cancellationConfirmationOptionsPath') ); // ==================== RECHNUNGS-DOKUMENTE ==================== // Upload für Rechnungs-Dokument router.post( '/invoices/:id', authenticate, requirePermission('contracts:update'), setUploadDir('invoices'), upload.single('document'), validateUploadedFile, async (req: AuthRequest, res: Response) => { try { if (!req.file) { res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' }); return; } const invoiceId = parseInt(req.params.id); const relativePath = `/uploads/invoices/${req.file.filename}`; // Alte Datei löschen falls vorhanden const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } }); if (!invoice) { res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' }); return; } if (invoice.documentPath) { const oldPath = path.join(process.cwd(), invoice.documentPath); if (fs.existsSync(oldPath)) { fs.unlinkSync(oldPath); } } // Invoice in der DB aktualisieren await prisma.invoice.update({ where: { id: invoiceId }, data: { documentPath: relativePath }, }); res.json({ success: true, data: { path: relativePath, filename: req.file.filename, originalName: req.file.originalname, size: req.file.size, }, }); } catch (error) { console.error('Invoice upload error:', error); res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' }); } } ); // Löschen von Rechnungs-Dokument router.delete( '/invoices/:id', authenticate, requirePermission('contracts:update'), async (req: AuthRequest, res: Response) => { try { const invoiceId = parseInt(req.params.id); const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } }); if (!invoice) { res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' }); return; } if (!invoice.documentPath) { res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' }); return; } // Datei löschen const filePath = path.join(process.cwd(), invoice.documentPath); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } // documentPath in DB auf null setzen await prisma.invoice.update({ where: { id: invoiceId }, data: { documentPath: null }, }); res.json({ success: true }); } catch (error) { console.error('Invoice delete error:', error); res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' }); } } ); export default router;