9cf8c505af
28.1 Restarbeit (URI-Schemata):
DANGEROUS_URI_SCHEMES jetzt vollstaendig – blob:, about:, ws:, wss:,
ldap:, dict: ergaenzt. http(s):, mailto:, tel: bewusst nicht
geblockt (legitime URLs in Notizfeldern).
29.1 Cyrillic-Homoglyph:
"jаvascript:" mit U+0430 lief durch die Regex. HOMOGLYPH_TO_ASCII-
Map (а→a, е→e, о→o, …, 13 Eintraege) wird VOR dem Scheme-Strip
angewendet.
29.2 Percent-Encoding:
"java%73cript:" und "java%2573cript:" umgingen den Filter.
percentDecode() laeuft jetzt iterativ bis zu 5 Runden.
29.3 Zero-Width-Joiner:
"javascript:" mit U+200B/200C/200D etc. zerteilte die Regex-
Matches. ZERO_WIDTH_CHARS-Regex strippt alle unsichtbaren Unicode-
Steuerzeichen, bevor irgendwas anderes laeuft.
28.3 Partial (PDF-Validierung tiefer):
Magic-Bytes allein reichten nicht – "%PDF-1.4\n#!/bin/bash" kam
durch. Jetzt zusaetzlich %%EOF-Marker in den letzten 1 KB +
Pattern-Scan der ersten 4 KB auf #!/, <script, <?php, <%, "MZ "
(PE-Header).
29.4 Email-Format-Validator:
neuer isValidEmail() lehnt Whitespace/Newlines (SMTP-Header-
Injection-Vektor) und Format-Muell ab. Verdrahtet in
create/update Customer + User + updatePortalSettings.
29.5 GET /api/providers/email 500 -> 404:
parseInt("email") = NaN, Prisma crashte. Controller validiert jetzt
Number.isFinite(id) und liefert 404.
Live-verifiziert auf dev: 13 Test-Cases (alle Schema-Varianten,
Homoglyphe, Percent, ZWJ, PDF-Validierung, Email-Format,
/providers/email) – alle erwarteten Antworten.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1266 lines
50 KiB
TypeScript
1266 lines
50 KiB
TypeScript
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<string, { von: unknown; nach: unknown }> = {};
|
||
const fieldLabels: Record<string, string> = {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<string, { von: unknown; nach: unknown }> = {};
|
||
const fieldLabels: Record<string, string> = {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<string, { von: unknown; nach: unknown }> = {};
|
||
const fieldLabels: Record<string, string> = {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<string, { von: unknown; nach: unknown }> = {};
|
||
const fieldLabels: Record<string, string> = {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<string, { von: unknown; nach: unknown }> = {};
|
||
const fieldLabels: Record<string, string> = {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<string, unknown> = {};
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<string, unknown> = { portalEnabled, portalEmail };
|
||
if (before) {
|
||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||
const fieldLabels: Record<string, string> = {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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);
|
||
}
|
||
}
|