6b1d493f0b
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>
874 lines
34 KiB
TypeScript
874 lines
34 KiB
TypeScript
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);
|
||
}
|
||
}
|