Files
opencrm/backend/src/services/customer.service.ts
T
duffyduckandClaude Opus 4.8 1a45c24abf MaLo-ID an die Lieferadresse (Strom/Gas) + Address-owner-Regression gefixt
MaLo-ID (Marktlokation) gehoert zur (Liefer-)Adresse, nicht zum Vertrag.
Address bekommt maloIdElectricity + maloIdGas (getrennte Marktlokationen je
Sparte), pflegbar im AddressModal (nur Lieferadresse). Im Vertrag ist die
MaLo-ID jetzt ein Lesefeld, das je nach Vertragstyp die MaLo der gewaehlten
Lieferadresse zeigt; ContractDetail/-Modal ebenso.

Schema + Migration 20260814100000: 2 Spalten (idempotent) + Daten-Migration
(bestehende EnergyContractDetails.maloId -> jeweilige Lieferadresse,
ELECTRICITY->maloIdElectricity / GAS->maloIdGas). Migrationslogik verifiziert.

Dabei einen selbst verursachten Regressions-Bug gefixt: beim R156-Umbau waren
die 10 owner*-Adressfelder aus der Address-Whitelist gefallen -> Eigentuemer-
Sektion speicherte seit cb21a2c nicht mehr. Address-Whitelist jetzt via
Pick-Helper, programmatisch gegen alle DB-Spalten abgeglichen (owner* + MaLo
drin, id/customerId/Timestamps raus). BankCard/Document gegengeprueft: ok
(nur documentPath bewusst upload-only ausgeschlossen).

Verifiziert: tsc+build gruen; owner + maloId speichern wieder, Injection
(id/customerId) blockiert; Daten-Migration Strom->Strom / Gas->Gas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 14:38:16 +02:00

1160 lines
34 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 { 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<string, unknown> = {};
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<string, unknown> {
const src = (data && typeof data === 'object') ? (data as Record<string, unknown>) : {};
const out: Record<string, unknown> = {};
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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<void> {
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 },
});
}