Files
opencrm/backend/src/controllers/customer.controller.ts
T
duffyduck 9cf8c505af Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)
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:
"j​av​ascript:" 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>
2026-05-20 18:47:44 +02:00

1266 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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);
}
}