import { CustomerType, ContractStatus } from '@prisma/client'; import prisma from '../lib/prisma.js'; import { generateCustomerNumber, paginate, buildPaginationResponse } from '../utils/helpers.js'; import { ApiError } from '../utils/apiError.js'; import { deleteUploadByRelativePath } from '../utils/fileCleanup.js'; export interface CustomerFilters { search?: string; type?: CustomerType; page?: number; limit?: number; // Wenn gesetzt: nur Customer mit id in dieser Liste. Für Portal-User, damit // weder Liste noch pagination.total die globale Kunden-Zahl preisgibt. allowedIds?: number[]; } export async function getAllCustomers(filters: CustomerFilters) { const { search, type, page = 1, limit = 20, allowedIds } = filters; const { skip, take } = paginate(page, limit); const where: Record = {}; if (type) { where.type = type; } if (allowedIds) { where.id = { in: allowedIds }; } if (search) { where.OR = [ { firstName: { contains: search } }, { lastName: { contains: search } }, { companyName: { contains: search } }, { email: { contains: search } }, { customerNumber: { contains: search } }, ]; } const [customers, total] = await Promise.all([ prisma.customer.findMany({ where, skip, take, orderBy: { createdAt: 'desc' }, include: { addresses: { where: { isDefault: true }, take: 1 }, _count: { select: { contracts: true }, }, }, }), prisma.customer.count({ where }), ]); return { customers, pagination: buildPaginationResponse(page, limit, total), }; } export async function getCustomerById(id: number) { return prisma.customer.findUnique({ where: { id }, include: { addresses: true, bankCards: { orderBy: { isActive: 'desc' } }, identityDocuments: { orderBy: { isActive: 'desc' } }, meters: { orderBy: { isActive: 'desc' }, include: { address: true, readings: { orderBy: { readingDate: 'desc' }, }, // Verträge, die diesen Zähler aktuell als Hauptzähler nutzen // (energyDetails.meterId === meter.id) energyDetails: { include: { contract: { select: { id: true, contractNumber: true, status: true, type: true, providerName: true } }, }, }, // Verträge, in denen der Zähler in der ContractMeter-Kette steht // (Vorgänger oder Nachfolger über Zählerwechsel) contractMeters: { include: { energyContractDetails: { include: { contract: { select: { id: true, contractNumber: true, status: true, type: true, providerName: true } }, }, }, }, }, }, }, stressfreiEmails: { orderBy: { isActive: 'desc' } }, contracts: { where: { // Deaktivierte Verträge ausblenden status: { not: ContractStatus.DEACTIVATED }, }, orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }], include: { address: true, salesPlatform: true, }, }, }, }); } export async function getCustomersByIds(ids: number[]) { return prisma.customer.findMany({ where: { id: { in: ids } }, select: { id: true, portalEmail: true, }, }); } export async function createCustomer(data: { type?: CustomerType; salutation?: string; firstName: string; lastName: string; companyName?: string; birthDate?: Date; birthPlace?: string; email?: string; phone?: string; mobile?: string; taxNumber?: string; vatExempt?: boolean; businessRegistration?: string; commercialRegister?: string; notes?: string; }) { return prisma.customer.create({ data: { ...data, customerNumber: generateCustomerNumber(), }, }); } export async function updateCustomer( id: number, data: { type?: CustomerType; salutation?: string; useInformalAddress?: boolean; firstName?: string; lastName?: string; companyName?: string; birthDate?: Date; birthPlace?: string; email?: string; phone?: string; mobile?: string; taxNumber?: string; vatExempt?: boolean; businessRegistration?: string; commercialRegister?: string; notes?: string; autoBirthdayGreeting?: boolean; autoBirthdayChannel?: string | null; } ) { return prisma.customer.update({ where: { id }, data, }); } export async function deleteCustomer(id: number) { // Vor dem Löschen: Alle Dokumente (Dateien) des Kunden löschen const customer = await prisma.customer.findUnique({ where: { id }, select: { businessRegistrationPath: true, commercialRegisterPath: true, privacyPolicyPath: true }, }); const bankCards = await prisma.bankCard.findMany({ where: { customerId: id }, select: { documentPath: true }, }); const identityDocs = await prisma.identityDocument.findMany({ where: { customerId: id }, select: { documentPath: true }, }); // Kundendokumente löschen if (customer) { deleteUploadByRelativePath(customer.businessRegistrationPath); deleteUploadByRelativePath(customer.commercialRegisterPath); deleteUploadByRelativePath(customer.privacyPolicyPath); } // Bankkarten- und Ausweisdokumente löschen for (const card of bankCards) { deleteUploadByRelativePath(card.documentPath); } for (const doc of identityDocs) { deleteUploadByRelativePath(doc.documentPath); } // Jetzt DB-Eintrag löschen (Cascade löscht die verknüpften Einträge) return prisma.customer.delete({ where: { id }, }); } // Address operations export async function getCustomerAddresses(customerId: number) { return prisma.address.findMany({ where: { customerId }, orderBy: [{ isDefault: 'desc' }, { createdAt: 'desc' }], }); } // Feld-Whitelist für Address-Create/Update (Mass-Assignment-Schutz, konsistent // zu R156; autoritativ aus den DB-Spalten abgeleitet). BEWUSST NICHT enthalten: // id/customerId/createdAt/updatedAt. Enthält die Eigentümer-Felder (owner*) – // die waren beim ersten R156-Umbau versehentlich rausgefallen (Regression) – // sowie die neuen MaLo-IDs. const ADDRESS_SCALAR_FIELDS = [ 'type', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'isDefault', 'maloIdElectricity', 'maloIdGas', 'ownerCompany', 'ownerFirstName', 'ownerLastName', 'ownerStreet', 'ownerHouseNumber', 'ownerPostalCode', 'ownerCity', 'ownerPhone', 'ownerMobile', 'ownerEmail', ] as const; function pickAddressScalars(data: unknown): Record { const src = (data && typeof data === 'object') ? (data as Record) : {}; const out: Record = {}; for (const key of ADDRESS_SCALAR_FIELDS) { if (key in src) out[key] = src[key]; } return out; } export async function createAddress( customerId: number, data: { type: 'DELIVERY_RESIDENCE' | 'BILLING'; street: string; houseNumber: string; postalCode: string; city: string; country?: string; isDefault?: boolean; } ) { // If this is set as default, unset other defaults of same type if (data.isDefault) { await prisma.address.updateMany({ where: { customerId, type: data.type }, data: { isDefault: false }, }); } // Feld-Whitelist (Mass-Assignment-Schutz) inkl. owner*/MaLo – siehe // ADDRESS_SCALAR_FIELDS. customerId kommt ausschließlich aus dem Parameter. return prisma.address.create({ data: { customerId, ...(pickAddressScalars(data) as any), }, }); } export async function updateAddress( id: number, data: { type?: 'DELIVERY_RESIDENCE' | 'BILLING'; street?: string; houseNumber?: string; postalCode?: string; city?: string; country?: string; isDefault?: boolean; } ) { const address = await prisma.address.findUnique({ where: { id } }); if (!address) throw new Error('Adresse nicht gefunden'); if (data.isDefault) { await prisma.address.updateMany({ where: { customerId: address.customerId, type: data.type || address.type, id: { not: id }, }, data: { isDefault: false }, }); } // Feld-Whitelist (Mass-Assignment-Schutz) inkl. owner*/MaLo – siehe // ADDRESS_SCALAR_FIELDS. return prisma.address.update({ where: { id }, data: pickAddressScalars(data) as any, }); } export async function deleteAddress(id: number) { return prisma.address.delete({ where: { id } }); } // Bank card operations export async function getCustomerBankCards( customerId: number, showInactive: boolean = false ) { const where: Record = { customerId }; if (!showInactive) { where.isActive = true; } return prisma.bankCard.findMany({ where, orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }], }); } export async function createBankCard( customerId: number, data: { accountHolder: string; iban: string; cardNumber?: string; bic?: string; bankName?: string; expiryDate?: Date; description?: string; } ) { // Explizite Feld-Whitelist statt `...data`-Spread: verhindert Mass-Assignment // (Pentest R155). Sonst könnte roher req.body u.a. `customerId`/`id` setzen. return prisma.bankCard.create({ data: { customerId, accountHolder: data.accountHolder, iban: data.iban, cardNumber: data.cardNumber, bic: data.bic, bankName: data.bankName, expiryDate: data.expiryDate, description: data.description, isActive: true, }, }); } export async function updateBankCard( id: number, data: { accountHolder?: string; iban?: string; cardNumber?: string; bic?: string; bankName?: string; expiryDate?: Date; description?: string; isActive?: boolean; } ) { // Explizite Feld-Whitelist statt `data`-Passthrough: verhindert Mass- // Assignment (Pentest R155 – roher req.body konnte `customerId`/`id` // mutieren). Undefined-Felder ignoriert Prisma → Partial-Update bleibt. return prisma.bankCard.update({ where: { id }, data: { accountHolder: data.accountHolder, iban: data.iban, cardNumber: data.cardNumber, bic: data.bic, bankName: data.bankName, expiryDate: data.expiryDate, description: data.description, isActive: data.isActive, }, }); } export async function deleteBankCard(id: number) { // Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen const bankCard = await prisma.bankCard.findUnique({ where: { id } }); if (bankCard?.documentPath) { deleteUploadByRelativePath(bankCard.documentPath); } return prisma.bankCard.delete({ where: { id } }); } // Identity document operations export async function getCustomerDocuments( customerId: number, showInactive: boolean = false ) { const where: Record = { customerId }; if (!showInactive) { where.isActive = true; } return prisma.identityDocument.findMany({ where, orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }], }); } export async function createDocument( customerId: number, data: { type: 'ID_CARD' | 'PASSPORT' | 'DRIVERS_LICENSE' | 'OTHER'; documentNumber: string; issuingAuthority?: string; issueDate?: Date; expiryDate?: Date; licenseClasses?: string; licenseIssueDate?: Date; } ) { // Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155). return prisma.identityDocument.create({ data: { customerId, type: data.type, documentNumber: data.documentNumber, issuingAuthority: data.issuingAuthority, issueDate: data.issueDate, expiryDate: data.expiryDate, licenseClasses: data.licenseClasses, licenseIssueDate: data.licenseIssueDate, isActive: true, }, }); } export async function updateDocument( id: number, data: { type?: 'ID_CARD' | 'PASSPORT' | 'DRIVERS_LICENSE' | 'OTHER'; documentNumber?: string; issuingAuthority?: string; issueDate?: Date; expiryDate?: Date; licenseClasses?: string; licenseIssueDate?: Date; isActive?: boolean; } ) { // Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155). return prisma.identityDocument.update({ where: { id }, data: { type: data.type, documentNumber: data.documentNumber, issuingAuthority: data.issuingAuthority, issueDate: data.issueDate, expiryDate: data.expiryDate, licenseClasses: data.licenseClasses, licenseIssueDate: data.licenseIssueDate, isActive: data.isActive, }, }); } export async function deleteDocument(id: number) { // Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen const document = await prisma.identityDocument.findUnique({ where: { id } }); if (document?.documentPath) { deleteUploadByRelativePath(document.documentPath); } return prisma.identityDocument.delete({ where: { id } }); } // Meter operations export async function getCustomerMeters( customerId: number, showInactive: boolean = false ) { const where: Record = { customerId }; if (!showInactive) { where.isActive = true; } return prisma.meter.findMany({ where, include: { address: true, readings: { orderBy: { readingDate: 'desc' }, take: 5, }, }, orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }], }); } // Schreibt den Endstand des Vorgänger-Zählers beim Zählerwechsel als // MeterReading. Wird beim Folgezähler-Anlegen aufgerufen (sowohl aus der // Kundenakte als auch aus der Vertragsansicht). Idempotent: existiert am // Wechseltag schon ein Reading, wird nichts angelegt. Validierung // monoton-steigend wird durchgereicht – wirft bei Konflikt. export async function recordPredecessorFinalReading( predecessorMeterId: number, switchAt: Date, value: number, ) { const meter = await prisma.meter.findUnique({ where: { id: predecessorMeterId }, select: { type: true }, }); if (!meter) return; const dayStart = new Date(switchAt); dayStart.setHours(0, 0, 0, 0); const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1); const existingSameDay = await prisma.meterReading.findFirst({ where: { meterId: predecessorMeterId, readingDate: { gte: dayStart, lt: dayEnd } }, }); if (existingSameDay) return; await validateReadingValue(predecessorMeterId, switchAt, value, undefined, 'HT'); await prisma.meterReading.create({ data: { meterId: predecessorMeterId, readingDate: switchAt, value, unit: meter.type === 'GAS' ? 'm³' : 'kWh', notes: 'Endstand bei Zählerwechsel (automatisch beim Folgezähler-Anlegen erfasst)', }, }); } // Lieferadresse muss zum Kunden gehören und vom Typ DELIVERY_RESIDENCE sein. // Wirft eine sprechende Fehlermeldung, die der Controller dem User durchreicht. async function assertDeliveryAddressBelongsToCustomer(addressId: number, customerId: number) { const addr = await prisma.address.findUnique({ where: { id: addressId } }); if (!addr || addr.customerId !== customerId) { throw new Error('Ungültige Lieferadresse'); } if (addr.type !== 'DELIVERY_RESIDENCE') { throw new Error('Nur Lieferadressen können einem Zähler zugeordnet werden'); } } export async function createMeter( customerId: number, data: { meterNumber: string; type: 'ELECTRICITY' | 'GAS'; tariffModel?: 'SINGLE' | 'DUAL'; location?: string; addressId?: number | null; // Optional: dieser Zähler ersetzt einen bestehenden (Folgezähler). // Beim Create werden alle Verträge, die den Vorgänger als aktuellen // Zähler nutzen, automatisch auf den neuen Zähler umgestellt // (ContractMeter-Eintrag analog zu Vertragsansicht). successorOf?: { predecessorMeterId: number; installedAt?: string; finalReadingPrevious?: number; // Default true im UI: alter Zähler wird nach dem Wechsel auf // isActive=false gesetzt. Kann ausgeschaltet werden, wenn der alte // Zähler aus irgendeinem Grund noch aktiv bleiben soll. deactivatePredecessor?: boolean; }; } ) { if (data.addressId == null) { throw new Error('Lieferadresse ist erforderlich'); } await assertDeliveryAddressBelongsToCustomer(data.addressId, customerId); // Vorgänger validieren (wenn Folgezähler) let predecessor: { id: number; customerId: number; type: 'ELECTRICITY' | 'GAS' } | null = null; if (data.successorOf) { const pred = await prisma.meter.findUnique({ where: { id: data.successorOf.predecessorMeterId }, select: { id: true, customerId: true, type: true }, }); if (!pred || pred.customerId !== customerId) { throw new Error('Vorgänger-Zähler nicht gefunden'); } if (pred.type !== data.type) { throw new Error('Vorgänger-Zähler muss denselben Typ haben (Strom/Gas)'); } predecessor = pred; // Endstand bereits hier validieren, damit kein verwaister Meter entsteht // wenn der Wert mit bestehenden Zählerständen kollidiert. if (data.successorOf.finalReadingPrevious != null) { const switchAt = data.successorOf.installedAt ? new Date(data.successorOf.installedAt) : new Date(); await validateReadingValue( pred.id, switchAt, data.successorOf.finalReadingPrevious, undefined, 'HT', ); } } const created = await prisma.meter.create({ data: { customerId, meterNumber: data.meterNumber, type: data.type, tariffModel: data.tariffModel, location: data.location, addressId: data.addressId, isActive: true, predecessorMeterId: predecessor?.id, }, include: { address: true, predecessor: true }, }); // Folgezähler-Propagation: alle Verträge, die den Vorgänger als aktuellen // Zähler nutzen, bekommen den neuen Zähler als Nachfolger angehängt // (analog zu addSuccessorMeter im contract.controller). if (predecessor && data.successorOf) { const installedAt = data.successorOf.installedAt ? new Date(data.successorOf.installedAt) : new Date(); const finalReading = data.successorOf.finalReadingPrevious; const affectedContracts = await prisma.energyContractDetails.findMany({ where: { meterId: predecessor.id }, include: { contractMeters: { orderBy: { position: 'asc' } } }, }); for (const ecd of affectedContracts) { // Vorhandenen ContractMeter für den Vorgänger als gewechselt markieren. // Falls noch kein ContractMeter für den Vorgänger existiert (Single-Meter- // Vertrag vor Multi-Meter-Refactor), legen wir ihn als position 0 an, // damit die Kette lückenlos ist. let predCM = ecd.contractMeters.find((cm) => cm.meterId === predecessor!.id); if (!predCM) { predCM = await prisma.contractMeter.create({ data: { energyContractDetailsId: ecd.id, meterId: predecessor.id, position: 0, installedAt: null, }, }); ecd.contractMeters.push(predCM); } await prisma.contractMeter.update({ where: { id: predCM.id }, data: { removedAt: installedAt, finalReading: finalReading != null ? finalReading : predCM.finalReading, }, }); const nextPosition = ecd.contractMeters.length > 0 ? Math.max(...ecd.contractMeters.map((cm) => cm.position)) + 1 : 0; // Idempotenz: falls (durch Doppel-Klick o.ä.) schon ein ContractMeter // mit dem neuen Zähler existiert, nicht doppelt anlegen. const existsForNew = await prisma.contractMeter.findUnique({ where: { energyContractDetailsId_meterId: { energyContractDetailsId: ecd.id, meterId: created.id, }, }, }); if (!existsForNew) { await prisma.contractMeter.create({ data: { energyContractDetailsId: ecd.id, meterId: created.id, position: nextPosition, installedAt, }, }); } // Aktuellen Zähler am Vertrag aktualisieren await prisma.energyContractDetails.update({ where: { id: ecd.id }, data: { meterId: created.id }, }); } // Endstand des Vorgängers als regulären Zählerstand erfassen, damit er // in die Verbrauchsberechnung einfließt und in der Zählerstände-Liste // sichtbar ist. Idempotent gegen Doppel-Submit. if (data.successorOf.finalReadingPrevious != null) { await recordPredecessorFinalReading( predecessor.id, installedAt, data.successorOf.finalReadingPrevious, ); } // Alten Zähler deaktivieren (Default), sofern der Aufrufer das nicht // explizit auf false setzt. Macht den typischen Zählerwechsel-Workflow // ein-klick-fähig. if (data.successorOf.deactivatePredecessor !== false) { await prisma.meter.update({ where: { id: predecessor.id }, data: { isActive: false }, }); } } return created; } export async function updateMeter( id: number, data: { meterNumber?: string; type?: 'ELECTRICITY' | 'GAS'; tariffModel?: 'SINGLE' | 'DUAL'; location?: string; isActive?: boolean; addressId?: number | null; } ) { if (data.addressId !== undefined && data.addressId !== null) { const meter = await prisma.meter.findUnique({ where: { id }, select: { customerId: true } }); if (!meter) throw new Error('Zähler nicht gefunden'); await assertDeliveryAddressBelongsToCustomer(data.addressId, meter.customerId); } return prisma.meter.update({ where: { id }, data, include: { address: true }, }); } export async function deleteMeter(id: number) { // Prüfen ob der Zähler noch an Verträgen hängt const linkedContracts = await prisma.contractMeter.findMany({ where: { meterId: id }, include: { energyContractDetails: { include: { contract: { select: { contractNumber: true } } } } }, }); if (linkedContracts.length > 0) { const contractNumbers = linkedContracts .map(cm => cm.energyContractDetails.contract.contractNumber) .join(', '); throw new Error(`Zähler kann nicht gelöscht werden – noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`); } // Auch direkte meterId-Referenz auf EnergyContractDetails prüfen const directLinks = await prisma.energyContractDetails.findMany({ where: { meterId: id }, include: { contract: { select: { contractNumber: true } } }, }); if (directLinks.length > 0) { const contractNumbers = directLinks.map(d => d.contract.contractNumber).join(', '); throw new Error(`Zähler kann nicht gelöscht werden – noch an Vertrag/Verträgen zugeordnet: ${contractNumbers}`); } return prisma.meter.delete({ where: { id } }); } export async function addMeterReading( meterId: number, data: { readingDate: Date; value: number; valueNt?: number; unit?: string; notes?: string; } ) { // Validierung: Zählerstand muss monoton steigend sein await validateReadingValue(meterId, data.readingDate, data.value, undefined, 'HT'); if (data.valueNt !== undefined) { await validateReadingValue(meterId, data.readingDate, data.valueNt, undefined, 'NT'); } return prisma.meterReading.create({ data: { meterId, readingDate: data.readingDate, value: data.value, valueNt: data.valueNt, unit: data.unit, notes: data.notes, }, }); } export async function getMeterReadings(meterId: number) { return prisma.meterReading.findMany({ where: { meterId }, orderBy: { readingDate: 'desc' }, }); } export async function updateMeterReading( meterId: number, readingId: number, data: { readingDate?: Date; value?: number; valueNt?: number | null; unit?: string; notes?: string; } ) { // Verify the reading belongs to the meter const reading = await prisma.meterReading.findFirst({ where: { id: readingId, meterId }, }); if (!reading) { throw new Error('Zählerstand nicht gefunden'); } // Validierung bei Wertänderung if (data.value !== undefined || data.readingDate !== undefined) { await validateReadingValue( meterId, data.readingDate || reading.readingDate, data.value ?? reading.value, readingId, 'HT' ); } if (data.valueNt !== undefined || data.readingDate !== undefined) { const ntVal = data.valueNt ?? reading.valueNt; if (ntVal !== undefined && ntVal !== null) { await validateReadingValue( meterId, data.readingDate || reading.readingDate, ntVal, readingId, 'NT' ); } } return prisma.meterReading.update({ where: { id: readingId }, data, }); } /** * Validiert, dass ein Zählerstand monoton steigend ist. * tariffLabel: 'HT' für Hochtarif/Eintarif, 'NT' für Niedertarif */ async function validateReadingValue(meterId: number, readingDate: Date, value: number, excludeReadingId?: number, tariffLabel: 'HT' | 'NT' = 'HT') { const existing = await prisma.meterReading.findMany({ where: { meterId, ...(excludeReadingId ? { id: { not: excludeReadingId } } : {}) }, orderBy: { readingDate: 'asc' }, }); const fmtDate = (d: Date) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); const fmtVal = (v: number) => v.toLocaleString('de-DE'); const label = tariffLabel === 'NT' ? 'NT-Zählerstand' : 'Zählerstand'; // Vergleichswert aus bestehendem Reading extrahieren const getVal = (r: typeof existing[0]) => tariffLabel === 'NT' ? (r.valueNt ?? 0) : r.value; // Stand vor dem neuen Datum const before = [...existing].filter(r => r.readingDate <= readingDate).pop(); if (before && value < getVal(before)) { throw new Error(`${label} (${fmtVal(value)}) darf nicht kleiner sein als der Stand vom ${fmtDate(before.readingDate)} (${fmtVal(getVal(before))})`); } // Stand nach dem neuen Datum const after = existing.find(r => r.readingDate > readingDate); if (after && value > getVal(after)) { throw new Error(`${label} (${fmtVal(value)}) darf nicht größer sein als der spätere Stand vom ${fmtDate(after.readingDate)} (${fmtVal(getVal(after))})`); } } export async function deleteMeterReading(meterId: number, readingId: number) { // Verify the reading belongs to the meter const reading = await prisma.meterReading.findFirst({ where: { id: readingId, meterId }, }); if (!reading) { throw new Error('Zählerstand nicht gefunden'); } return prisma.meterReading.delete({ where: { id: readingId }, }); } // ==================== PORTAL SETTINGS ==================== export async function updatePortalSettings( customerId: number, data: { portalEnabled?: boolean; portalEmail?: string | null; } ) { // Wenn Portal deaktiviert wird, Passwort-Hash nicht löschen (für spätere Reaktivierung) return prisma.customer.update({ where: { id: customerId }, data: { portalEnabled: data.portalEnabled, portalEmail: data.portalEmail, }, select: { id: true, portalEnabled: true, portalEmail: true, portalLastLogin: true, }, }); } export async function getPortalSettings(customerId: number) { return prisma.customer.findUnique({ where: { id: customerId }, select: { id: true, portalEnabled: true, portalEmail: true, portalLastLogin: true, portalPasswordHash: true, // Nur um zu prüfen ob Passwort gesetzt (wird als boolean zurückgegeben) }, }); } // ==================== REPRESENTATIVE MANAGEMENT ==================== export async function getCustomerRepresentatives(customerId: number) { // Holt alle Kunden, die der angegebene Kunde vertreten kann (dieser ist der Vertreter) return prisma.customerRepresentative.findMany({ where: { representativeId: customerId, isActive: true }, include: { customer: { select: { id: true, customerNumber: true, firstName: true, lastName: true, companyName: true, type: true, }, }, }, orderBy: { createdAt: 'desc' }, }); } export async function getRepresentedByList(customerId: number) { // Holt alle Kunden, die den angegebenen Kunden vertreten können. // `portalEnabled` mit rausgeben, damit die UI ein Badge zeigen kann, // falls der Vertreter noch keinen Portal-Zugang hat (Vertretung ist // dann formal eingetragen, greift aber erst mit aktivem Portal). return prisma.customerRepresentative.findMany({ where: { customerId: customerId, isActive: true }, include: { representative: { select: { id: true, customerNumber: true, firstName: true, lastName: true, companyName: true, type: true, portalEnabled: true, }, }, }, orderBy: { createdAt: 'desc' }, }); } export async function addRepresentative( customerId: number, // Der Kunde, dessen Verträge eingesehen werden dürfen representativeId: number, // Der Kunde, der einsehen darf notes?: string ) { // Prüfen, ob beide Kunden existieren const [customer, representative] = await Promise.all([ prisma.customer.findUnique({ where: { id: customerId } }), prisma.customer.findUnique({ where: { id: representativeId } }), ]); if (!customer) { throw new Error('Kunde nicht gefunden'); } if (!representative) { throw new Error('Vertreter-Kunde nicht gefunden'); } if (customerId === representativeId) { throw new Error('Ein Kunde kann sich nicht selbst vertreten'); } // Portal-Zwang entfernt (2026-07-03): der Vertreter darf ohne Portal // eingetragen werden; die Beziehung ist erst dann effektiv aktiv, // wenn der Vertreter einen Portal-Zugang bekommt (die Frontend-UI // zeigt in dem Fall ein Warn-Badge). return prisma.customerRepresentative.upsert({ where: { customerId_representativeId: { customerId, representativeId }, }, create: { customerId, representativeId, notes, isActive: true, }, update: { isActive: true, notes, }, include: { representative: { select: { id: true, customerNumber: true, firstName: true, lastName: true, companyName: true, type: true, }, }, }, }); } export async function removeRepresentative(customerId: number, representativeId: number) { // Anstatt zu löschen, setzen wir isActive auf false return prisma.customerRepresentative.update({ where: { customerId_representativeId: { customerId, representativeId }, }, data: { isActive: false }, }); } export async function searchCustomersForRepresentative(search: string, excludeCustomerId: number) { // Sucht Kunden, die als Vertreter hinzugefügt werden können. // 2026-07-03: Portal-Filter entfernt – der Admin soll auch Kunden ohne // Portal-Zugang als Vertreter markieren können; das Portal wird dann // typischerweise nachträglich aktiviert. Der Zugriff wird ohnehin erst // aktiv, sobald das Portal für den Vertreter angeschaltet ist – die UI // zeigt jetzt ein Warn-Badge, wenn der Vertreter noch kein Portal hat. return prisma.customer.findMany({ where: { id: { not: excludeCustomerId }, OR: [ { firstName: { contains: search } }, { lastName: { contains: search } }, { companyName: { contains: search } }, { customerNumber: { contains: search } }, ], }, select: { id: true, customerNumber: true, firstName: true, lastName: true, companyName: true, type: true, portalEnabled: true, }, take: 10, }); } // ==================== SALUTATION PREFERENCE ==================== // Per-User "Du"/"Sie" pro Kunde. Fehlender Eintrag ⇒ Kunden-Default // (Customer.useInformalAddress). Nur Mitarbeiter-User, keine Portal-User. export type SalutationPreference = 'DU' | 'SIE'; function isValidPreference(v: unknown): v is SalutationPreference { return v === 'DU' || v === 'SIE'; } /** * Wirft ApiError(404), wenn kein Kunde mit dieser ID existiert. Vor * jedem Salutation-Preference-Zugriff aufrufen – ohne den Check würde * `upsert`/`deleteMany` einen Prisma-FK-Error werfen, den der Controller * als 500 mappt (Pentest R104.1). */ async function assertCustomerExists(customerId: number): Promise { const exists = await prisma.customer.findUnique({ where: { id: customerId }, select: { id: true }, }); if (!exists) { throw new ApiError(404, 'Kunde nicht gefunden'); } } /** * Liefert die effektive Anrede für einen User/Kunde: eigene Präferenz * bevorzugt, sonst Kunden-Default. `source` sagt der UI, ob's ein eigener * Override ist oder der Fallback greift. */ export async function getSalutationPreference( userId: number, customerId: number, ): Promise<{ preference: SalutationPreference; source: 'user' | 'customer-default'; }> { const own = await prisma.userCustomerSalutation.findUnique({ where: { userId_customerId: { userId, customerId } }, }); if (own && isValidPreference(own.preference)) { return { preference: own.preference, source: 'user' }; } const customer = await prisma.customer.findUnique({ where: { id: customerId }, select: { useInformalAddress: true }, }); if (!customer) { // Pentest R104.1 (INFO, 2026-07-04): vorher `throw new Error(...)`, // was der Controller-Catch als 500 mappte. Sauberes 404 macht die // Response semantisch korrekt (existiert nicht / gerade gelöscht). throw new ApiError(404, 'Kunde nicht gefunden'); } return { preference: customer.useInformalAddress ? 'DU' : 'SIE', source: 'customer-default', }; } export async function setSalutationPreference( userId: number, customerId: number, preference: SalutationPreference, ) { await assertCustomerExists(customerId); return prisma.userCustomerSalutation.upsert({ where: { userId_customerId: { userId, customerId } }, create: { userId, customerId, preference }, update: { preference }, }); } /** * Löscht den eigenen Override, sodass die UI wieder auf den Kunden- * Default zurückfällt. Kein 404, wenn kein Eintrag da war – das ist * idempotent gedacht ("stelle sicher, dass kein Override existiert"). */ export async function clearSalutationPreference(userId: number, customerId: number) { await assertCustomerExists(customerId); await prisma.userCustomerSalutation.deleteMany({ where: { userId, customerId }, }); }