a3fef8891a
Upload-Endpoints (/api/upload/...) hatten denselben Mismatch-Vektor
wie schon die Vollmacht-Route (Pentest 28.3): multer prüft nur den
client-gemeldeten MIME-Type, eine `.php`-Datei mit
Content-Type: image/gif rutschte durch und landete als
`<unique>.gif.php` (Doppel-Endung) auf Disk – kein RCE in unserem
Setup, aber dreckige Datei + Inkonsistenz zwischen geliefertem MIME
und tatsächlichem Inhalt.
Fix: neue validateUploadedFile-Middleware nach upload.single(...) –
- liest die ersten 12 Bytes der gerade geschriebenen Datei
- erkennt PDF/PNG/JPEG/GIF/WebP per Magic-Bytes
- bei Mismatch: Datei löschen + 415 "Datei-Inhalt entspricht keinem
zulässigen Typ"
- benennt die Datei auf eine KANONISCHE Endung (.pdf/.jpg/.png/.gif/
.webp) um, abgeleitet aus dem erkannten Typ (NICHT aus
file.originalname). Damit verschwindet `evil.gif.php` zu
`<unique>.gif` (39.4).
- setzt req.file.mimetype auf den erkannten Type, sodass Controller
konsistente Werte sehen.
Eingehängt in allen 10 upload.single('document')-Routes
(bank-cards, documents, business-registrations, commercial-register,
contract-docs etc.).
Live-verifiziert:
- PHP-Datei als image/gif → 415 + Datei gelöscht
- HTML-Datei als application/pdf → 415 + Datei gelöscht
- WebP-Inhalt mit MIME image/png → 200, gespeichert als .webp
- echtes WebP/JPG → 200 mit kanonischer Endung
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
928 lines
30 KiB
TypeScript
928 lines
30 KiB
TypeScript
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
|
||
* `<timestamp-random>.<canonical-ext>` (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
|
||
// `<unique>.gif.php` o.ä. geschrieben – wir wollen `<unique>.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<string, unknown> = { [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;
|