Files
opencrm/backend/src/controllers/contract.controller.ts
T
duffyduckandClaude Opus 4.8 fc3131059b Neuer Vertragsstatus "Gekuendigt / bestaetigt" + Kuendigungs-Workflow
Bisheriges "Gekuendigt" (CANCELLED) umbenannt in "Gekuendigt / Bestaetigung
abwarten" und wird jetzt automatisch gesetzt, sobald ein Kuendigungsschreiben
hochgeladen wird. Neuer Status CANCELLED_CONFIRMED ("Gekuendigt / bestaetigt")
wird automatisch gesetzt, sobald ein Kuendigungsbestaetigungsdatum vorliegt
(Dokument fuellt das Datum oder manuell) + Vertragsende = Kuendigungsdatum.

Schema: Enum-Wert CANCELLED_CONFIRMED + Migration (idempotentes MODIFY COLUMN);
Daten-Migration hebt bestehende CANCELLED (alte Logik: nur bei Bestaetigung
gesetzt) auf CANCELLED_CONFIRMED.

Backend: neue Trigger maybeMarkAwaitingConfirmationOnLetter (Schreiben->CANCELLED)
im Upload-Handler; maybeCancelOnCancellationConfirmation setzt jetzt
CANCELLED_CONFIRMED (auch aus CANCELLED). Cockpit-Semantik mitgewandert
(Fristen-Skip/"beendet" fuer CANCELLED_CONFIRMED; Ladeliste + Kuendigungs-
bestaetigungs-Filter erweitert).

Frontend: Labels/Farben/Status-Erklaerungen + Status-Dropdown in ContractList,
ContractDetail, ContractForm, ContractDetailModal, CustomerDetail
(CANCELLED orange "abwarten", CANCELLED_CONFIRMED rot).

Verifiziert: tsc+build gruen; Schreiben->CANCELLED, Bestaetigung->
CANCELLED_CONFIRMED+Enddatum; Daten-Migration idempotent.

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

1009 lines
42 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, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.js';
import { canAccessContract, canAccessCustomer } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation, maybeCancelOnCancellationConfirmation, 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;
// Pentest 86.1/86.2 (LOW, 2026-06-19): Längen- + Whitelist-Check auf
// Kunden-/Vertrags-/Auftragsnummer-Feldern. validateContractIdentifier
// wirft ApiError(400) bei Verstoß → saubere 400-Antwort statt 500.
//
// Pentest 87.1 (LOW, 2026-06-19): Identifier-Felder MÜSSEN gegen den
// Raw-Input geprüft werden, NICHT gegen den stripHtml-Output. Sonst
// verschluckt der Sanitizer Tag-Verstöße still: `<b>bold</b>` würde
// als `"bold"` mit 200 OK durchgehen, `<script>alert(1)</script>`
// sogar zu `null` und damit den vorherigen Wert überschreiben.
// Die strikte Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) deckt alle
// Bypässe ab, die stripHtml normalerweise auffangen würde
// (Tags, Schemes, Zero-Width-Chars, Homoglyphe, Percent-Encoding)
// sie sind alle nicht in der Allowlist und fliegen mit 400 raus.
if (parentKey && isContractIdentifierField(parentKey)) {
return validateContractIdentifier(body, parentKey);
}
// Pentest 95.1/95.3/95.4 (LOWMEDIUM, 2026-06-21): portalUsername
// (Manual-Modus) hatte gar keine Validierung CRLF/Header-Injection,
// silent stripHtml-Mutation und VARCHAR-Overflow möglich. Gleiches
// Raw-Input-Pattern wie R87.
if (parentKey === 'portalUsername') {
return validatePortalUsername(body, parentKey);
}
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, includeDeactivated } = req.query;
// Baumstruktur für Kundenansicht
if (tree === 'true' && customerId) {
const customerIdNum = parseInt(customerId as string);
// Pentest R120 (CRITICAL IDOR): Der tree=true-Zweig returnte früh,
// BEVOR die Portal-User-customerIds-Filterung unten für die flache
// Liste griff. Ein Portal-User konnte damit den vollständigen
// Vertragsbaum eines beliebigen Fremdkunden lesen
// (?tree=true&customerId=<fremd>). Ownership-Check nachgezogen
// canAccessCustomer prüft eigene ID + vertretene MIT Live-Vollmacht
// und sendet selbst die 403-Response. Nicht-Portal-User (Staff)
// passieren unverändert.
if (!(await canAccessCustomer(req, res, customerIdNum))) return;
const treeData = await contractService.getContractTreeForCustomer(
customerIdNum,
includeDeactivated === 'true',
);
res.json({ success: true, data: treeData } as ApiResponse);
return;
}
// Für Kundenportal-Benutzer: nur eigene + vertretene Kunden MIT Vollmacht.
// Fail-closed (Pentest R145): Portal-Token wird IMMER gescoped; fehlt wider
// Erwarten die customerId, ergibt das eine leere Menge (nicht alle).
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal) {
customerIds = req.user.customerId ? [req.user.customerId] : [];
if (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,
includeDeactivated: includeDeactivated === 'true',
});
// 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);
// Kündigungs-Datumsfelder robust normalisieren: `validateOptionalIsoDate`
// akzeptiert Datum-only (YYYY-MM-DD) UND volles ISO und liefert einen
// sauberen Date-Wert an Prisma. Ohne das lehnte Prisma ein Datum-only
// ab (Pentest R138, Hygiene-Punkt). Konsistent zum Upload-Pfad, der
// `confirmationDate` bereits so validiert.
for (const df of ['cancellationConfirmationDate', 'cancellationConfirmationOptionsDate'] as const) {
if (df in (sanitizedBody as Record<string, unknown>)) {
let validated: string | null;
try {
validated = validateOptionalIsoDate((sanitizedBody as Record<string, unknown>)[df], df);
} catch (err) {
res.status(400).json({
success: false,
error: err instanceof Error ? err.message : 'Ungültiges Datum',
} as ApiResponse);
return;
}
(sanitizedBody as Record<string, unknown>)[df] = validated ? new Date(validated) : null;
}
}
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
// Wenn über das Formular ein (neues/geändertes) Kündigungsbestätigungs-
// datum gesetzt wurde: Vertrag → CANCELLED_CONFIRMED ("Gekündigt / bestätigt")
// + Vertragsende = Kündigungsdatum. Nur bei tatsächlicher Änderung auslösen,
// damit manuelle Status-Korrekturen nicht sofort zurückgesetzt werden.
{
const asDay = (d: unknown) =>
d ? new Date(d as string).toISOString().split('T')[0] : null;
const beforeConf = asDay((before as any)?.cancellationConfirmationDate);
const afterConf = asDay((sanitizedBody as any)?.cancellationConfirmationDate);
if (afterConf && afterConf !== beforeConf) {
await maybeCancelOnCancellationConfirmation(contractId, req);
}
}
// 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',
contractNumberAtProvider: 'Vertragsnummer beim Anbieter',
orderNumberAtSalesPlatform: 'Auftragsnummer bei Vertriebsplattform',
customerNumberAtSalesPlatform: 'Kundennummer bei Vertriebsplattform',
contractNumberAtSalesPlatform: 'Vertragsnummer bei Vertriebsplattform',
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',
portalCredentialsNotRequired: 'Portaldaten-Opt-out',
stressfreiEmailId: 'Stressfrei-/Corporate-Adresse',
};
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,
});
// Zusätzliches CRITICAL-Event für das „Portaldaten nicht benötigt"-Opt-out.
// Landet unter resourceType ContractPassword (sensitivity CRITICAL laut
// audit.service determineSensitivity) und liegt damit in derselben
// DSGVO-Reihe wie Klartext-Password-Reads. Motivation: das Setzen des
// Flags räumt server-seitig portalUsername/portalPasswordEncrypted/
// stressfreiEmailId auf NULL diese Löschung soll unabhängig vom
// generischen UPDATE-Diff nachweisbar sein. Rücknahme des Flags wird
// ebenfalls geloggt, damit die Kette in beide Richtungen konsistent ist.
if (
before
&& 'portalCredentialsNotRequired' in body
&& before.portalCredentialsNotRequired !== body.portalCredentialsNotRequired
) {
const toggledOn = body.portalCredentialsNotRequired === true;
await logChange({
req,
action: 'UPDATE',
resourceType: 'ContractPassword',
resourceId: contractId.toString(),
label: toggledOn
? `Vertrag ${before.contractNumber || contractId}: Portal-Zugangsdaten als „nicht erforderlich" markiert (Username/Passwort/Stressfrei-ID auf NULL gesetzt)`
: `Vertrag ${before.contractNumber || contractId}: „Portaldaten nicht erforderlich"-Marker zurückgenommen`,
details: {
vorher: before.portalCredentialsNotRequired,
nachher: body.portalCredentialsNotRequired,
hatUsernameVorher: !!before.portalUsername,
hatPasswortVorher: !!before.portalPasswordEncrypted,
hatStressfreiVorher: !!before.stressfreiEmailId,
},
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).
// Fail-closed (Pentest R146): Portal-Token wird IMMER gescoped; fehlt wider
// Erwarten die customerId, ergibt das eine leere Menge (nicht alle) der
// Cockpit-Service filtert `customerIds: []` bereits als „keine Treffer".
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal) {
customerIds = req.user.customerId ? [req.user.customerId] : [];
if (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 } = 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;
}
// Pentest 62.7: deliveryDate validieren (ISO-8601 oder null).
let deliveryDate: string | null;
try {
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
} catch (err) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } 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) {
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
// Schreibfehler.
const status = error instanceof ApiError ? error.statusCode : 400;
// Multer hat die Datei schon geschrieben bei Reject räumen.
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(status).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);
}
}