import { Request, Response } from 'express'; import fs from 'fs'; import prisma from '../lib/prisma.js'; import * as contractService from '../services/contract.service.js'; import * as contractCockpitService from '../services/contractCockpit.service.js'; import * as contractHistoryService from '../services/contractHistory.service.js'; import * as authorizationService from '../services/authorization.service.js'; import { recordPredecessorFinalReading } from '../services/customer.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; import { logChange } from '../services/audit.service.js'; import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js'; import { ApiError } from '../utils/apiError.js'; import { canAccessContract, canAccessCustomer } from '../utils/accessControl.js'; import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js'; /** * Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten * eines Body-Objekts (rekursiv über energyDetails, internetDetails etc.). * Pentest 2026-05-24 (MEDIUM, 31.1): providerName, tariffName und die * price*-Felder nahmen rohe HTML-Payloads an (`` // sogar zu `null` und damit den vorherigen Wert überschreiben. // Die strikte Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) deckt alle // Bypässe ab, die stripHtml normalerweise auffangen würde // (Tags, Schemes, Zero-Width-Chars, Homoglyphe, Percent-Encoding) – // sie sind alle nicht in der Allowlist und fliegen mit 400 raus. if (parentKey && isContractIdentifierField(parentKey)) { return validateContractIdentifier(body, parentKey); } // Pentest 95.1/95.3/95.4 (LOW–MEDIUM, 2026-06-21): portalUsername // (Manual-Modus) hatte gar keine Validierung – CRLF/Header-Injection, // silent stripHtml-Mutation und VARCHAR-Overflow möglich. Gleiches // Raw-Input-Pattern wie R87. if (parentKey === 'portalUsername') { return validatePortalUsername(body, parentKey); } return stripHtml(body); } if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey)); if (typeof body === 'object') { const out: Record = {}; for (const [k, v] of Object.entries(body as Record)) { out[k] = sanitizeContractBody(v, k); } return out; } return body; } export async function getContracts(req: AuthRequest, res: Response): Promise { try { const { customerId, type, status, search, page, limit, tree, includeDeactivated } = req.query; // Baumstruktur für Kundenansicht if (tree === 'true' && customerId) { const customerIdNum = parseInt(customerId as string); // Pentest R120 (CRITICAL IDOR): Der tree=true-Zweig returnte früh, // BEVOR die Portal-User-customerIds-Filterung unten für die flache // Liste griff. Ein Portal-User konnte damit den vollständigen // Vertragsbaum eines beliebigen Fremdkunden lesen // (?tree=true&customerId=). Ownership-Check nachgezogen – // canAccessCustomer prüft eigene ID + vertretene MIT Live-Vollmacht // und sendet selbst die 403-Response. Nicht-Portal-User (Staff) // passieren unverändert. if (!(await canAccessCustomer(req, res, customerIdNum))) return; const treeData = await contractService.getContractTreeForCustomer( customerIdNum, includeDeactivated === 'true', ); res.json({ success: true, data: treeData } as ApiResponse); return; } // Für Kundenportal-Benutzer: nur eigene + vertretene Kunden MIT Vollmacht let customerIds: number[] | undefined; if (req.user?.isCustomerPortal && req.user.customerId) { // Eigene Customer-ID immer customerIds = [req.user.customerId]; // Vertretene Kunden nur wenn Vollmacht erteilt const representedIds: number[] = req.user.representedCustomerIds || []; for (const repCustId of representedIds) { const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId); if (hasAuth) { customerIds.push(repCustId); } } } const result = await contractService.getAllContracts({ customerId: customerId ? parseInt(customerId as string) : undefined, customerIds, // Wird nur für Kundenportal-Benutzer gesetzt type: type as any, status: status as any, search: search as string, page: page ? parseInt(page as string) : undefined, limit: limit ? parseInt(limit as string) : undefined, }); // Portal-User bekommen die Strict-Variante (ohne commission/notes/ // nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale. const isPortal = !!req.user?.isCustomerPortal; const data = isPortal ? sanitizeContractsStrict(result.contracts as any[]) : sanitizeContracts(result.contracts as any[]); res.json({ success: true, data, pagination: result.pagination, } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Verträge', } as ApiResponse); } } export async function getContract(req: AuthRequest, res: Response): Promise { try { const contract = await contractService.getContractById(parseInt(req.params.id)); if (!contract) { res.status(404).json({ success: false, error: 'Vertrag nicht gefunden', } as ApiResponse); return; } // Für Kundenportal-Benutzer: Zugriff nur auf eigene + vertretene Kunden MIT Vollmacht if (req.user?.isCustomerPortal && req.user.customerId) { const allowedCustomerIds = [req.user.customerId]; const representedIds: number[] = req.user.representedCustomerIds || []; for (const repCustId of representedIds) { const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId); if (hasAuth) { allowedCustomerIds.push(repCustId); } } if (!allowedCustomerIds.includes(contract.customerId)) { res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag', } as ApiResponse); return; } } const isPortal = !!req.user?.isCustomerPortal; const data = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any); res.json({ success: true, data } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden des Vertrags', } as ApiResponse); } } export async function createContract(req: AuthRequest, res: Response): Promise { try { // Input-Validierung: type + customerId sind Pflicht, sonst stürzte der // Service mit einer kryptischen JS-Message ab (Pentest Runde 12, INFO). const body = (req.body || {}) as Record; if (!body.type || typeof body.type !== 'string') { res.status(400).json({ success: false, error: 'Vertrags-Typ (type) ist erforderlich' } as ApiResponse); return; } if (!body.customerId || typeof body.customerId !== 'number') { res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse); return; } const sanitizedBody = sanitizeContractBody(body); const contract = await contractService.createContract(sanitizedBody as any); await logChange({ req, action: 'CREATE', resourceType: 'Contract', resourceId: contract.id.toString(), label: `Vertrag ${contract.contractNumber} angelegt`, customerId: contract.customerId, }); const isPortal = !!req.user?.isCustomerPortal; const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any); res.status(201).json({ success: true, data: sanitized } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Vertrags', } as ApiResponse); } } export async function updateContract(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); // Pentest 56.3 (latent, 2026-06-01): Defense-in-Depth – // canAccessContract explizit aufrufen, statt sich nur auf die // Route-Permission zu verlassen. Portal-User mit kompromittierter // Token-Permission würden sonst beliebige Verträge editieren können. if (!(await canAccessContract(req, res, contractId))) return; // Vorherigen Stand laden für Audit-Vergleich const before = await prisma.contract.findUnique({ where: { id: contractId }, include: { energyDetails: true, internetDetails: true, mobileDetails: true, tvDetails: true, carInsuranceDetails: true }, }); // HTML/JS-Strip auf allen String-Werten (Pentest 2026-05-24, 31.1) const sanitizedBody = sanitizeContractBody(req.body); const contract = await contractService.updateContract(contractId, sanitizedBody as any); // Geänderte Felder ermitteln const changes: Record = {}; const fieldLabels: Record = { status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende', portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter', contractNumberAtProvider: 'Vertragsnummer beim Anbieter', orderNumberAtSalesPlatform: 'Auftragsnummer bei Vertriebsplattform', customerNumberAtSalesPlatform: 'Kundennummer bei Vertriebsplattform', contractNumberAtSalesPlatform: 'Vertragsnummer bei Vertriebsplattform', providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist', contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform', cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am', identityDocumentId: 'Ausweis', bankCardId: 'Bankverbindung', addressId: 'Adresse', commission: 'Provision', notes: 'Notizen', portalCredentialsNotRequired: 'Portaldaten-Opt-out', stressfreiEmailId: 'Stressfrei-/Corporate-Adresse', }; const energyLabels: Record = { meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch', basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', instantBonus: 'Sofort-Bonus', newCustomerBonus: 'Neukunden-Bonus', }; // Hauptfelder vergleichen – gegen die SANITISIERTE Version, damit // das Audit-Log die echten DB-Werte widerspiegelt, nicht den // rohen Request-Body mit ggf. gestrippter HTML. const body = sanitizedBody as any; if (before) { for (const [key, newVal] of Object.entries(body)) { if (['energyDetails', 'internetDetails', 'mobileDetails', 'tvDetails', 'carInsuranceDetails', 'password'].includes(key)) continue; const oldVal = (before as any)[key]; const norm = (v: unknown) => (v === null || v === undefined || v === '' ? null : v); if (JSON.stringify(norm(oldVal)) !== JSON.stringify(norm(newVal))) { const label = fieldLabels[key] || key; changes[label] = { von: oldVal ?? '-', nach: newVal ?? '-' }; } } // Energie-Details vergleichen if (body.energyDetails && before.energyDetails) { for (const [key, newVal] of Object.entries(body.energyDetails)) { const oldVal = (before.energyDetails as any)[key]; const norm = (v: unknown) => (v === null || v === undefined || v === '' ? null : v); if (JSON.stringify(norm(oldVal)) !== JSON.stringify(norm(newVal))) { const label = energyLabels[key] || key; changes[label] = { von: oldVal ?? '-', nach: newVal ?? '-' }; } } } } const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von} → ${c.nach}`).join(', '); await logChange({ req, action: 'UPDATE', resourceType: 'Contract', resourceId: contractId.toString(), label: changeList ? `Vertrag ${before?.contractNumber || contractId} aktualisiert: ${changeList}` : `Vertrag ${before?.contractNumber || contractId} aktualisiert`, details: Object.keys(changes).length > 0 ? changes : undefined, customerId: before?.customerId, }); // Zusätzliches CRITICAL-Event für das „Portaldaten nicht benötigt"-Opt-out. // Landet unter resourceType ContractPassword (sensitivity CRITICAL laut // audit.service determineSensitivity) und liegt damit in derselben // DSGVO-Reihe wie Klartext-Password-Reads. Motivation: das Setzen des // Flags räumt server-seitig portalUsername/portalPasswordEncrypted/ // stressfreiEmailId auf NULL – diese Löschung soll unabhängig vom // generischen UPDATE-Diff nachweisbar sein. Rücknahme des Flags wird // ebenfalls geloggt, damit die Kette in beide Richtungen konsistent ist. if ( before && 'portalCredentialsNotRequired' in body && before.portalCredentialsNotRequired !== body.portalCredentialsNotRequired ) { const toggledOn = body.portalCredentialsNotRequired === true; await logChange({ req, action: 'UPDATE', resourceType: 'ContractPassword', resourceId: contractId.toString(), label: toggledOn ? `Vertrag ${before.contractNumber || contractId}: Portal-Zugangsdaten als „nicht erforderlich" markiert (Username/Passwort/Stressfrei-ID auf NULL gesetzt)` : `Vertrag ${before.contractNumber || contractId}: „Portaldaten nicht erforderlich"-Marker zurückgenommen`, details: { vorher: before.portalCredentialsNotRequired, nachher: body.portalCredentialsNotRequired, hatUsernameVorher: !!before.portalUsername, hatPasswortVorher: !!before.portalPasswordEncrypted, hatStressfreiVorher: !!before.stressfreiEmailId, }, customerId: before.customerId, }); } // Response sanitisieren – sonst leakt portalPasswordEncrypted etc. // (Pentest Runde 15, gleiche Klasse wie 20.3 für Customer). const isPortal = !!req.user?.isCustomerPortal; const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any); res.json({ success: true, data: sanitized } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Vertrags', } as ApiResponse); } } export async function deleteContract(req: Request, res: Response): Promise { try { const contractId = parseInt(req.params.id); // Pentest 56.3 (latent): Defense-in-Depth – Ownership-Check vor Delete. if (!(await canAccessContract(req as AuthRequest, res, contractId))) return; const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } }); await contractService.deleteContract(contractId); await logChange({ req, action: 'DELETE', resourceType: 'Contract', resourceId: contractId.toString(), label: `Vertrag ${contract?.contractNumber} gelöscht`, customerId: contract?.customerId, }); res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen des Vertrags', } as ApiResponse); } } export async function createFollowUp(req: AuthRequest, res: Response): Promise { try { const previousContractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, previousContractId))) return; // Vorgängervertrag laden für Vertragsnummer const previousContract = await prisma.contract.findUnique({ where: { id: previousContractId }, select: { contractNumber: true }, }); if (!previousContract) { res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse); return; } const contract = await contractService.createFollowUpContract(previousContractId); const createdBy = req.user?.email || 'unbekannt'; // Historie-Eintrag für den Vorgängervertrag erstellen await contractHistoryService.createFollowUpHistoryEntry( previousContractId, contract.contractNumber, createdBy ); // Historie-Eintrag für den neuen Folgevertrag erstellen await contractHistoryService.createNewContractFromPredecessorEntry( contract.id, previousContract.contractNumber, createdBy ); await logChange({ req, action: 'CREATE', resourceType: 'Contract', resourceId: contract.id.toString(), label: `Folgevertrag erstellt für ${previousContract.contractNumber}`, customerId: contract.customerId, }); const isPortal = !!req.user?.isCustomerPortal; const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any); res.status(201).json({ success: true, data: sanitized } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Folgevertrags', } as ApiResponse); } } /** * VVL = Vertragsverlängerung beim selben Anbieter. * Erstellt einen neuen Vertrag mit allen Daten des Vorgängers (außer * Auftragsdokument), Startdatum = altes Start + Vertragslaufzeit. */ export async function createRenewal(req: AuthRequest, res: Response): Promise { try { const previousContractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, previousContractId))) return; const previousContract = await prisma.contract.findUnique({ where: { id: previousContractId }, select: { contractNumber: true }, }); if (!previousContract) { res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse); return; } const contract = await contractService.createRenewalContract(previousContractId); if (!contract) { res.status(500).json({ success: false, error: 'VVL konnte nicht erstellt werden' } as ApiResponse); return; } const createdBy = req.user?.email || 'unbekannt'; await contractHistoryService.createRenewalHistoryEntry( previousContractId, contract.contractNumber, createdBy, ); await contractHistoryService.createNewRenewalFromPredecessorEntry( contract.id, previousContract.contractNumber, createdBy, ); await logChange({ req, action: 'CREATE', resourceType: 'Contract', resourceId: contract.id.toString(), label: `VVL erstellt für ${previousContract.contractNumber}`, customerId: contract.customerId, }); const isPortal = !!req.user?.isCustomerPortal; const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any); res.status(201).json({ success: true, data: sanitized } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL', } as ApiResponse); } } export async function getContractPassword(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const password = await contractService.getContractPassword(contractId); if (password === null) { res.status(404).json({ success: false, error: 'Kein Passwort hinterlegt', } as ApiResponse); return; } // Klartext-Passwort-Read auditieren (CRITICAL) await logChange({ req, action: 'READ', resourceType: 'ContractPassword', resourceId: contractId.toString(), label: `Klartext-Anbieter-Passwort von Vertrag #${contractId} entschlüsselt`, }); res.json({ success: true, data: { password } } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Entschlüsseln des Passworts', } as ApiResponse); } } export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise { try { const simCardId = parseInt(req.params.simCardId); // SimCard → MobileDetails → Contract const sim = await prisma.simCard.findUnique({ where: { id: simCardId }, select: { mobileDetails: { select: { contractId: true } } }, }); if (!sim?.mobileDetails) { res.status(404).json({ success: false, error: 'SIM-Karte nicht gefunden' } as ApiResponse); return; } if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return; const credentials = await contractService.getSimCardCredentials(simCardId); // Klartext-Read (PIN/PUK) auditieren (CRITICAL) await logChange({ req, action: 'READ', resourceType: 'SimCardCredentials', resourceId: simCardId.toString(), label: `Klartext-SIM-Karten-PIN/PUK von SIM #${simCardId} (Vertrag #${sim.mobileDetails.contractId}) entschlüsselt`, }); res.json({ success: true, data: credentials } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Entschlüsseln der SIM-Karten-Daten', } as ApiResponse); } } export async function getInternetCredentials(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const credentials = await contractService.getInternetCredentials(contractId); // Klartext-DSL/Internet-Login auditieren (CRITICAL) await logChange({ req, action: 'READ', resourceType: 'InternetCredentials', resourceId: contractId.toString(), label: `Klartext-Internet-Zugangsdaten von Vertrag #${contractId} entschlüsselt`, }); res.json({ success: true, data: credentials } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Entschlüsseln des Internet-Passworts', } as ApiResponse); } } export async function getSipCredentials(req: AuthRequest, res: Response): Promise { try { const phoneNumberId = parseInt(req.params.phoneNumberId); // PhoneNumber → InternetDetails → Contract const phone = await prisma.phoneNumber.findUnique({ where: { id: phoneNumberId }, select: { internetDetails: { select: { contractId: true } } }, }); if (!phone?.internetDetails) { res.status(404).json({ success: false, error: 'Rufnummer nicht gefunden' } as ApiResponse); return; } if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return; const credentials = await contractService.getSipCredentials(phoneNumberId); // Klartext-SIP/Telefon-Login auditieren (CRITICAL) await logChange({ req, action: 'READ', resourceType: 'SipCredentials', resourceId: phoneNumberId.toString(), label: `Klartext-SIP-Zugangsdaten von Rufnummer #${phoneNumberId} (Vertrag #${phone.internetDetails.contractId}) entschlüsselt`, }); res.json({ success: true, data: credentials } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Entschlüsseln des SIP-Passworts', } as ApiResponse); } } // ==================== VERTRAGS-COCKPIT ==================== export async function getCockpit(req: AuthRequest, res: Response): Promise { try { // Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen. // Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden // (Pentest Runde 4, 2026-05-16: HOCH). let customerIds: number[] | undefined; if (req.user?.isCustomerPortal && req.user.customerId) { customerIds = [req.user.customerId]; const representedIds: number[] = req.user.representedCustomerIds || []; for (const repCustId of representedIds) { const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId); if (hasAuth) { customerIds.push(repCustId); } } } const cockpitData = await contractCockpitService.getCockpitData({ customerIds }); res.json({ success: true, data: cockpitData } as ApiResponse); } catch (error) { console.error('Cockpit error:', error); res.status(500).json({ success: false, error: 'Fehler beim Laden des Vertrags-Cockpits', } as ApiResponse); } } // ==================== FOLGEZÄHLER ==================== export async function addSuccessorMeter(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); const { meterId, installedAt, finalReadingPrevious, deactivatePredecessor } = req.body; const contract = await prisma.contract.findUnique({ where: { id: contractId }, include: { energyDetails: { include: { contractMeters: { orderBy: { position: 'asc' } } } } }, }); if (!contract?.energyDetails) { res.status(404).json({ success: false, error: 'Energievertrag nicht gefunden' } as ApiResponse); return; } const ecdId = contract.energyDetails.id; const existingMeters = [...contract.energyDetails.contractMeters]; const switchAt = installedAt ? new Date(installedAt) : new Date(); // Vorgänger ermitteln (letzter ContractMeter oder Single-Meter-Vertrag) const predecessorMeterId = existingMeters.length > 0 ? existingMeters[existingMeters.length - 1].meterId : contract.energyDetails.meterId; // Endstand bereits hier validieren (monoton-steigend gegen vorhandene // Zählerstände des Vorgängers), damit wir nicht halb-geschriebene // Zustände hinterlassen. if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) { const finalReadingValue = parseFloat(finalReadingPrevious); // recordPredecessorFinalReading läuft erst NACH den Writes – Pre-Check // ohne Write hier separat über die Service-Validierung (idempotent, weil // sie keinen Reading anlegt, wenn am Wechseltag schon einer existiert). // Wir lassen den eigentlichen Write am Ende laufen, damit ein Fehler // beim Reading die Kette nicht zerreißt. const dayStart = new Date(switchAt); dayStart.setHours(0, 0, 0, 0); const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1); const sameDay = await prisma.meterReading.findFirst({ where: { meterId: predecessorMeterId, readingDate: { gte: dayStart, lt: dayEnd } }, }); if (!sameDay) { const lastBefore = await prisma.meterReading.findFirst({ where: { meterId: predecessorMeterId, readingDate: { lte: switchAt } }, orderBy: { readingDate: 'desc' }, }); if (lastBefore && finalReadingValue < lastBefore.value) { const fmtDate = (d: Date) => d.toLocaleDateString('de-DE'); throw new Error(`Endstand (${finalReadingValue}) darf nicht kleiner sein als der Stand vom ${fmtDate(lastBefore.readingDate)} (${lastBefore.value})`); } } } // Backfill: Bei Single-Meter-Verträgen (kein ContractMeter-Eintrag) den // bisherigen `energyDetails.meterId` als position 0 nachtragen, damit die // Folgezähler-Kette lückenlos ist und der alte Zähler nicht aus dem // Vertrag verschwindet, wenn `energyDetails.meterId` gleich auf den Neuen // gedreht wird. if (existingMeters.length === 0 && contract.energyDetails.meterId) { const backfilled = await prisma.contractMeter.create({ data: { energyContractDetailsId: ecdId, meterId: contract.energyDetails.meterId, position: 0, installedAt: null, }, }); existingMeters.push(backfilled); } const nextPosition = existingMeters.length > 0 ? Math.max(...existingMeters.map(m => m.position)) + 1 : 0; // Vorherigen Zähler als gewechselt markieren if (existingMeters.length > 0) { const prevMeter = existingMeters[existingMeters.length - 1]; await prisma.contractMeter.update({ where: { id: prevMeter.id }, data: { removedAt: switchAt, finalReading: finalReadingPrevious !== undefined ? parseFloat(finalReadingPrevious) : prevMeter.finalReading, }, }); } const contractMeter = await prisma.contractMeter.create({ data: { energyContractDetailsId: ecdId, meterId: parseInt(meterId), position: nextPosition, installedAt: switchAt, }, include: { meter: { include: { readings: true } } }, }); // Aktuellen Zähler am Vertrag aktualisieren await prisma.energyContractDetails.update({ where: { id: ecdId }, data: { meterId: parseInt(meterId) }, }); // Endstand des Vorgängers als regulären Zählerstand erfassen, damit er in // die Verbrauchsberechnung einfließt und in der Zählerstände-Liste auftaucht. if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) { await recordPredecessorFinalReading( predecessorMeterId, switchAt, parseFloat(finalReadingPrevious), ); } // Alten Zähler deaktivieren (Default), sofern der Aufrufer das nicht // explizit auf false setzt – ein-klick-fähiger Zählerwechsel. if (predecessorMeterId && deactivatePredecessor !== false) { await prisma.meter.update({ where: { id: predecessorMeterId }, data: { isActive: false }, }); } await logChange({ req, action: 'CREATE', resourceType: 'ContractMeter', resourceId: contractMeter.id.toString(), label: `Folgezähler hinzugefügt zu Vertrag #${contractId}`, customerId: contract.customerId, }); res.json({ success: true, data: contractMeter } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers', } as ApiResponse); } } export async function removeContractMeter(req: AuthRequest, res: Response): Promise { try { const contractMeterId = parseInt(req.params.contractMeterId); const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; await prisma.contractMeter.delete({ where: { id: contractMeterId } }); await logChange({ req, action: 'DELETE', resourceType: 'ContractMeter', resourceId: contractMeterId.toString(), label: `Folgezähler entfernt von Vertrag #${contractId}`, }); res.json({ success: true, data: null } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Entfernen', } as ApiResponse); } } // ==================== VERTRAGSDOKUMENTE ==================== export async function getContractDocuments(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const documents = await prisma.contractDocument.findMany({ where: { contractId }, orderBy: { createdAt: 'desc' }, }); res.json({ success: true, data: documents } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Dokumente' } as ApiResponse); } } export async function uploadContractDocument(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const { documentType, notes } = req.body; if (!req.file) { res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse); return; } if (!documentType) { res.status(400).json({ success: false, error: 'Dokumenttyp erforderlich' } as ApiResponse); return; } // Pentest 62.7: deliveryDate validieren (ISO-8601 oder null). let deliveryDate: string | null; try { deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate'); } catch (err) { try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse); return; } const documentPath = `/uploads/contract-documents/${req.file.filename}`; // Pentest 58.1: Whitelist-Validierung statt nur stripHtml. Multer hat // die Datei schon geschrieben – bei Reject räumen wir sie wieder weg. let cleanType: string; try { cleanType = validateContractDocumentType(documentType); } catch (err) { try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger Dokumenttyp' } as ApiResponse); return; } // Pentest 55.4: Race-Schutz – Lock + Recent-Duplicate-Check. const doc = await withContractDocumentLock(contractId, cleanType, () => prisma.contractDocument.create({ data: { contractId, documentType: cleanType, documentPath, originalName: req.file!.originalname, notes: sanitizeNotes(notes), uploadedBy: req.user?.email, }, }), ); const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } }); await logChange({ req, action: 'CREATE', resourceType: 'ContractDocument', resourceId: doc.id.toString(), label: `Dokument "${cleanType}" hochgeladen für Vertrag ${contract?.contractNumber}`, details: { typ: cleanType, datei: req.file.originalname }, customerId: contract?.customerId, }); // Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer await maybeActivateOnDeliveryConfirmation(contractId, cleanType, req, deliveryDate); res.status(201).json({ success: true, data: doc } as ApiResponse); } catch (error) { // Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400 // vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument- // Schreibfehler. const status = error instanceof ApiError ? error.statusCode : 400; // Multer hat die Datei schon geschrieben – bei Reject räumen. if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } res.status(status).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Hochladen', } as ApiResponse); } } export async function deleteContractDocument(req: AuthRequest, res: Response): Promise { try { const documentId = parseInt(req.params.documentId); const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } }); if (!doc || doc.contractId !== contractId) { res.status(404).json({ success: false, error: 'Dokument nicht gefunden' } as ApiResponse); return; } // Datei löschen const fs = await import('fs'); const path = await import('path'); const filePath = path.join(process.cwd(), doc.documentPath); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } await prisma.contractDocument.delete({ where: { id: documentId } }); const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } }); await logChange({ req, action: 'DELETE', resourceType: 'ContractDocument', resourceId: documentId.toString(), label: `Dokument "${doc.documentType}" gelöscht von Vertrag ${contract?.contractNumber}`, details: { typ: doc.documentType, datei: doc.originalName }, customerId: contract?.customerId, }); res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen', } as ApiResponse); } } // ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ==================== export async function snoozeContract(req: AuthRequest, res: Response): Promise { try { const id = parseInt(req.params.id); if (!(await canAccessContract(req, res, id))) return; const { nextReviewDate, months } = req.body; let reviewDate: Date | null = null; if (nextReviewDate) { // Explizites Datum angegeben reviewDate = new Date(nextReviewDate); } else if (months) { // Monate angegeben → berechne Datum reviewDate = new Date(); reviewDate.setMonth(reviewDate.getMonth() + months); } // Wenn beides leer → nextReviewDate wird auf null gesetzt (Snooze aufheben) const updated = await prisma.contract.update({ where: { id }, data: { nextReviewDate: reviewDate }, select: { id: true, contractNumber: true, nextReviewDate: true, }, }); await logChange({ req, action: 'UPDATE', resourceType: 'Contract', resourceId: id.toString(), label: `Vertrag ${updated.contractNumber} zurückgestellt`, }); res.json({ success: true, data: updated, message: reviewDate ? `Vertrag zurückgestellt bis ${reviewDate.toLocaleDateString('de-DE')}` : 'Zurückstellung aufgehoben', } as ApiResponse); } catch (error) { console.error('Snooze error:', error); res.status(500).json({ success: false, error: 'Fehler beim Zurückstellen des Vertrags', } as ApiResponse); } }