Files
opencrm/backend/src/controllers/contract.controller.ts
T
duffyduck 6b1d493f0b Pentest 58.1 MEDIUM: documentType jetzt mit echter Whitelist-Validierung
Bisher lief documentType nur durch stripHtml – ein beliebiger String
("NICHT_ERLAUBT", "DROP TABLE ...", Tippfehler) wurde 1:1 als
ContractDocument.documentType in die DB geschrieben. Das brach
Frontend-Filter, Lieferbestätigung-Auto-Activation und Reports.

Neuer validateContractDocumentType-Helper in utils/sanitize:
- Whitelist ALLOWED_CONTRACT_DOCUMENT_TYPES (8 Werte, gespiegelt aus
  Frontend CONTRACT_DOCUMENT_TYPES)
- Case-insensitiver Match, Rückgabe ist immer der kanonische Wert
- Wirft sprechende 400-Fehlermeldung mit Liste der erlaubten Werte

Eingesetzt in allen 3 Schreibpfaden:
- contract.controller.uploadContractDocument (multer-Datei wird bei
  Reject sauber gelöscht)
- cachedEmail.controller.saveEmailAsContractDocument
- cachedEmail.controller.saveAttachmentAsContractDocument

Audit-Log + maybeActivateOnDeliveryConfirmation nutzen jetzt den
kanonischen Wert (statt der rohen Eingabe), damit Reports
einheitlich aussehen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:53:34 +02:00

874 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 { Request, Response } from 'express';
import fs from 'fs';
import prisma from '../lib/prisma.js';
import * as contractService from '../services/contract.service.js';
import * as contractCockpitService from '../services/contractCockpit.service.js';
import * as contractHistoryService from '../services/contractHistory.service.js';
import * as authorizationService from '../services/authorization.service.js';
import { recordPredecessorFinalReading } from '../services/customer.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType } from '../utils/sanitize.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
/**
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
* eines Body-Objekts (rekursiv über energyDetails, internetDetails etc.).
* Pentest 2026-05-24 (MEDIUM, 31.1): providerName, tariffName und die
* price*-Felder nahmen rohe HTML-Payloads an (`<script>`, `<svg onload>`)
* und lieferten sie 1:1 an Portal-User zurück. Verträge enthalten KEINE
* HTML-Felder (Richtige HTML-Texte liegen in AppSettings), deshalb ist
* Strip safe.
*
* AUSNAHME: Passwort-/Secret-Felder. `stripHtml` filtert `<…>`-Sequenzen
* und URI-Schemata wie `data:`, also würde ein PW wie `Pass<TAG>word!`
* zu `Password!` mutilieren oder `data:secret` zu `blocked:secret`.
* Das Passwort wird sowieso verschlüsselt persistiert (`encrypt()`),
* niemals als HTML ausgegeben also kein XSS-Risk, und die Mangling
* ist ein Bug (2026-05-27, intern gemeldet: "Portal-Passwörter werden
* nicht gespeichert").
*/
const PASSTHROUGH_KEYS = new Set(['portalPassword', 'password']);
function sanitizeContractBody(body: unknown, parentKey?: string): unknown {
if (body === null || body === undefined) return body;
if (typeof body === 'string') {
if (parentKey && PASSTHROUGH_KEYS.has(parentKey)) return body;
return stripHtml(body);
}
if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey));
if (typeof body === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
out[k] = sanitizeContractBody(v, k);
}
return out;
}
return body;
}
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
try {
const { customerId, type, status, search, page, limit, tree } = req.query;
// Baumstruktur für Kundenansicht
if (tree === 'true' && customerId) {
const treeData = await contractService.getContractTreeForCustomer(
parseInt(customerId as string)
);
res.json({ success: true, data: treeData } as ApiResponse);
return;
}
// Für Kundenportal-Benutzer: nur eigene + vertretene Kunden MIT Vollmacht
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
// Eigene Customer-ID immer
customerIds = [req.user.customerId];
// Vertretene Kunden nur wenn Vollmacht erteilt
const representedIds: number[] = req.user.representedCustomerIds || [];
for (const repCustId of representedIds) {
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
if (hasAuth) {
customerIds.push(repCustId);
}
}
}
const result = await contractService.getAllContracts({
customerId: customerId ? parseInt(customerId as string) : undefined,
customerIds, // Wird nur für Kundenportal-Benutzer gesetzt
type: type as any,
status: status as any,
search: search as string,
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
});
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
const isPortal = !!req.user?.isCustomerPortal;
const data = isPortal
? sanitizeContractsStrict(result.contracts as any[])
: sanitizeContracts(result.contracts as any[]);
res.json({
success: true,
data,
pagination: result.pagination,
} as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Verträge',
} as ApiResponse);
}
}
export async function getContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contract = await contractService.getContractById(parseInt(req.params.id));
if (!contract) {
res.status(404).json({
success: false,
error: 'Vertrag nicht gefunden',
} as ApiResponse);
return;
}
// Für Kundenportal-Benutzer: Zugriff nur auf eigene + vertretene Kunden MIT Vollmacht
if (req.user?.isCustomerPortal && req.user.customerId) {
const allowedCustomerIds = [req.user.customerId];
const representedIds: number[] = req.user.representedCustomerIds || [];
for (const repCustId of representedIds) {
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
if (hasAuth) {
allowedCustomerIds.push(repCustId);
}
}
if (!allowedCustomerIds.includes(contract.customerId)) {
res.status(403).json({
success: false,
error: 'Kein Zugriff auf diesen Vertrag',
} as ApiResponse);
return;
}
}
const isPortal = !!req.user?.isCustomerPortal;
const data = isPortal
? sanitizeContractStrict(contract as any)
: sanitizeContract(contract as any);
res.json({ success: true, data } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden des Vertrags',
} as ApiResponse);
}
}
export async function createContract(req: AuthRequest, res: Response): Promise<void> {
try {
// Input-Validierung: type + customerId sind Pflicht, sonst stürzte der
// Service mit einer kryptischen JS-Message ab (Pentest Runde 12, INFO).
const body = (req.body || {}) as Record<string, unknown>;
if (!body.type || typeof body.type !== 'string') {
res.status(400).json({ success: false, error: 'Vertrags-Typ (type) ist erforderlich' } as ApiResponse);
return;
}
if (!body.customerId || typeof body.customerId !== 'number') {
res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse);
return;
}
const sanitizedBody = sanitizeContractBody(body);
const contract = await contractService.createContract(sanitizedBody as any);
await logChange({
req, action: 'CREATE', resourceType: 'Contract',
resourceId: contract.id.toString(),
label: `Vertrag ${contract.contractNumber} angelegt`,
customerId: contract.customerId,
});
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract 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 Vertrags',
} as ApiResponse);
}
}
export async function updateContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
// Pentest 56.3 (latent, 2026-06-01): Defense-in-Depth
// canAccessContract explizit aufrufen, statt sich nur auf die
// Route-Permission zu verlassen. Portal-User mit kompromittierter
// Token-Permission würden sonst beliebige Verträge editieren können.
if (!(await canAccessContract(req, res, contractId))) return;
// Vorherigen Stand laden für Audit-Vergleich
const before = await prisma.contract.findUnique({
where: { id: contractId },
include: { energyDetails: true, internetDetails: true, mobileDetails: true, tvDetails: true, carInsuranceDetails: true },
});
// HTML/JS-Strip auf allen String-Werten (Pentest 2026-05-24, 31.1)
const sanitizedBody = sanitizeContractBody(req.body);
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
// Geänderte Felder ermitteln
const changes: Record<string, { von: unknown; nach: unknown }> = {};
const fieldLabels: Record<string, string> = {
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
identityDocumentId: 'Ausweis', bankCardId: 'Bankverbindung', addressId: 'Adresse',
commission: 'Provision', notes: 'Notizen',
};
const energyLabels: Record<string, string> = {
meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch',
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis',
instantBonus: 'Sofort-Bonus', newCustomerBonus: 'Neukunden-Bonus',
};
// Hauptfelder vergleichen gegen die SANITISIERTE Version, damit
// das Audit-Log die echten DB-Werte widerspiegelt, nicht den
// rohen Request-Body mit ggf. gestrippter HTML.
const body = sanitizedBody as any;
if (before) {
for (const [key, newVal] of Object.entries(body)) {
if (['energyDetails', 'internetDetails', 'mobileDetails', 'tvDetails', 'carInsuranceDetails', 'password'].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;
changes[label] = { von: oldVal ?? '-', nach: newVal ?? '-' };
}
}
// Energie-Details vergleichen
if (body.energyDetails && before.energyDetails) {
for (const [key, newVal] of Object.entries(body.energyDetails)) {
const oldVal = (before.energyDetails 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 = energyLabels[key] || key;
changes[label] = { von: oldVal ?? '-', nach: newVal ?? '-' };
}
}
}
}
const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von}${c.nach}`).join(', ');
await logChange({
req, action: 'UPDATE', resourceType: 'Contract',
resourceId: contractId.toString(),
label: changeList
? `Vertrag ${before?.contractNumber || contractId} aktualisiert: ${changeList}`
: `Vertrag ${before?.contractNumber || contractId} aktualisiert`,
details: Object.keys(changes).length > 0 ? changes : undefined,
customerId: before?.customerId,
});
// Response sanitisieren sonst leakt portalPasswordEncrypted etc.
// (Pentest Runde 15, gleiche Klasse wie 20.3 für Customer).
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal
? sanitizeContractStrict(contract as any)
: sanitizeContract(contract as any);
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Vertrags',
} as ApiResponse);
}
}
export async function deleteContract(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
// Pentest 56.3 (latent): Defense-in-Depth Ownership-Check vor Delete.
if (!(await canAccessContract(req as AuthRequest, res, contractId))) return;
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
await contractService.deleteContract(contractId);
await logChange({
req, action: 'DELETE', resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${contract?.contractNumber} gelöscht`,
customerId: contract?.customerId,
});
res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Vertrags',
} as ApiResponse);
}
}
export async function createFollowUp(req: AuthRequest, res: Response): Promise<void> {
try {
const previousContractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, previousContractId))) return;
// Vorgängervertrag laden für Vertragsnummer
const previousContract = await prisma.contract.findUnique({
where: { id: previousContractId },
select: { contractNumber: true },
});
if (!previousContract) {
res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse);
return;
}
const contract = await contractService.createFollowUpContract(previousContractId);
const createdBy = req.user?.email || 'unbekannt';
// Historie-Eintrag für den Vorgängervertrag erstellen
await contractHistoryService.createFollowUpHistoryEntry(
previousContractId,
contract.contractNumber,
createdBy
);
// Historie-Eintrag für den neuen Folgevertrag erstellen
await contractHistoryService.createNewContractFromPredecessorEntry(
contract.id,
previousContract.contractNumber,
createdBy
);
await logChange({
req, action: 'CREATE', resourceType: 'Contract',
resourceId: contract.id.toString(),
label: `Folgevertrag erstellt für ${previousContract.contractNumber}`,
customerId: contract.customerId,
});
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract 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 Folgevertrags',
} as ApiResponse);
}
}
/**
* VVL = Vertragsverlängerung beim selben Anbieter.
* Erstellt einen neuen Vertrag mit allen Daten des Vorgängers (außer
* Auftragsdokument), Startdatum = altes Start + Vertragslaufzeit.
*/
export async function createRenewal(req: AuthRequest, res: Response): Promise<void> {
try {
const previousContractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, previousContractId))) return;
const previousContract = await prisma.contract.findUnique({
where: { id: previousContractId },
select: { contractNumber: true },
});
if (!previousContract) {
res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse);
return;
}
const contract = await contractService.createRenewalContract(previousContractId);
if (!contract) {
res.status(500).json({ success: false, error: 'VVL konnte nicht erstellt werden' } as ApiResponse);
return;
}
const createdBy = req.user?.email || 'unbekannt';
await contractHistoryService.createRenewalHistoryEntry(
previousContractId,
contract.contractNumber,
createdBy,
);
await contractHistoryService.createNewRenewalFromPredecessorEntry(
contract.id,
previousContract.contractNumber,
createdBy,
);
await logChange({
req, action: 'CREATE', resourceType: 'Contract',
resourceId: contract.id.toString(),
label: `VVL erstellt für ${previousContract.contractNumber}`,
customerId: contract.customerId,
});
const isPortal = !!req.user?.isCustomerPortal;
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract 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 der VVL',
} as ApiResponse);
}
}
export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const password = await contractService.getContractPassword(contractId);
if (password === null) {
res.status(404).json({
success: false,
error: 'Kein Passwort hinterlegt',
} as ApiResponse);
return;
}
// Klartext-Passwort-Read auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'ContractPassword',
resourceId: contractId.toString(),
label: `Klartext-Anbieter-Passwort von Vertrag #${contractId} entschlüsselt`,
});
res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Entschlüsseln des Passworts',
} as ApiResponse);
}
}
export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const simCardId = parseInt(req.params.simCardId);
// SimCard → MobileDetails → Contract
const sim = await prisma.simCard.findUnique({
where: { id: simCardId },
select: { mobileDetails: { select: { contractId: true } } },
});
if (!sim?.mobileDetails) {
res.status(404).json({ success: false, error: 'SIM-Karte nicht gefunden' } as ApiResponse);
return;
}
if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;
const credentials = await contractService.getSimCardCredentials(simCardId);
// Klartext-Read (PIN/PUK) auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'SimCardCredentials',
resourceId: simCardId.toString(),
label: `Klartext-SIM-Karten-PIN/PUK von SIM #${simCardId} (Vertrag #${sim.mobileDetails.contractId}) entschlüsselt`,
});
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Entschlüsseln der SIM-Karten-Daten',
} as ApiResponse);
}
}
export async function getInternetCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const credentials = await contractService.getInternetCredentials(contractId);
// Klartext-DSL/Internet-Login auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'InternetCredentials',
resourceId: contractId.toString(),
label: `Klartext-Internet-Zugangsdaten von Vertrag #${contractId} entschlüsselt`,
});
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Entschlüsseln des Internet-Passworts',
} as ApiResponse);
}
}
export async function getSipCredentials(req: AuthRequest, res: Response): Promise<void> {
try {
const phoneNumberId = parseInt(req.params.phoneNumberId);
// PhoneNumber → InternetDetails → Contract
const phone = await prisma.phoneNumber.findUnique({
where: { id: phoneNumberId },
select: { internetDetails: { select: { contractId: true } } },
});
if (!phone?.internetDetails) {
res.status(404).json({ success: false, error: 'Rufnummer nicht gefunden' } as ApiResponse);
return;
}
if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;
const credentials = await contractService.getSipCredentials(phoneNumberId);
// Klartext-SIP/Telefon-Login auditieren (CRITICAL)
await logChange({
req,
action: 'READ',
resourceType: 'SipCredentials',
resourceId: phoneNumberId.toString(),
label: `Klartext-SIP-Zugangsdaten von Rufnummer #${phoneNumberId} (Vertrag #${phone.internetDetails.contractId}) entschlüsselt`,
});
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Entschlüsseln des SIP-Passworts',
} as ApiResponse);
}
}
// ==================== VERTRAGS-COCKPIT ====================
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
try {
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen.
// Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden
// (Pentest Runde 4, 2026-05-16: HOCH).
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
customerIds = [req.user.customerId];
const representedIds: number[] = req.user.representedCustomerIds || [];
for (const repCustId of representedIds) {
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
if (hasAuth) {
customerIds.push(repCustId);
}
}
}
const cockpitData = await contractCockpitService.getCockpitData({ customerIds });
res.json({ success: true, data: cockpitData } as ApiResponse);
} catch (error) {
console.error('Cockpit error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Laden des Vertrags-Cockpits',
} as ApiResponse);
}
}
// ==================== FOLGEZÄHLER ====================
export async function addSuccessorMeter(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
const { meterId, installedAt, finalReadingPrevious, deactivatePredecessor } = req.body;
const contract = await prisma.contract.findUnique({
where: { id: contractId },
include: { energyDetails: { include: { contractMeters: { orderBy: { position: 'asc' } } } } },
});
if (!contract?.energyDetails) {
res.status(404).json({ success: false, error: 'Energievertrag nicht gefunden' } as ApiResponse);
return;
}
const ecdId = contract.energyDetails.id;
const existingMeters = [...contract.energyDetails.contractMeters];
const switchAt = installedAt ? new Date(installedAt) : new Date();
// Vorgänger ermitteln (letzter ContractMeter oder Single-Meter-Vertrag)
const predecessorMeterId = existingMeters.length > 0
? existingMeters[existingMeters.length - 1].meterId
: contract.energyDetails.meterId;
// Endstand bereits hier validieren (monoton-steigend gegen vorhandene
// Zählerstände des Vorgängers), damit wir nicht halb-geschriebene
// Zustände hinterlassen.
if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) {
const finalReadingValue = parseFloat(finalReadingPrevious);
// recordPredecessorFinalReading läuft erst NACH den Writes Pre-Check
// ohne Write hier separat über die Service-Validierung (idempotent, weil
// sie keinen Reading anlegt, wenn am Wechseltag schon einer existiert).
// Wir lassen den eigentlichen Write am Ende laufen, damit ein Fehler
// beim Reading die Kette nicht zerreißt.
const dayStart = new Date(switchAt); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1);
const sameDay = await prisma.meterReading.findFirst({
where: { meterId: predecessorMeterId, readingDate: { gte: dayStart, lt: dayEnd } },
});
if (!sameDay) {
const lastBefore = await prisma.meterReading.findFirst({
where: { meterId: predecessorMeterId, readingDate: { lte: switchAt } },
orderBy: { readingDate: 'desc' },
});
if (lastBefore && finalReadingValue < lastBefore.value) {
const fmtDate = (d: Date) => d.toLocaleDateString('de-DE');
throw new Error(`Endstand (${finalReadingValue}) darf nicht kleiner sein als der Stand vom ${fmtDate(lastBefore.readingDate)} (${lastBefore.value})`);
}
}
}
// Backfill: Bei Single-Meter-Verträgen (kein ContractMeter-Eintrag) den
// bisherigen `energyDetails.meterId` als position 0 nachtragen, damit die
// Folgezähler-Kette lückenlos ist und der alte Zähler nicht aus dem
// Vertrag verschwindet, wenn `energyDetails.meterId` gleich auf den Neuen
// gedreht wird.
if (existingMeters.length === 0 && contract.energyDetails.meterId) {
const backfilled = await prisma.contractMeter.create({
data: {
energyContractDetailsId: ecdId,
meterId: contract.energyDetails.meterId,
position: 0,
installedAt: null,
},
});
existingMeters.push(backfilled);
}
const nextPosition = existingMeters.length > 0
? Math.max(...existingMeters.map(m => m.position)) + 1
: 0;
// Vorherigen Zähler als gewechselt markieren
if (existingMeters.length > 0) {
const prevMeter = existingMeters[existingMeters.length - 1];
await prisma.contractMeter.update({
where: { id: prevMeter.id },
data: {
removedAt: switchAt,
finalReading: finalReadingPrevious !== undefined
? parseFloat(finalReadingPrevious)
: prevMeter.finalReading,
},
});
}
const contractMeter = await prisma.contractMeter.create({
data: {
energyContractDetailsId: ecdId,
meterId: parseInt(meterId),
position: nextPosition,
installedAt: switchAt,
},
include: { meter: { include: { readings: true } } },
});
// Aktuellen Zähler am Vertrag aktualisieren
await prisma.energyContractDetails.update({
where: { id: ecdId },
data: { meterId: parseInt(meterId) },
});
// Endstand des Vorgängers als regulären Zählerstand erfassen, damit er in
// die Verbrauchsberechnung einfließt und in der Zählerstände-Liste auftaucht.
if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) {
await recordPredecessorFinalReading(
predecessorMeterId,
switchAt,
parseFloat(finalReadingPrevious),
);
}
// Alten Zähler deaktivieren (Default), sofern der Aufrufer das nicht
// explizit auf false setzt ein-klick-fähiger Zählerwechsel.
if (predecessorMeterId && deactivatePredecessor !== false) {
await prisma.meter.update({
where: { id: predecessorMeterId },
data: { isActive: false },
});
}
await logChange({
req, action: 'CREATE', resourceType: 'ContractMeter',
resourceId: contractMeter.id.toString(),
label: `Folgezähler hinzugefügt zu Vertrag #${contractId}`,
customerId: contract.customerId,
});
res.json({ success: true, data: contractMeter } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers',
} as ApiResponse);
}
}
export async function removeContractMeter(req: AuthRequest, res: Response): Promise<void> {
try {
const contractMeterId = parseInt(req.params.contractMeterId);
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
await prisma.contractMeter.delete({ where: { id: contractMeterId } });
await logChange({
req, action: 'DELETE', resourceType: 'ContractMeter',
resourceId: contractMeterId.toString(),
label: `Folgezähler entfernt von Vertrag #${contractId}`,
});
res.json({ success: true, data: null } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Entfernen',
} as ApiResponse);
}
}
// ==================== VERTRAGSDOKUMENTE ====================
export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const documents = await prisma.contractDocument.findMany({
where: { contractId },
orderBy: { createdAt: 'desc' },
});
res.json({ success: true, data: documents } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Dokumente' } as ApiResponse);
}
}
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const { documentType, notes, deliveryDate } = req.body;
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
return;
}
if (!documentType) {
res.status(400).json({ success: false, error: 'Dokumenttyp erforderlich' } as ApiResponse);
return;
}
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
// Pentest 58.1: Whitelist-Validierung statt nur stripHtml. Multer hat
// die Datei schon geschrieben bei Reject räumen wir sie wieder weg.
let cleanType: string;
try {
cleanType = validateContractDocumentType(documentType);
} catch (err) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger Dokumenttyp' } as ApiResponse);
return;
}
// Pentest 55.4: Race-Schutz Lock + Recent-Duplicate-Check.
const doc = await withContractDocumentLock(contractId, cleanType, () =>
prisma.contractDocument.create({
data: {
contractId,
documentType: cleanType,
documentPath,
originalName: req.file!.originalname,
notes: sanitizeNotes(notes),
uploadedBy: req.user?.email,
},
}),
);
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
await logChange({
req, action: 'CREATE', resourceType: 'ContractDocument',
resourceId: doc.id.toString(),
label: `Dokument "${cleanType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
details: { typ: cleanType, datei: req.file.originalname },
customerId: contract?.customerId,
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
await maybeActivateOnDeliveryConfirmation(contractId, cleanType, req, deliveryDate);
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 Hochladen',
} as ApiResponse);
}
}
export async function deleteContractDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const documentId = parseInt(req.params.documentId);
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
if (!doc || doc.contractId !== contractId) {
res.status(404).json({ success: false, error: 'Dokument nicht gefunden' } as ApiResponse);
return;
}
// Datei löschen
const fs = await import('fs');
const path = await import('path');
const filePath = path.join(process.cwd(), doc.documentPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
await prisma.contractDocument.delete({ where: { id: documentId } });
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
await logChange({
req, action: 'DELETE', resourceType: 'ContractDocument',
resourceId: documentId.toString(),
label: `Dokument "${doc.documentType}" gelöscht von Vertrag ${contract?.contractNumber}`,
details: { typ: doc.documentType, datei: doc.originalName },
customerId: contract?.customerId,
});
res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
} as ApiResponse);
}
}
// ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ====================
export async function snoozeContract(req: AuthRequest, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessContract(req, res, id))) return;
const { nextReviewDate, months } = req.body;
let reviewDate: Date | null = null;
if (nextReviewDate) {
// Explizites Datum angegeben
reviewDate = new Date(nextReviewDate);
} else if (months) {
// Monate angegeben → berechne Datum
reviewDate = new Date();
reviewDate.setMonth(reviewDate.getMonth() + months);
}
// Wenn beides leer → nextReviewDate wird auf null gesetzt (Snooze aufheben)
const updated = await prisma.contract.update({
where: { id },
data: { nextReviewDate: reviewDate },
select: {
id: true,
contractNumber: true,
nextReviewDate: true,
},
});
await logChange({
req, action: 'UPDATE', resourceType: 'Contract',
resourceId: id.toString(),
label: `Vertrag ${updated.contractNumber} zurückgestellt`,
});
res.json({
success: true,
data: updated,
message: reviewDate
? `Vertrag zurückgestellt bis ${reviewDate.toLocaleDateString('de-DE')}`
: 'Zurückstellung aufgehoben',
} as ApiResponse);
} catch (error) {
console.error('Snooze error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Zurückstellen des Vertrags',
} as ApiResponse);
}
}