import { Request, Response } from 'express'; import prisma from '../lib/prisma.js'; import * as customerService from '../services/customer.service.js'; import * as authService from '../services/auth.service.js'; import { logChange } from '../services/audit.service.js'; import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; import { sanitizeCustomer, sanitizeCustomers, sanitizeCustomerStrict, pickCustomerCreate, pickCustomerUpdate, isValidEmail, } from '../utils/sanitize.js'; import { canAccessMeter, canAccessAddress, canAccessBankCard, canAccessIdentityDocument, canAccessCustomer, getPortalAllowedCustomerIds, } from '../utils/accessControl.js'; // Customer CRUD export async function getCustomers(req: AuthRequest, res: Response): Promise { try { const { search, type, page, limit } = req.query; // Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit aktiver // Vollmacht) sehen. Wir geben die Liste direkt als DB-Filter mit, damit // auch `pagination.total` nur über diese IDs zählt (Pentest Runde 6 // MITTEL-02: `total: 4271` leakte vorher die globale Kunden-Zahl). const allowedIds = await getPortalAllowedCustomerIds(req); const result = await customerService.getAllCustomers({ search: search as string, type: type as 'PRIVATE' | 'BUSINESS', page: page ? parseInt(page as string) : undefined, limit: limit ? parseInt(limit as string) : undefined, allowedIds: allowedIds ?? undefined, }); const customers = result.customers as any[]; // Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false; const sanitized = canSeePasswords ? sanitizeCustomers(customers) : customers.map((c) => sanitizeCustomerStrict(c)).filter(Boolean); res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Kunden', } as ApiResponse); } } export async function getCustomer(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.id); if (!(await canAccessCustomer(req, res, customerId))) return; const customer = await customerService.getCustomerById(customerId); if (!customer) { res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse); return; } // Portal-Kunden/Read-only sehen kein portalPasswordEncrypted const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false; const sanitized = canSeePasswords ? sanitizeCustomer(customer as any) : sanitizeCustomerStrict(customer as any); res.json({ success: true, data: sanitized } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse); } } export async function createCustomer(req: Request, res: Response): Promise { try { // Whitelist: nur erlaubte Felder aus req.body übernehmen const data: any = pickCustomerCreate(req.body); // Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als // SMTP-Header-Injection-Vektor in der DB (Pentest 29.4). if (data.email && !isValidEmail(data.email)) { res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); return; } if (data.portalEmail && !isValidEmail(data.portalEmail)) { res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse); return; } // Convert birthDate string to Date if present if (data.birthDate) { data.birthDate = new Date(data.birthDate); } const customer = await customerService.createCustomer(data); await logChange({ req, action: 'CREATE', resourceType: 'Customer', resourceId: customer.id.toString(), label: `Kunde ${customer.customerNumber} angelegt (${customer.firstName} ${customer.lastName})`, customerId: customer.id, }); // Response sanitisieren (Pentest Runde 15, 20.3/20.4): die Service- // Funktion gibt das rohe DB-Objekt mit portalPasswordHash + Reset-Token // zurück. Ohne sanitize-Aufruf leakte das beim Erstellen + Update. const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false; const sanitized = canSeePasswords ? sanitizeCustomer(customer as any) : sanitizeCustomerStrict(customer 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 Kunden', } as ApiResponse); } } export async function updateCustomer(req: Request, res: Response): Promise { try { const customerId = parseInt(req.params.id); // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) // Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4) if (req.body?.email && !isValidEmail(req.body.email)) { res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse); return; } if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) { res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse); return; } const data: any = pickCustomerUpdate(req.body); // Vorherigen Stand laden für Audit const before = await prisma.customer.findUnique({ where: { id: customerId } }); // Convert birthDate string to Date if present, empty string to null if (data.birthDate === '' || data.birthDate === null) { data.birthDate = null; } else if (data.birthDate) { data.birthDate = new Date(data.birthDate); } // Leere Strings in optionalen Feldern zu null konvertieren const nullableFields = ['salutation', 'birthPlace', 'phone', 'mobile', 'email', 'companyName', 'taxNumber', 'businessRegistration', 'commercialRegister', 'commercialRegisterNumber', 'notes']; for (const field of nullableFields) { if (data[field] === '') data[field] = null; } const customer = await customerService.updateCustomer(customerId, data); // Audit: Geänderte Felder ermitteln und loggen if (before) { const changes: Record = {}; const fieldLabels: Record = { salutation: 'Anrede', firstName: 'Vorname', lastName: 'Nachname', email: 'E-Mail', phone: 'Telefon', mobile: 'Mobil', birthDate: 'Geburtsdatum', birthPlace: 'Geburtsort', companyName: 'Firma', type: 'Typ', taxNumber: 'Steuernummer', notes: 'Notizen', useInformalAddress: 'Anrede per', autoBirthdayGreeting: 'Autom. Geburtstagsgruß', autoBirthdayChannel: 'Kanal für Geburtstagsgruß', }; for (const [key, value] of Object.entries(data)) { // Technische/interne Felder überspringen if (['id', 'createdAt', 'updatedAt', 'customerNumber', 'portalPasswordHash', 'portalPasswordEncrypted'].includes(key)) continue; const oldVal = (before as any)[key]; const newVal = value; // Normalisieren: null, undefined, "" werden alle als "leer" behandelt const normalize = (v: unknown) => { if (v === null || v === undefined || v === '') return null; if (v instanceof Date) return v.toISOString().split('T')[0]; return v; }; const oldNorm = normalize(oldVal); const newNorm = normalize(newVal); if (JSON.stringify(oldNorm) !== JSON.stringify(newNorm)) { const label = fieldLabels[key] || key; const formatVal = (v: unknown) => { if (v === null || v === undefined || v === '') return '-'; if (v instanceof Date) return v.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); if (typeof v === 'boolean') return v ? 'Ja' : 'Nein'; return String(v); }; changes[label] = { von: formatVal(oldVal), nach: formatVal(newVal) }; } } if (Object.keys(changes).length > 0) { const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von} → ${c.nach}`).join(', '); await logChange({ req, action: 'UPDATE', resourceType: 'Customer', resourceId: customerId.toString(), label: `Kunde ${before.customerNumber} aktualisiert: ${changeList}`, details: changes, customerId, }); } } // Response sanitisieren – sonst leakt portalPasswordHash + // portalPasswordResetToken + consentHash + portalPasswordMustChange. // Pentest Runde 15 (20.3 KRITISCH, 20.4 HOCH). const canSeePasswords = (req as AuthRequest).user?.permissions?.includes('customers:update') ?? false; const sanitized = canSeePasswords ? sanitizeCustomer(customer as any) : sanitizeCustomerStrict(customer as any); res.json({ success: true, data: sanitized } as ApiResponse); } catch (error) { console.error('Update customer error:', error); res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Kunden', } as ApiResponse); } } export async function deleteCustomer(req: Request, res: Response): Promise { try { const customerId = parseInt(req.params.id); const customer = await prisma.customer.findUnique({ where: { id: customerId }, select: { customerNumber: true, firstName: true, lastName: true } }); await customerService.deleteCustomer(customerId); await logChange({ req, action: 'DELETE', resourceType: 'Customer', resourceId: customerId.toString(), label: `Kunde ${customer?.customerNumber} gelöscht (${customer?.firstName} ${customer?.lastName})`, customerId, }); res.json({ success: true, message: 'Kunde gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen des Kunden', } as ApiResponse); } } // Addresses export async function getAddresses(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const addresses = await customerService.getCustomerAddresses(customerId); res.json({ success: true, data: addresses } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse); } } export async function createAddress(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const address = await customerService.createAddress(customerId, req.body); await logChange({ req, action: 'CREATE', resourceType: 'Address', resourceId: address.id.toString(), label: `Adresse hinzugefügt für Kunde #${customerId}`, customerId, }); res.status(201).json({ success: true, data: address } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Adresse', } as ApiResponse); } } export async function updateAddress(req: AuthRequest, res: Response): Promise { try { const addressId = parseInt(req.params.id); if (!(await canAccessAddress(req, res, addressId))) return; const data = req.body; // Vorherigen Stand laden für Audit const before = await prisma.address.findUnique({ where: { id: addressId } }); const address = await customerService.updateAddress(addressId, data); const customerId = address.customerId; // Audit: Geänderte Felder ermitteln und loggen if (before) { const changes: Record = {}; const fieldLabels: Record = { street: 'Straße', houseNumber: 'Hausnummer', postalCode: 'PLZ', city: 'Stadt', country: 'Land', type: 'Typ', isDefault: 'Standard', ownerCompany: 'Eigentümer Firma', ownerFirstName: 'Eigentümer Vorname', ownerLastName: 'Eigentümer Nachname', ownerStreet: 'Eigentümer Straße', ownerHouseNumber: 'Eigentümer Hausnr.', ownerPostalCode: 'Eigentümer PLZ', ownerCity: 'Eigentümer Ort', ownerPhone: 'Eigentümer Telefon', ownerMobile: 'Eigentümer Mobil', ownerEmail: 'Eigentümer E-Mail', }; for (const [key, newVal] of Object.entries(data)) { if (['id', 'createdAt', 'updatedAt'].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; const formatVal = (v: unknown) => { if (v === null || v === undefined || v === '') return '-'; if (typeof v === 'boolean') return v ? 'Ja' : 'Nein'; return String(v); }; changes[label] = { von: formatVal(oldVal), nach: formatVal(newVal) }; } } const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von} → ${c.nach}`).join(', '); await logChange({ req, action: 'UPDATE', resourceType: 'Address', resourceId: address.id.toString(), label: changeList ? `Adresse aktualisiert für Kunde #${customerId}: ${changeList}` : `Adresse aktualisiert für Kunde #${customerId}`, details: Object.keys(changes).length > 0 ? changes : undefined, customerId, }); } else { await logChange({ req, action: 'UPDATE', resourceType: 'Address', resourceId: address.id.toString(), label: `Adresse aktualisiert für Kunde #${customerId}`, customerId, }); } res.json({ success: true, data: address } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Adresse', } as ApiResponse); } } export async function deleteAddress(req: AuthRequest, res: Response): Promise { try { const addressId = parseInt(req.params.id); if (!(await canAccessAddress(req, res, addressId))) return; const addr = await prisma.address.findUnique({ where: { id: addressId }, select: { customerId: true } }); const customerId = addr?.customerId; await customerService.deleteAddress(addressId); await logChange({ req, action: 'DELETE', resourceType: 'Address', resourceId: addressId.toString(), label: `Adresse gelöscht für Kunde #${customerId}`, customerId: customerId ?? undefined, }); res.json({ success: true, message: 'Adresse gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen der Adresse', } as ApiResponse); } } // Bank Cards export async function getBankCards(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const showInactive = req.query.showInactive === 'true'; const cards = await customerService.getCustomerBankCards(customerId, showInactive); res.json({ success: true, data: cards } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse); } } export async function createBankCard(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const card = await customerService.createBankCard(customerId, req.body); await logChange({ req, action: 'CREATE', resourceType: 'BankCard', resourceId: card.id.toString(), label: `Bankverbindung hinzugefügt für Kunde #${customerId}`, customerId, }); res.status(201).json({ success: true, data: card } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Bankkarte', } as ApiResponse); } } export async function updateBankCard(req: AuthRequest, res: Response): Promise { try { const cardId = parseInt(req.params.id); if (!(await canAccessBankCard(req, res, cardId))) return; const data = req.body; // Vorherigen Stand laden für Audit const before = await prisma.bankCard.findUnique({ where: { id: cardId } }); const card = await customerService.updateBankCard(cardId, data); const customerId = card.customerId; // Audit: Geänderte Felder ermitteln und loggen if (before) { const changes: Record = {}; const fieldLabels: Record = { iban: 'IBAN', bic: 'BIC', bankName: 'Bank', accountHolder: 'Kontoinhaber', isActive: 'Aktiv', }; for (const [key, newVal] of Object.entries(data)) { if (['id', 'createdAt', 'updatedAt'].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; const formatVal = (v: unknown) => { if (v === null || v === undefined || v === '') return '-'; if (typeof v === 'boolean') return v ? 'Ja' : 'Nein'; return String(v); }; changes[label] = { von: formatVal(oldVal), nach: formatVal(newVal) }; } } const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von} → ${c.nach}`).join(', '); await logChange({ req, action: 'UPDATE', resourceType: 'BankCard', resourceId: card.id.toString(), label: changeList ? `Bankverbindung aktualisiert für Kunde #${customerId}: ${changeList}` : `Bankverbindung aktualisiert für Kunde #${customerId}`, details: Object.keys(changes).length > 0 ? changes : undefined, customerId, }); } else { await logChange({ req, action: 'UPDATE', resourceType: 'BankCard', resourceId: card.id.toString(), label: `Bankverbindung aktualisiert für Kunde #${customerId}`, customerId, }); } res.json({ success: true, data: card } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Bankkarte', } as ApiResponse); } } export async function deleteBankCard(req: AuthRequest, res: Response): Promise { try { const cardId = parseInt(req.params.id); if (!(await canAccessBankCard(req, res, cardId))) return; const card = await prisma.bankCard.findUnique({ where: { id: cardId }, select: { customerId: true } }); const customerId = card?.customerId; await customerService.deleteBankCard(cardId); await logChange({ req, action: 'DELETE', resourceType: 'BankCard', resourceId: cardId.toString(), label: `Bankverbindung gelöscht für Kunde #${customerId}`, customerId: customerId ?? undefined, }); res.json({ success: true, message: 'Bankkarte gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen der Bankkarte', } as ApiResponse); } } // Identity Documents export async function getDocuments(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const showInactive = req.query.showInactive === 'true'; const docs = await customerService.getCustomerDocuments(customerId, showInactive); res.json({ success: true, data: docs } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse); } } export async function createDocument(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const doc = await customerService.createDocument(customerId, req.body); await logChange({ req, action: 'CREATE', resourceType: 'IdentityDocument', resourceId: doc.id.toString(), label: `Ausweis hinzugefügt für Kunde #${customerId}`, customerId, }); res.status(201).json({ success: true, data: doc } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Ausweises', } as ApiResponse); } } export async function updateDocument(req: AuthRequest, res: Response): Promise { try { const docId = parseInt(req.params.id); if (!(await canAccessIdentityDocument(req, res, docId))) return; const data = req.body; // Vorherigen Stand laden für Audit const before = await prisma.identityDocument.findUnique({ where: { id: docId } }); const doc = await customerService.updateDocument(docId, data); const customerId = doc.customerId; // Audit: Geänderte Felder ermitteln und loggen if (before) { const changes: Record = {}; const fieldLabels: Record = { type: 'Dokumenttyp', documentNumber: 'Dokumentnummer', issuingAuthority: 'Ausstellungsbehörde', issueDate: 'Ausstellungsdatum', expiryDate: 'Ablaufdatum', isActive: 'Aktiv', licenseClasses: 'Führerscheinklassen', }; for (const [key, newVal] of Object.entries(data)) { if (['id', 'createdAt', 'updatedAt'].includes(key)) continue; const oldVal = (before as any)[key]; const norm = (v: unknown) => { if (v === null || v === undefined || v === '') return null; if (v instanceof Date) return v.toISOString().split('T')[0]; return v; }; if (JSON.stringify(norm(oldVal)) !== JSON.stringify(norm(newVal))) { const label = fieldLabels[key] || key; const formatVal = (v: unknown) => { if (v === null || v === undefined || v === '') return '-'; if (v instanceof Date) return v.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); if (typeof v === 'boolean') return v ? 'Ja' : 'Nein'; return String(v); }; changes[label] = { von: formatVal(oldVal), nach: formatVal(newVal) }; } } const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von} → ${c.nach}`).join(', '); await logChange({ req, action: 'UPDATE', resourceType: 'IdentityDocument', resourceId: doc.id.toString(), label: changeList ? `Ausweis aktualisiert für Kunde #${customerId}: ${changeList}` : `Ausweis aktualisiert für Kunde #${customerId}`, details: Object.keys(changes).length > 0 ? changes : undefined, customerId, }); } else { await logChange({ req, action: 'UPDATE', resourceType: 'IdentityDocument', resourceId: doc.id.toString(), label: `Ausweis aktualisiert für Kunde #${customerId}`, customerId, }); } res.json({ success: true, data: doc } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Ausweises', } as ApiResponse); } } export async function deleteDocument(req: AuthRequest, res: Response): Promise { try { const docId = parseInt(req.params.id); if (!(await canAccessIdentityDocument(req, res, docId))) return; const doc = await prisma.identityDocument.findUnique({ where: { id: docId }, select: { customerId: true } }); const customerId = doc?.customerId; await customerService.deleteDocument(docId); await logChange({ req, action: 'DELETE', resourceType: 'IdentityDocument', resourceId: docId.toString(), label: `Ausweis gelöscht für Kunde #${customerId}`, customerId: customerId ?? undefined, }); res.json({ success: true, message: 'Ausweis gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen des Ausweises', } as ApiResponse); } } // Meters export async function getMeters(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const showInactive = req.query.showInactive === 'true'; const meters = await customerService.getCustomerMeters(customerId, showInactive); res.json({ success: true, data: meters } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse); } } export async function createMeter(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const meter = await customerService.createMeter(customerId, req.body); await logChange({ req, action: 'CREATE', resourceType: 'Meter', resourceId: meter.id.toString(), label: `Zähler angelegt für Kunde #${customerId}`, customerId, }); res.status(201).json({ success: true, data: meter } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Zählers', } as ApiResponse); } } export async function updateMeter(req: AuthRequest, res: Response): Promise { try { const meterId = parseInt(req.params.id); if (!(await canAccessMeter(req, res, meterId))) return; const data = req.body; // Vorherigen Stand laden für Audit const before = await prisma.meter.findUnique({ where: { id: meterId } }); const meter = await customerService.updateMeter(meterId, data); const customerId = meter.customerId; // Audit: Geänderte Felder ermitteln und loggen if (before) { const changes: Record = {}; const fieldLabels: Record = { meterNumber: 'Zählernummer', type: 'Typ', tariffModel: 'Tarifmodell', location: 'Standort', isActive: 'Aktiv', }; for (const [key, newVal] of Object.entries(data)) { if (['id', 'createdAt', 'updatedAt'].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; const formatVal = (v: unknown) => { if (v === null || v === undefined || v === '') return '-'; if (typeof v === 'boolean') return v ? 'Ja' : 'Nein'; return String(v); }; changes[label] = { von: formatVal(oldVal), nach: formatVal(newVal) }; } } const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von} → ${c.nach}`).join(', '); await logChange({ req, action: 'UPDATE', resourceType: 'Meter', resourceId: meter.id.toString(), label: changeList ? `Zähler aktualisiert: ${changeList}` : `Zähler aktualisiert`, details: Object.keys(changes).length > 0 ? changes : undefined, customerId, }); } else { await logChange({ req, action: 'UPDATE', resourceType: 'Meter', resourceId: meter.id.toString(), label: `Zähler aktualisiert`, }); } res.json({ success: true, data: meter } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählers', } as ApiResponse); } } export async function deleteMeter(req: AuthRequest, res: Response): Promise { try { const meterId = parseInt(req.params.id); if (!(await canAccessMeter(req, res, meterId))) return; await customerService.deleteMeter(meterId); await logChange({ req, action: 'DELETE', resourceType: 'Meter', resourceId: meterId.toString(), label: `Zähler gelöscht`, }); res.json({ success: true, message: 'Zähler gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählers', } as ApiResponse); } } // Meter Readings export async function getMeterReadings(req: AuthRequest, res: Response): Promise { try { const meterId = parseInt(req.params.meterId); if (!(await canAccessMeter(req, res, meterId))) return; const readings = await customerService.getMeterReadings(meterId); res.json({ success: true, data: readings } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Zählerstände' } as ApiResponse); } } export async function addMeterReading(req: AuthRequest, res: Response): Promise { try { const { readingDate, value, valueNt, unit, notes } = req.body; const meterId = parseInt(req.params.meterId); if (!(await canAccessMeter(req, res, meterId))) return; const reading = await customerService.addMeterReading(meterId, { readingDate: new Date(readingDate), value: parseFloat(value), valueNt: valueNt !== undefined && valueNt !== null && valueNt !== '' ? parseFloat(valueNt) : undefined, unit, notes, }); // Audit: Zählerstand mit Kontext loggen const meter = await prisma.meter.findUnique({ where: { id: meterId }, select: { meterNumber: true, customer: { select: { id: true, firstName: true, lastName: true } } }, }); if (meter) { const ntInfo = valueNt ? ` / NT: ${parseFloat(valueNt)}` : ''; await logChange({ req, action: 'CREATE', resourceType: 'MeterReading', label: `Zählerstand ${parseFloat(value)}${ntInfo} ${unit || 'kWh'} für Zähler ${meter.meterNumber} erfasst (${meter.customer.firstName} ${meter.customer.lastName})`, details: { zähler: meter.meterNumber, stand: parseFloat(value), datum: readingDate }, customerId: meter.customer.id, }); } res.status(201).json({ success: true, data: reading } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Zählerstands', } as ApiResponse); } } export async function updateMeterReading(req: AuthRequest, res: Response): Promise { try { const meterId = parseInt(req.params.meterId); if (!(await canAccessMeter(req, res, meterId))) return; const { readingDate, value, valueNt, unit, notes } = req.body; const updateData: Record = {}; if (readingDate !== undefined) updateData.readingDate = new Date(readingDate); if (value !== undefined) updateData.value = parseFloat(value); if (valueNt !== undefined) updateData.valueNt = valueNt !== null && valueNt !== '' ? parseFloat(valueNt) : null; if (unit !== undefined) updateData.unit = unit; if (notes !== undefined) updateData.notes = notes; const reading = await customerService.updateMeterReading( meterId, parseInt(req.params.readingId), updateData as any ); await logChange({ req, action: 'UPDATE', resourceType: 'MeterReading', resourceId: reading.id.toString(), label: `Zählerstand aktualisiert`, }); res.json({ success: true, data: reading } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählerstands', } as ApiResponse); } } export async function deleteMeterReading(req: AuthRequest, res: Response): Promise { try { const meterId = parseInt(req.params.meterId); if (!(await canAccessMeter(req, res, meterId))) return; const readingId = parseInt(req.params.readingId); await customerService.deleteMeterReading(meterId, readingId); await logChange({ req, action: 'DELETE', resourceType: 'MeterReading', resourceId: readingId.toString(), label: `Zählerstand gelöscht`, }); res.json({ success: true, data: null } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählerstands', } as ApiResponse); } } // ==================== PORTAL: ZÄHLERSTAND MELDEN ==================== export async function reportMeterReading(req: AuthRequest, res: Response): Promise { try { const user = req.user as any; if (!user?.isCustomerPortal || !user?.customerId) { res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' } as ApiResponse); return; } const meterId = parseInt(req.params.meterId); const { value, readingDate, notes } = req.body; // Prüfe ob der Zähler zum Kunden gehört const meter = await prisma.meter.findUnique({ where: { id: meterId }, select: { customerId: true }, }); if (!meter || meter.customerId !== user.customerId) { res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Zähler' } as ApiResponse); return; } const parsedDate = readingDate ? new Date(readingDate) : new Date(); const parsedValue = parseFloat(value); // Validierung über den Service (monoton steigend) const reading = await customerService.addMeterReading(meterId, { readingDate: parsedDate, value: parsedValue, notes, }); // Status auf REPORTED setzen await prisma.meterReading.update({ where: { id: reading.id }, data: { reportedBy: user.email, status: 'REPORTED' }, }); // Audit const meterInfo = await prisma.meter.findUnique({ where: { id: meterId }, select: { meterNumber: true } }); await logChange({ req, action: 'CREATE', resourceType: 'MeterReading', label: `Zählerstand ${parsedValue} gemeldet (Zähler ${meterInfo?.meterNumber || meterId})`, details: { zähler: meterInfo?.meterNumber, stand: parsedValue, datum: parsedDate.toISOString() }, customerId: user.customerId, }); res.status(201).json({ success: true, data: reading } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Melden des Zählerstands', } as ApiResponse); } } export async function getMyMeters(req: AuthRequest, res: Response): Promise { try { const user = req.user as any; if (!user?.isCustomerPortal || !user?.customerId) { res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' } as ApiResponse); return; } const meters = await prisma.meter.findMany({ where: { customerId: user.customerId, isActive: true }, include: { readings: { orderBy: { readingDate: 'desc' }, take: 5, }, }, orderBy: { createdAt: 'asc' }, }); res.json({ success: true, data: meters } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse); } } export async function markReadingTransferred(req: AuthRequest, res: Response): Promise { try { const meterId = parseInt(req.params.meterId); if (!(await canAccessMeter(req, res, meterId))) return; const readingId = parseInt(req.params.readingId); const reading = await prisma.meterReading.update({ where: { id: readingId }, data: { status: 'TRANSFERRED', transferredAt: new Date(), transferredBy: req.user?.email, }, }); await logChange({ req, action: 'UPDATE', resourceType: 'MeterReading', resourceId: readingId.toString(), label: `Zählerstand als übertragen markiert`, }); res.json({ success: true, data: reading } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren', } as ApiResponse); } } // ==================== PORTAL SETTINGS ==================== export async function getPortalSettings(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const settings = await customerService.getPortalSettings(customerId); if (!settings) { res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse); return; } // Passwort-Hash nicht zurückgeben, nur ob ein Passwort gesetzt ist res.json({ success: true, data: { id: settings.id, portalEnabled: settings.portalEnabled, portalEmail: settings.portalEmail, portalLastLogin: settings.portalLastLogin, hasPassword: !!settings.portalPasswordHash, }, } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Portal-Einstellungen', } as ApiResponse); } } export async function updatePortalSettings(req: Request, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); // `password` (oder password-ähnliche Felder) gehören NICHT in den // Settings-Update. Sonst denkt der Client, sein Passwort wurde gesetzt // (HTTP 200), während das Feld stillschweigend ignoriert wird. Wer // ein Passwort setzen will, nutzt POST /portal/password mit // Komplexitäts-Check. (Pentest-Befund.) const body = req.body || {}; const forbidden = ['password', 'portalPassword', 'portalPasswordHash', 'portalPasswordEncrypted']; const offending = forbidden.filter((k) => k in body); if (offending.length > 0) { res.status(400).json({ success: false, error: `Felder nicht erlaubt: ${offending.join(', ')}. Bitte POST /customers/${customerId}/portal/password nutzen.`, } as ApiResponse); return; } const { portalEnabled, portalEmail } = body; // Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4) if (portalEmail && !isValidEmail(portalEmail)) { res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse); return; } // Vorherigen Stand laden für Audit const before = await prisma.customer.findUnique({ where: { id: customerId }, select: { portalEnabled: true, portalEmail: true }, }); const settings = await customerService.updatePortalSettings(customerId, { portalEnabled, portalEmail, }); // Audit: Geänderte Felder ermitteln und loggen const data: Record = { portalEnabled, portalEmail }; if (before) { const changes: Record = {}; const fieldLabels: Record = { portalEnabled: 'Portal aktiv', portalEmail: 'Portal-E-Mail', }; for (const [key, newVal] of Object.entries(data)) { if (newVal === undefined) 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; const formatVal = (v: unknown) => { if (v === null || v === undefined || v === '') return '-'; if (typeof v === 'boolean') return v ? 'Ja' : 'Nein'; return String(v); }; changes[label] = { von: formatVal(oldVal), nach: formatVal(newVal) }; } } const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von} → ${c.nach}`).join(', '); await logChange({ req, action: 'UPDATE', resourceType: 'PortalSettings', resourceId: customerId.toString(), label: changeList ? `Portal-Einstellungen aktualisiert für Kunde #${customerId}: ${changeList}` : `Portal-Einstellungen aktualisiert für Kunde #${customerId}`, details: Object.keys(changes).length > 0 ? changes : undefined, customerId, }); } else { await logChange({ req, action: 'UPDATE', resourceType: 'PortalSettings', resourceId: customerId.toString(), label: `Portal-Einstellungen aktualisiert für Kunde #${customerId}`, customerId, }); } res.json({ success: true, data: settings } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Portal-Einstellungen', } as ApiResponse); } } /** * Generiert ein zufälliges, komplexes Passwort (16 Zeichen, gemischt). * Setzt es NICHT direkt — wird im Frontend in den Setzen-Button-Flow gefüttert. * Damit hat der Admin Wahlfreiheit (Generieren → ggf. anpassen → speichern). */ export async function generatePortalPassword(req: Request, res: Response): Promise { try { const password = generateSecurePassword({ length: 16 }); res.json({ success: true, data: { password } } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts', } as ApiResponse); } } /** * Verschickt die Portal-Zugangsdaten per E-Mail an die hinterlegte * `email` (bevorzugt) oder fallback auf `portalEmail` des Kunden. Das * Passwort wird aus dem `portalPasswordEncrypted`-Feld entschlüsselt * (= das aktuell aktive Klartext-Passwort, das auch in der UI angezeigt wird). */ export async function sendPortalCredentials(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); const customer = await prisma.customer.findUnique({ where: { id: customerId }, select: { id: true, firstName: true, lastName: true, salutation: true, companyName: true, email: true, portalEmail: true, portalEnabled: true, portalPasswordEncrypted: true, portalPasswordHash: true, }, }); if (!customer) { res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse); return; } if (!customer.portalEnabled) { res.status(400).json({ success: false, error: 'Portal ist für diesen Kunden nicht aktiviert', } as ApiResponse); return; } if (!customer.portalPasswordHash) { res.status(400).json({ success: false, error: 'Es ist noch kein Portal-Passwort gesetzt', } as ApiResponse); return; } const targetEmail = customer.email || customer.portalEmail; if (!targetEmail) { res.status(400).json({ success: false, error: 'Kunde hat keine E-Mail-Adresse hinterlegt', } as ApiResponse); return; } const loginEmail = customer.portalEmail || customer.email!; const plaintextPassword = await authService.getCustomerPortalPassword(customerId); if (!plaintextPassword) { res.status(400).json({ success: false, error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld – bitte neu setzen)', } as ApiResponse); return; } await authService.sendPortalCredentialsEmail({ to: targetEmail, customer, loginEmail, password: plaintextPassword, }); // Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss // der Kunde sich ein eigenes setzen. await authService.markPortalPasswordForChange(customerId); await logChange({ req, action: 'UPDATE', resourceType: 'PortalSettings', resourceId: customerId.toString(), label: `Portal-Zugangsdaten per E-Mail versendet an ${targetEmail} (Einmalpasswort)`, customerId, }); res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten', } as ApiResponse); } } export async function setPortalPassword(req: Request, res: Response): Promise { try { const { password } = req.body; // Komplexität: 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen (zentrale Regel) const complexity = validatePasswordComplexity(password); if (!complexity.ok) { res.status(400).json({ success: false, error: 'Passwort erfüllt Mindestanforderungen nicht: ' + complexity.errors.join(', '), } as ApiResponse); return; } const customerId = parseInt(req.params.customerId); await authService.setCustomerPortalPassword(customerId, password); await logChange({ req, action: 'UPDATE', resourceType: 'PortalSettings', resourceId: customerId.toString(), label: `Portal-Passwort gesetzt für Kunde #${customerId}`, customerId, }); res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts', } as ApiResponse); } } export async function getPortalPassword(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const password = await authService.getCustomerPortalPassword(customerId); // Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal- // Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit // + Insider-Threat-Erkennung. await logChange({ req, action: 'READ', resourceType: 'PortalPassword', resourceId: customerId.toString(), label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt`, customerId, }); res.json({ success: true, data: { password } } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Abrufen des Passworts', } as ApiResponse); } } // ==================== REPRESENTATIVE MANAGEMENT ==================== export async function getRepresentatives(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; // Wer kann diesen Kunden vertreten (representedBy)? const representedBy = await customerService.getRepresentedByList(customerId); res.json({ success: true, data: representedBy } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Vertreter', } as ApiResponse); } } export async function addRepresentative(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const { representativeId, notes } = req.body; const representative = await customerService.addRepresentative( customerId, parseInt(representativeId), notes ); await logChange({ req, action: 'CREATE', resourceType: 'Representative', resourceId: representative.id.toString(), label: `Vertreter hinzugefügt für Kunde #${customerId}`, customerId, }); res.status(201).json({ success: true, data: representative } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Vertreters', } as ApiResponse); } } export async function removeRepresentative(req: AuthRequest, res: Response): Promise { try { const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; await customerService.removeRepresentative( customerId, parseInt(req.params.representativeId) ); await logChange({ req, action: 'DELETE', resourceType: 'Representative', label: `Vertreter entfernt für Kunde #${customerId}`, customerId, }); res.json({ success: true, message: 'Vertreter entfernt' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Entfernen des Vertreters', } as ApiResponse); } } export async function searchForRepresentative(req: AuthRequest, res: Response): Promise { try { // KRITISCH (Pentest Runde 6): ohne canAccessCustomer kann ein Portal-User // mit beliebigem :customerId-Pfad alle Kunden durchsuchen → komplette // Kunden-DB-Enumeration via Buchstaben-Brute-Force. const customerId = parseInt(req.params.customerId); if (!(await canAccessCustomer(req, res, customerId))) return; const { search } = req.query; if (!search || typeof search !== 'string' || search.length < 2) { res.json({ success: true, data: [] } as ApiResponse); return; } const customers = await customerService.searchCustomersForRepresentative( search, customerId, ); res.json({ success: true, data: customers } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler bei der Suche', } as ApiResponse); } }