Files
opencrm/backend/src/routes/upload.routes.ts
T
duffyduck 7c18343a95 Bugfixes: Adresse-Modal + Upload-Limit auf 25 MB
1. AddressModal: Straße-Feld ließ sich nicht editieren. setFormData
   wurde im Render-Body aufgerufen, wenn formData.street !==
   address.street → Reset bei jedem Tastendruck. In useEffect mit
   [address?.id]-Dependency umgezogen.

2. Multer-Limit von 10 MB auf 25 MB in upload.routes.ts,
   gdpr.routes.ts, contract.routes.ts. Zwei Handy-Fotos zu PDF
   kratzten am alten Limit. FileUpload-Hinweistext angepasst.
2026-06-03 16:37:09 +02:00

950 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
canAccessCustomer,
canAccessBankCard,
canAccessIdentityDocument,
} from '../utils/accessControl.js';
import { validateOptionalIsoDate } from '../utils/sanitize.js';
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
// für saubere 404-Antwort + Datei-Cleanup, wenn die Resource nicht
// existiert. Anschließend laufen die `canAccess*`-Checks (Portal-User
// werden dort auf ihre eigenen Kunden eingeschränkt; Staff bekommen
// volle Sicht konsistent mit der bestehenden Access-Control-Logik).
async function resolveInvoiceContractId(invoiceId: number): Promise<number | null> {
const invoice = await prisma.invoice.findUnique({
where: { id: invoiceId },
select: { contractId: true, energyContractDetails: { select: { contractId: true } } },
});
return invoice?.contractId ?? invoice?.energyContractDetails?.contractId ?? null;
}
function cleanupFile(filePath?: string) {
if (!filePath) return;
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
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: {
// 25 MB passt für Ausweis-Scans, Handy-Photos im JpgToPdf-Flow,
// mehrseitige PDFs aus dem Modal (bis ~5-7 Seiten je nach Auflösung).
// Vorher 10 MB → Multer brach bei zwei Smartphone-Fotos ab.
fileSize: 25 * 1024 * 1024,
},
});
// Middleware um Subdirectory zu setzen
function setUploadDir(subDir: string) {
return (req: AuthRequest, res: Response, next: Function) => {
(req as any).uploadSubDir = subDir;
next();
};
}
// Magic-Byte-Whitelist + canonical Rename + PDF-Scan: siehe
// middleware/uploadFileTypeValidator.ts (zentralisiert, damit auch
// contract.routes.ts denselben Check fahren kann Pentest 69.3).
// 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);
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const card = await prisma.bankCard.findUnique({ where: { id: bankCardId } });
if (!card) {
cleanupFile(req.file.path);
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
return;
}
if (!(await canAccessBankCard(req, res, bankCardId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/bank-cards/${req.file.filename}`;
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);
cleanupFile(req.file?.path);
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);
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const doc = await prisma.identityDocument.findUnique({ where: { id: documentId } });
if (!doc) {
cleanupFile(req.file.path);
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
return;
}
if (!(await canAccessIdentityDocument(req, res, documentId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/documents/${req.file.filename}`;
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);
cleanupFile(req.file?.path);
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;
}
// Pentest 56.1: Ownership-Check.
if (!(await canAccessBankCard(req, res, bankCardId))) 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;
}
// Pentest 56.1: Ownership-Check.
if (!(await canAccessIdentityDocument(req, res, documentId))) 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);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
return;
}
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);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
return;
}
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);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) return;
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);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) return;
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);
// Pentest 56.2 (HIGH): Ownership-Check VOR Consent-Massen-Update.
// Ohne diese Prüfung konnte jeder Caller mit customers:update für
// jede beliebige customerId ALLE Einwilligungen auf GRANTED setzen
// (DSGVO-Eskalation).
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
return;
}
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);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) return;
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<string, unknown> = { [fieldName]: relativePath };
if (fieldName === 'cancellationConfirmationPath' || fieldName === 'cancellationConfirmationOptionsPath') {
const dateField = fieldName === 'cancellationConfirmationPath'
? 'cancellationConfirmationDate'
: 'cancellationConfirmationOptionsDate';
// Pentest 62.7: confirmationDate gegen ISO-8601 validieren.
let provided: string | null;
try {
provided = validateOptionalIsoDate(req.body?.confirmationDate, 'confirmationDate');
} catch (err) {
cleanupFile(req.file?.path);
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Bestätigungsdatum' });
return;
}
let target: Date | null = null;
if (provided) {
target = new Date(provided);
}
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);
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
if (!invoice) {
cleanupFile(req.file.path);
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
return;
}
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) {
cleanupFile(req.file.path);
return;
}
const relativePath = `/uploads/invoices/${req.file.filename}`;
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;
}
// Pentest 56.1: Ownership-Check vor Delete.
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) 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;