gdpr audit implemented, email log, vollmachten, pdf delete cancel data privacy and vollmachten, removed message no id card in engergy car, and other contracts that are not telecom contracts, added insert counter for engery

This commit is contained in:
2026-03-21 11:59:53 +01:00
parent 89cf92eaf5
commit f2876f877e
1491 changed files with 265550 additions and 1292 deletions
@@ -0,0 +1,201 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as auditService from '../services/audit.service.js';
import { AuditAction, AuditSensitivity } from '@prisma/client';
/**
* Audit-Logs mit Filtern abrufen
*/
export async function getAuditLogs(req: AuthRequest, res: Response) {
try {
const {
userId,
customerId,
dataSubjectId,
action,
sensitivity,
resourceType,
resourceId,
startDate,
endDate,
success,
search,
page,
limit,
} = req.query;
const result = await auditService.searchAuditLogs({
userId: userId ? parseInt(userId as string) : undefined,
customerId: customerId ? parseInt(customerId as string) : undefined,
dataSubjectId: dataSubjectId ? parseInt(dataSubjectId as string) : undefined,
action: action as AuditAction | undefined,
sensitivity: sensitivity as AuditSensitivity | undefined,
resourceType: resourceType as string | undefined,
resourceId: resourceId as string | undefined,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
success: success !== undefined ? success === 'true' : undefined,
search: search as string | undefined,
page: page ? parseInt(page as string) : 1,
limit: limit ? parseInt(limit as string) : 50,
});
res.json({ success: true, ...result });
} catch (error) {
console.error('Fehler beim Abrufen der Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
}
}
/**
* Einzelnes Audit-Log abrufen
*/
export async function getAuditLogById(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const log = await auditService.getAuditLogById(id);
if (!log) {
return res.status(404).json({ success: false, error: 'Audit-Log nicht gefunden' });
}
res.json({ success: true, data: log });
} catch (error) {
console.error('Fehler beim Abrufen des Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen des Audit-Logs' });
}
}
/**
* Audit-Logs für einen Kunden abrufen (DSGVO)
*/
export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const logs = await auditService.getAuditLogsByDataSubject(customerId);
res.json({ success: true, data: logs });
} catch (error) {
console.error('Fehler beim Abrufen der Kunden-Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
}
}
/**
* Audit-Logs exportieren
*/
export async function exportAuditLogs(req: AuthRequest, res: Response) {
try {
const format = (req.query.format as 'json' | 'csv') || 'json';
const {
action,
sensitivity,
resourceType,
startDate,
endDate,
} = req.query;
const content = await auditService.exportAuditLogs(
{
action: action as AuditAction | undefined,
sensitivity: sensitivity as AuditSensitivity | undefined,
resourceType: resourceType as string | undefined,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
},
format
);
const contentType = format === 'csv' ? 'text/csv' : 'application/json';
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.${format}`;
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(content);
} catch (error) {
console.error('Fehler beim Exportieren der Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Exportieren' });
}
}
/**
* Hash-Ketten-Integrität prüfen
*/
export async function verifyIntegrity(req: AuthRequest, res: Response) {
try {
const { fromId, toId } = req.query;
const result = await auditService.verifyIntegrity(
fromId ? parseInt(fromId as string) : undefined,
toId ? parseInt(toId as string) : undefined
);
res.json({
success: true,
data: {
valid: result.valid,
checkedCount: result.checkedCount,
invalidEntries: result.invalidEntries,
message: result.valid
? 'Alle Einträge sind valide'
: `${result.invalidEntries.length} manipulierte Einträge gefunden`,
},
});
} catch (error) {
console.error('Fehler bei der Integritätsprüfung:', error);
res.status(500).json({ success: false, error: 'Fehler bei der Integritätsprüfung' });
}
}
/**
* Retention-Policies abrufen
*/
export async function getRetentionPolicies(req: AuthRequest, res: Response) {
try {
const policies = await auditService.getRetentionPolicies();
res.json({ success: true, data: policies });
} catch (error) {
console.error('Fehler beim Abrufen der Retention-Policies:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Policies' });
}
}
/**
* Retention-Policy aktualisieren
*/
export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const { retentionDays, description, legalBasis, isActive } = req.body;
const policy = await auditService.updateRetentionPolicy(id, {
retentionDays,
description,
legalBasis,
isActive,
});
res.json({ success: true, data: policy });
} catch (error) {
console.error('Fehler beim Aktualisieren der Retention-Policy:', error);
res.status(500).json({ success: false, error: 'Fehler beim Aktualisieren' });
}
}
/**
* Retention-Cleanup manuell ausführen
*/
export async function runRetentionCleanup(req: AuthRequest, res: Response) {
try {
const result = await auditService.runRetentionCleanup();
res.json({
success: true,
data: result,
message: `${result.deletedCount} alte Audit-Logs wurden gelöscht`,
});
} catch (error) {
console.error('Fehler beim Retention-Cleanup:', error);
res.status(500).json({ success: false, error: 'Fehler beim Cleanup' });
}
}
@@ -317,7 +317,12 @@ export async function sendEmailFromAccount(req: Request, res: Response): Promise
};
// E-Mail senden
const result = await sendEmail(credentials, stressfreiEmail.email, emailParams);
const authReq = req as any;
const result = await sendEmail(credentials, stressfreiEmail.email, emailParams, {
context: 'customer-email',
customerId: stressfreiEmail.customerId,
triggeredBy: authReq.user?.email,
});
if (!result.success) {
res.status(400).json({
@@ -0,0 +1,107 @@
import { Request, Response } from 'express';
import * as consentPublicService from '../services/consent-public.service.js';
import { createAuditLog } from '../services/audit.service.js';
import { CONSENT_TYPE_LABELS } from '../services/consent.service.js';
import { ConsentType } from '@prisma/client';
/**
* Öffentliche Consent-Seite: Kundendaten + Datenschutztext + Status
*/
export async function getConsentPage(req: Request, res: Response) {
try {
const { hash } = req.params;
const result = await consentPublicService.getCustomerByConsentHash(hash);
if (!result) {
return res.status(404).json({ success: false, error: 'Ungültiger Link' });
}
const privacyPolicyHtml = await consentPublicService.getPrivacyPolicyHtml(result.customer.id);
// Consent-Status mit Labels
const consentsWithLabels = result.consents.map((c) => ({
consentType: c.consentType,
status: c.status,
label: CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.label || c.consentType,
description: CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.description || '',
grantedAt: c.grantedAt,
}));
res.json({
success: true,
data: {
customer: {
firstName: result.customer.firstName,
lastName: result.customer.lastName,
customerNumber: result.customer.customerNumber,
},
privacyPolicyHtml,
consents: consentsWithLabels,
allGranted: consentsWithLabels.every((c) => c.status === 'GRANTED'),
},
});
} catch (error) {
console.error('Fehler beim Laden der Consent-Seite:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
/**
* Alle 4 Einwilligungen erteilen (öffentlicher Link)
*/
export async function grantAllConsents(req: Request, res: Response) {
try {
const { hash } = req.params;
const ipAddress = req.ip || req.socket.remoteAddress || 'unknown';
const results = await consentPublicService.grantAllConsentsPublic(hash, ipAddress);
// Audit-Log (manuell, da keine Auth-Middleware)
const customer = await consentPublicService.getCustomerByConsentHash(hash);
if (customer) {
for (const type of Object.values(ConsentType)) {
await createAuditLog({
userEmail: customer.customer.email || 'public-link',
action: 'UPDATE',
sensitivity: 'HIGH',
resourceType: 'CustomerConsent',
resourceId: `${customer.customer.id}:${type}`,
resourceLabel: `Einwilligung ${type} erteilt via Public-Link`,
endpoint: `/api/public/consent/${hash}/grant`,
httpMethod: 'POST',
ipAddress,
dataSubjectId: customer.customer.id,
legalBasis: 'DSGVO Art. 6 Abs. 1 lit. a',
});
}
}
res.json({ success: true, data: results });
} catch (error: any) {
console.error('Fehler beim Erteilen der Einwilligungen:', error);
res.status(400).json({ success: false, error: error.message || 'Fehler beim Erteilen' });
}
}
/**
* Datenschutzerklärung als PDF
*/
export async function getConsentPdf(req: Request, res: Response) {
try {
const { hash } = req.params;
const result = await consentPublicService.getCustomerByConsentHash(hash);
if (!result) {
return res.status(404).json({ success: false, error: 'Ungültiger Link' });
}
const pdfBuffer = await consentPublicService.generateConsentPdf(result.customer.id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="datenschutzerklaerung.pdf"');
res.send(pdfBuffer);
} catch (error) {
console.error('Fehler beim Generieren des PDFs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Generieren' });
}
}
+21 -5
View File
@@ -3,6 +3,7 @@ import { PrismaClient } from '@prisma/client';
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 { ApiResponse, AuthRequest } from '../types/index.js';
const prisma = new PrismaClient();
@@ -20,11 +21,19 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
return;
}
// Für Kundenportal-Benutzer: nur eigene + vertretene Kunden-Verträge anzeigen
// Für Kundenportal-Benutzer: nur eigene + vertretene Kunden MIT Vollmacht
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
// Eigene Customer-ID + alle vertretenen Kunden-IDs
customerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
// 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({
@@ -60,9 +69,16 @@ export async function getContract(req: AuthRequest, res: Response): Promise<void
return;
}
// Für Kundenportal-Benutzer: Zugriff nur auf eigene + vertretene Kunden-Verträge
// Für Kundenportal-Benutzer: Zugriff nur auf eigene + vertretene Kunden MIT Vollmacht
if (req.user?.isCustomerPortal && req.user.customerId) {
const allowedCustomerIds = [req.user.customerId, ...(req.user.representedCustomerIds || [])];
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,
+96 -1
View File
@@ -1,7 +1,10 @@
import { Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';
import * as customerService from '../services/customer.service.js';
import * as authService from '../services/auth.service.js';
import { ApiResponse } from '../types/index.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
const prisma = new PrismaClient();
// Customer CRUD
export async function getCustomers(req: Request, res: Response): Promise<void> {
@@ -331,6 +334,98 @@ export async function deleteMeterReading(req: Request, res: Response): Promise<v
}
}
// ==================== PORTAL: ZÄHLERSTAND MELDEN ====================
export async function reportMeterReading(req: AuthRequest, res: Response): Promise<void> {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' } as ApiResponse);
return;
}
const meterId = parseInt(req.params.meterId);
const { value, readingDate, notes } = req.body;
// Prüfe ob der Zähler zum Kunden gehört
const meter = await prisma.meter.findUnique({
where: { id: meterId },
select: { customerId: true },
});
if (!meter || meter.customerId !== user.customerId) {
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Zähler' } as ApiResponse);
return;
}
const reading = await prisma.meterReading.create({
data: {
meterId,
value: parseFloat(value),
readingDate: readingDate ? new Date(readingDate) : new Date(),
notes,
reportedBy: user.email,
status: 'REPORTED',
},
});
res.status(201).json({ success: true, data: reading } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Melden des Zählerstands',
} as ApiResponse);
}
}
export async function getMyMeters(req: AuthRequest, res: Response): Promise<void> {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' } as ApiResponse);
return;
}
const meters = await prisma.meter.findMany({
where: { customerId: user.customerId, isActive: true },
include: {
readings: {
orderBy: { readingDate: 'desc' },
take: 5,
},
},
orderBy: { createdAt: 'asc' },
});
res.json({ success: true, data: meters } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
}
}
export async function markReadingTransferred(req: AuthRequest, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.meterId);
const readingId = parseInt(req.params.readingId);
const reading = await prisma.meterReading.update({
where: { id: readingId },
data: {
status: 'TRANSFERRED',
transferredAt: new Date(),
transferredBy: req.user?.email,
},
});
res.json({ success: true, data: reading } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
} as ApiResponse);
}
}
// ==================== PORTAL SETTINGS ====================
export async function getPortalSettings(req: Request, res: Response): Promise<void> {
@@ -0,0 +1,43 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as emailLogService from '../services/emailLog.service.js';
export async function getEmailLogs(req: AuthRequest, res: Response) {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 50;
const success = req.query.success !== undefined ? req.query.success === 'true' : undefined;
const search = req.query.search as string || undefined;
const context = req.query.context as string || undefined;
const result = await emailLogService.getEmailLogs({ page, limit, success, search, context });
res.json({ success: true, ...result });
} catch (error) {
console.error('Fehler beim Laden der Email-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
export async function getEmailLogStats(req: AuthRequest, res: Response) {
try {
const stats = await emailLogService.getEmailLogStats();
res.json({ success: true, data: stats });
} catch (error) {
console.error('Fehler beim Laden der Email-Log-Stats:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
export async function getEmailLogDetail(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const log = await emailLogService.getEmailLogById(id);
if (!log) {
return res.status(404).json({ success: false, error: 'Log-Eintrag nicht gefunden' });
}
res.json({ success: true, data: log });
} catch (error) {
console.error('Fehler beim Laden des Email-Log-Details:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
+899
View File
@@ -0,0 +1,899 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as gdprService from '../services/gdpr.service.js';
import * as consentService from '../services/consent.service.js';
import * as consentPublicService from '../services/consent-public.service.js';
import * as appSettingService from '../services/appSetting.service.js';
import { createAuditLog } from '../services/audit.service.js';
import { ConsentType, DeletionRequestStatus, PrismaClient } from '@prisma/client';
import path from 'path';
import fs from 'fs';
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
import * as authorizationService from '../services/authorization.service.js';
const prisma = new PrismaClient();
/**
* Kundendaten exportieren (DSGVO Art. 15)
*/
export async function exportCustomerData(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const format = (req.query.format as string) || 'json';
const data = await gdprService.exportCustomerData(customerId);
// Audit-Log für Export
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'EXPORT',
resourceType: 'GDPR',
resourceId: customerId.toString(),
resourceLabel: `Datenexport für ${data.dataSubject.name}`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: customerId,
legalBasis: 'DSGVO Art. 15',
});
if (format === 'json') {
res.setHeader('Content-Type', 'application/json');
res.setHeader(
'Content-Disposition',
`attachment; filename="datenexport_${data.dataSubject.customerNumber}_${new Date().toISOString().split('T')[0]}.json"`
);
res.json(data);
} else {
// Für PDF würde hier PDFKit verwendet werden
res.json({ success: true, data });
}
} catch (error) {
console.error('Fehler beim Datenexport:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Datenexport',
});
}
}
/**
* Löschanfrage erstellen
*/
export async function createDeletionRequest(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.id);
const { requestSource } = req.body;
const request = await gdprService.createDeletionRequest({
customerId,
requestSource: requestSource || 'portal',
requestedBy: req.user?.email || 'unknown',
});
res.status(201).json({ success: true, data: request });
} catch (error) {
console.error('Fehler beim Erstellen der Löschanfrage:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
});
}
}
/**
* Alle Löschanfragen abrufen
*/
export async function getDeletionRequests(req: AuthRequest, res: Response) {
try {
const { status, page, limit } = req.query;
const result = await gdprService.getDeletionRequests({
status: status as DeletionRequestStatus | undefined,
page: page ? parseInt(page as string) : 1,
limit: limit ? parseInt(limit as string) : 20,
});
res.json({ success: true, ...result });
} catch (error) {
console.error('Fehler beim Abrufen der Löschanfragen:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
/**
* Einzelne Löschanfrage abrufen
*/
export async function getDeletionRequest(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const request = await gdprService.getDeletionRequest(id);
if (!request) {
return res.status(404).json({ success: false, error: 'Löschanfrage nicht gefunden' });
}
res.json({ success: true, data: request });
} catch (error) {
console.error('Fehler beim Abrufen der Löschanfrage:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
/**
* Löschanfrage bearbeiten
*/
export async function processDeletionRequest(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const { action, retentionReason } = req.body;
if (!['complete', 'partial', 'reject'].includes(action)) {
return res.status(400).json({
success: false,
error: 'Ungültige Aktion. Erlaubt: complete, partial, reject',
});
}
const result = await gdprService.processDeletionRequest(id, {
processedBy: req.user?.email || 'unknown',
action,
retentionReason,
});
// Audit-Log für Löschung
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'ANONYMIZE',
resourceType: 'GDPR',
resourceId: id.toString(),
resourceLabel: `Löschanfrage ${action}`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: result.customerId,
legalBasis: 'DSGVO Art. 17',
});
res.json({ success: true, data: result });
} catch (error) {
console.error('Fehler beim Bearbeiten der Löschanfrage:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Bearbeiten',
});
}
}
/**
* Löschnachweis-PDF herunterladen
*/
export async function getDeletionProof(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const request = await gdprService.getDeletionRequest(id);
if (!request) {
return res.status(404).json({ success: false, error: 'Löschanfrage nicht gefunden' });
}
if (!request.proofDocument) {
return res.status(404).json({ success: false, error: 'Kein Löschnachweis vorhanden' });
}
const filepath = path.join(process.cwd(), 'uploads', request.proofDocument);
if (!fs.existsSync(filepath)) {
return res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
}
res.download(filepath);
} catch (error) {
console.error('Fehler beim Download des Löschnachweises:', error);
res.status(500).json({ success: false, error: 'Fehler beim Download' });
}
}
/**
* DSGVO-Dashboard Statistiken
*/
export async function getDashboardStats(req: AuthRequest, res: Response) {
try {
const stats = await gdprService.getGDPRDashboardStats();
res.json({ success: true, data: stats });
} catch (error) {
console.error('Fehler beim Abrufen der Dashboard-Statistiken:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
// ==================== CONSENT ENDPOINTS ====================
/**
* Einwilligungen eines Kunden abrufen
*/
export async function getCustomerConsents(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const consents = await consentService.getCustomerConsents(customerId);
// Labels hinzufügen
const consentsWithLabels = consents.map((c) => ({
...c,
label: consentService.CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.label,
description: consentService.CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.description,
}));
res.json({ success: true, data: consentsWithLabels });
} catch (error) {
console.error('Fehler beim Abrufen der Einwilligungen:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
/**
* Consent-Status prüfen (hat der Kunde vollständig zugestimmt?)
*/
export async function checkConsentStatus(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const result = await consentService.hasFullConsent(customerId);
res.json({ success: true, data: result });
} catch (error) {
console.error('Fehler beim Consent-Check:', error);
res.status(500).json({ success: false, error: 'Fehler beim Consent-Check' });
}
}
/**
* Einwilligung aktualisieren (nur Kundenportal-Benutzer!)
*/
export async function updateCustomerConsent(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const consentType = req.params.consentType as ConsentType;
const { status, source, documentPath, version } = req.body;
// Nur Kundenportal-Benutzer dürfen Einwilligungen ändern
if (!(req.user as any)?.isCustomerPortal) {
return res.status(403).json({
success: false,
error: 'Nur Kunden können Einwilligungen ändern',
});
}
// Portal: nur eigene + vertretene Kunden
const allowed = [
(req.user as any).customerId,
...((req.user as any).representedCustomerIds || []),
];
if (!allowed.includes(customerId)) {
return res.status(403).json({
success: false,
error: 'Keine Berechtigung für diesen Kunden',
});
}
if (!Object.values(ConsentType).includes(consentType)) {
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
}
const consent = await consentService.updateConsent(customerId, consentType, {
status,
source: source || 'portal',
documentPath,
version,
ipAddress: req.socket.remoteAddress,
createdBy: req.user?.email || 'unknown',
});
res.json({ success: true, data: consent });
} catch (error) {
console.error('Fehler beim Aktualisieren der Einwilligung:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
});
}
}
/**
* Consent-Übersicht für Dashboard
*/
export async function getConsentOverview(req: AuthRequest, res: Response) {
try {
const overview = await consentService.getConsentOverview();
// Labels hinzufügen
const overviewWithLabels = Object.entries(overview).map(([type, stats]) => ({
type,
label: consentService.CONSENT_TYPE_LABELS[type as ConsentType]?.label,
description: consentService.CONSENT_TYPE_LABELS[type as ConsentType]?.description,
...stats,
}));
res.json({ success: true, data: overviewWithLabels });
} catch (error) {
console.error('Fehler beim Abrufen der Consent-Übersicht:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
// ==================== PRIVACY POLICY ENDPOINTS ====================
/**
* Datenschutzerklärung abrufen (HTML)
*/
export async function getPrivacyPolicy(req: AuthRequest, res: Response) {
try {
const html = await appSettingService.getSetting('privacyPolicyHtml');
res.json({ success: true, data: { html: html || '' } });
} catch (error) {
console.error('Fehler beim Abrufen der Datenschutzerklärung:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
/**
* Datenschutzerklärung speichern (HTML)
*/
export async function updatePrivacyPolicy(req: AuthRequest, res: Response) {
try {
const { html } = req.body;
if (typeof html !== 'string') {
return res.status(400).json({ success: false, error: 'HTML-Inhalt erforderlich' });
}
await appSettingService.setSetting('privacyPolicyHtml', html);
res.json({ success: true, message: 'Datenschutzerklärung gespeichert' });
} catch (error) {
console.error('Fehler beim Speichern der Datenschutzerklärung:', error);
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
}
}
/**
* Vollmacht-Vorlage abrufen (HTML)
*/
export async function getAuthorizationTemplate(req: AuthRequest, res: Response) {
try {
const html = await appSettingService.getSetting('authorizationTemplateHtml');
res.json({ success: true, data: { html: html || '' } });
} catch (error) {
console.error('Fehler beim Abrufen der Vollmacht-Vorlage:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
}
}
/**
* Vollmacht-Vorlage speichern (HTML)
*/
export async function updateAuthorizationTemplate(req: AuthRequest, res: Response) {
try {
const { html } = req.body;
if (typeof html !== 'string') {
return res.status(400).json({ success: false, error: 'HTML-Inhalt erforderlich' });
}
await appSettingService.setSetting('authorizationTemplateHtml', html);
res.json({ success: true, message: 'Vollmacht-Vorlage gespeichert' });
} catch (error) {
console.error('Fehler beim Speichern der Vollmacht-Vorlage:', error);
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
}
}
// ==================== SEND CONSENT LINK ====================
/**
* Consent-Link an Kunden senden
*/
// ==================== PORTAL ENDPOINTS ====================
/**
* Portal: Eigene Datenschutzseite (Privacy Policy + Consent-Status)
*/
export async function getMyPrivacy(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
const customerId = user.customerId;
const [privacyPolicyHtml, consents] = await Promise.all([
consentPublicService.getPrivacyPolicyHtml(customerId),
consentService.getCustomerConsents(customerId),
]);
const consentsWithLabels = consents.map((c) => ({
...c,
label: consentService.CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.label,
description: consentService.CONSENT_TYPE_LABELS[c.consentType as ConsentType]?.description,
}));
res.json({
success: true,
data: {
privacyPolicyHtml,
consents: consentsWithLabels,
},
});
} catch (error) {
console.error('Fehler beim Laden der Portal-Datenschutzseite:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
/**
* Portal: Datenschutzerklärung als PDF
*/
export async function getMyPrivacyPdf(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
const pdfBuffer = await consentPublicService.generateConsentPdf(user.customerId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="datenschutzerklaerung.pdf"');
res.send(pdfBuffer);
} catch (error) {
console.error('Fehler beim Generieren des Portal-PDFs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Generieren' });
}
}
/**
* Portal: Eigenen Consent-Status prüfen
*/
export async function getMyConsentStatus(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
const result = await consentService.hasFullConsent(user.customerId);
res.json({ success: true, data: result });
} catch (error) {
console.error('Fehler beim Consent-Status:', error);
res.status(500).json({ success: false, error: 'Fehler beim Consent-Status' });
}
}
export async function sendConsentLink(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const { channel } = req.body; // 'email', 'whatsapp', 'telegram', 'signal'
// ConsentHash sicherstellen
const hash = await consentPublicService.ensureConsentHash(customerId);
const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173';
const consentUrl = `${baseUrl}/datenschutz/${hash}`;
// Bei E-Mail: tatsächlich senden
if (channel === 'email') {
// Kunde laden
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { id: true, firstName: true, lastName: true, email: true },
});
if (!customer?.email) {
return res.status(400).json({
success: false,
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
});
}
// System-E-Mail-Credentials vom aktiven Provider holen
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
return res.status(400).json({
success: false,
error: 'Keine System-E-Mail konfiguriert. Bitte in den Email-Provider-Einstellungen eine System-E-Mail-Adresse und Passwort hinterlegen.',
});
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
// E-Mail zusammenstellen
const emailHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Datenschutzerklärung Ihre Zustimmung</h2>
<p>Sehr geehrte(r) ${customer.firstName} ${customer.lastName},</p>
<p>
um Sie optimal beraten und betreuen zu können, benötigen wir Ihre Zustimmung zu unserer Datenschutzerklärung.
</p>
<p>
Bitte klicken Sie auf den folgenden Button, um unsere Datenschutzerklärung einzusehen und Ihre Einwilligung zu erteilen:
</p>
<p style="text-align: center; margin: 32px 0;">
<a href="${consentUrl}"
style="background-color: #2563eb; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; display: inline-block;">
Datenschutzerklärung ansehen &amp; zustimmen
</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Alternativ können Sie auch diesen Link in Ihren Browser kopieren:<br>
<a href="${consentUrl}" style="color: #2563eb; word-break: break-all;">${consentUrl}</a>
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Hacker-Net Telekommunikation Stefan Hacker<br>
Am Wunderburgpark 5b, 26135 Oldenburg<br>
info@hacker-net.de
</p>
</div>
`;
const result = await sendEmail(credentials, systemEmail.emailAddress, {
to: customer.email,
subject: 'Datenschutzerklärung Bitte um Ihre Zustimmung',
html: emailHtml,
}, {
context: 'consent-link',
customerId,
triggeredBy: req.user?.email,
});
if (!result.success) {
return res.status(400).json({
success: false,
error: `E-Mail-Versand fehlgeschlagen: ${result.error}`,
});
}
}
// Audit-Log
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'READ',
resourceType: 'CustomerConsent',
resourceId: customerId.toString(),
resourceLabel: `Consent-Link gesendet (${channel})`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: customerId,
legalBasis: 'DSGVO Art. 6 Abs. 1a',
});
res.json({
success: true,
data: {
url: consentUrl,
channel,
hash,
},
});
} catch (error) {
console.error('Fehler beim Senden des Consent-Links:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
}
}
// ==================== VOLLMACHTEN ====================
/**
* Vollmacht-Anfrage an Kunden senden (per E-Mail, WhatsApp, Telegram, Signal)
*/
export async function sendAuthorizationRequest(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const representativeId = parseInt(req.params.representativeId);
const { channel } = req.body;
// Kunde (Vollmachtgeber) laden
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { id: true, firstName: true, lastName: true, email: true },
});
// Vertreter (Bevollmächtigter) laden
const representative = await prisma.customer.findUnique({
where: { id: representativeId },
select: { id: true, firstName: true, lastName: true },
});
if (!customer || !representative) {
return res.status(404).json({ success: false, error: 'Kunde oder Vertreter nicht gefunden' });
}
const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173';
const portalUrl = `${baseUrl}/privacy`;
// E-Mail senden
if (channel === 'email') {
if (!customer.email) {
return res.status(400).json({ success: false, error: 'Kunde hat keine E-Mail-Adresse hinterlegt' });
}
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
return res.status(400).json({
success: false,
error: 'Keine System-E-Mail konfiguriert. Bitte in den Email-Provider-Einstellungen eine System-E-Mail-Adresse und Passwort hinterlegen.',
});
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const emailHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Vollmacht Ihre Zustimmung erforderlich</h2>
<p>Sehr geehrte(r) ${customer.firstName} ${customer.lastName},</p>
<p>
<strong>${representative.firstName} ${representative.lastName}</strong> möchte als Ihr Vertreter Zugriff auf Ihre Vertragsdaten erhalten.
</p>
<p>
Damit dies möglich ist, benötigen wir Ihre Vollmacht. Sie können diese bequem über unser Kundenportal erteilen:
</p>
<p style="text-align: center; margin: 32px 0;">
<a href="${portalUrl}"
style="background-color: #2563eb; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; display: inline-block;">
Vollmacht im Portal erteilen
</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Alternativ können Sie auch diesen Link in Ihren Browser kopieren:<br>
<a href="${portalUrl}" style="color: #2563eb; word-break: break-all;">${portalUrl}</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Sie können die Vollmacht jederzeit im Portal unter "Datenschutz" widerrufen.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Hacker-Net Telekommunikation Stefan Hacker<br>
Am Wunderburgpark 5b, 26135 Oldenburg<br>
info@hacker-net.de
</p>
</div>
`;
const result = await sendEmail(credentials, systemEmail.emailAddress, {
to: customer.email,
subject: `Vollmacht für ${representative.firstName} ${representative.lastName} Bitte um Ihre Zustimmung`,
html: emailHtml,
}, {
context: 'authorization-request',
customerId,
triggeredBy: req.user?.email,
});
if (!result.success) {
return res.status(400).json({
success: false,
error: `E-Mail-Versand fehlgeschlagen: ${result.error}`,
});
}
}
// Messaging-Text für WhatsApp/Telegram/Signal
const messageText = `Hallo ${customer.firstName}, ${representative.firstName} ${representative.lastName} möchte als Ihr Vertreter Zugriff auf Ihre Vertragsdaten. Bitte erteilen Sie die Vollmacht im Portal: ${portalUrl}`;
res.json({
success: true,
data: { channel, portalUrl, messageText },
});
} catch (error) {
console.error('Fehler beim Senden der Vollmacht-Anfrage:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
}
}
/**
* Vollmachten für einen Kunden abrufen (Admin-Ansicht)
*/
export async function getAuthorizations(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
await authorizationService.ensureAuthorizationEntries(customerId);
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
res.json({ success: true, data: authorizations });
} catch (error) {
console.error('Fehler beim Laden der Vollmachten:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden der Vollmachten' });
}
}
/**
* Vollmacht erteilen (Admin: z.B. Papier-Upload)
*/
export async function grantAuthorization(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const representativeId = parseInt(req.params.representativeId);
const { source, notes } = req.body;
const auth = await authorizationService.grantAuthorization(customerId, representativeId, {
source: source || 'crm-backend',
notes,
});
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Erteilen der Vollmacht:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erteilen der Vollmacht',
});
}
}
/**
* Vollmacht widerrufen
*/
export async function withdrawAuthorization(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const representativeId = parseInt(req.params.representativeId);
const auth = await authorizationService.withdrawAuthorization(customerId, representativeId);
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Widerrufen der Vollmacht:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Widerrufen',
});
}
}
/**
* Vollmacht-Dokument hochladen (PDF)
*/
export async function uploadAuthorizationDocument(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const representativeId = parseInt(req.params.representativeId);
if (!req.file) {
return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
}
const documentPath = `/uploads/authorizations/${req.file.filename}`;
const auth = await authorizationService.updateAuthorizationDocument(
customerId,
representativeId,
documentPath
);
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Upload des Vollmacht-Dokuments:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Upload',
});
}
}
/**
* Vollmacht-Dokument löschen
*/
export async function deleteAuthorizationDocument(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const representativeId = parseInt(req.params.representativeId);
const auth = await authorizationService.deleteAuthorizationDocument(customerId, representativeId);
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Löschen des Vollmacht-Dokuments:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
});
}
}
/**
* Portal: Eigene Vollmachten abrufen (welche Vertreter dürfen meine Daten sehen?)
*/
export async function getMyAuthorizations(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
await authorizationService.ensureAuthorizationEntries(user.customerId);
const authorizations = await authorizationService.getAuthorizationsForCustomer(user.customerId);
res.json({ success: true, data: authorizations });
} catch (error) {
console.error('Fehler beim Laden der eigenen Vollmachten:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
/**
* Portal: Vollmacht erteilen/widerrufen
*/
export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
const representativeId = parseInt(req.params.representativeId);
const { grant } = req.body;
let auth;
if (grant) {
auth = await authorizationService.grantAuthorization(user.customerId, representativeId, {
source: 'portal',
});
} else {
auth = await authorizationService.withdrawAuthorization(user.customerId, representativeId);
}
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Ändern der Vollmacht:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Ändern',
});
}
}
/**
* Portal: Prüfe Vollmacht-Status für alle vertretenen Kunden
*/
export async function getMyAuthorizationStatus(req: AuthRequest, res: Response) {
try {
const user = req.user as any;
if (!user?.isCustomerPortal || !user?.customerId) {
return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' });
}
// IDs der Kunden die dieser Vertreter vertritt
const representedIds: number[] = user.representedCustomerIds || [];
// Für jeden vertretenen Kunden prüfen ob Vollmacht erteilt
const statuses: { customerId: number; hasAuthorization: boolean }[] = [];
for (const custId of representedIds) {
const has = await authorizationService.hasAuthorization(custId, user.customerId);
statuses.push({ customerId: custId, hasAuthorization: has });
}
res.json({ success: true, data: statuses });
} catch (error) {
console.error('Fehler beim Vollmacht-Status:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
+16
View File
@@ -26,6 +26,12 @@ import emailProviderRoutes from './routes/emailProvider.routes.js';
import cachedEmailRoutes from './routes/cachedEmail.routes.js';
import invoiceRoutes from './routes/invoice.routes.js';
import contractHistoryRoutes from './routes/contractHistory.routes.js';
import auditLogRoutes from './routes/auditLog.routes.js';
import gdprRoutes from './routes/gdpr.routes.js';
import consentPublicRoutes from './routes/consent-public.routes.js';
import emailLogRoutes from './routes/emailLog.routes.js';
import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js';
dotenv.config();
@@ -36,9 +42,16 @@ const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
// Audit-Logging Middleware (DSGVO-konform)
app.use(auditContextMiddleware);
app.use(auditMiddleware);
// Statische Dateien für Uploads
app.use('/api/uploads', express.static(path.join(process.cwd(), 'uploads')));
// Öffentliche Routes (OHNE Authentifizierung)
app.use('/api/public/consent', consentPublicRoutes);
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/customers', customerRoutes);
@@ -63,6 +76,9 @@ app.use('/api/email-providers', emailProviderRoutes);
app.use('/api', cachedEmailRoutes);
app.use('/api/energy-details', invoiceRoutes);
app.use('/api', contractHistoryRoutes);
app.use('/api/audit-logs', auditLogRoutes);
app.use('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes);
// Health check
app.get('/api/health', (req, res) => {
+134
View File
@@ -0,0 +1,134 @@
import { PrismaClient, Prisma } from '@prisma/client';
import { setBeforeValues, setAfterValues } from '../middleware/auditContext.js';
// Modelle die für Before/After-Tracking relevant sind
const AUDITED_MODELS = [
'Customer',
'Contract',
'Address',
'BankCard',
'IdentityDocument',
'User',
'Meter',
'MeterReading',
'StressfreiEmail',
'Provider',
'Tariff',
'ContractCategory',
'AppSetting',
'CustomerConsent',
];
// Sensible Felder die aus dem Audit-Log gefiltert werden
const SENSITIVE_FIELDS = [
'password',
'passwordHash',
'portalPasswordHash',
'portalPasswordEncrypted',
'emailPasswordEncrypted',
'internetPasswordEncrypted',
'sipPasswordEncrypted',
'pin',
'puk',
'apiKey',
];
/**
* Filtert sensible Felder aus einem Objekt
*/
function filterSensitiveFields(obj: Record<string, unknown>): Record<string, unknown> {
const filtered: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (SENSITIVE_FIELDS.includes(key)) {
filtered[key] = '[REDACTED]';
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
filtered[key] = filterSensitiveFields(value as Record<string, unknown>);
} else {
filtered[key] = value;
}
}
return filtered;
}
/**
* Prüft ob ein Model für Audit-Tracking relevant ist
*/
function isAuditedModel(model: string | undefined): boolean {
return model !== undefined && AUDITED_MODELS.includes(model);
}
/**
* Erstellt einen Prisma Client mit Audit-Middleware
*/
function createPrismaClient(): PrismaClient {
const prisma = new PrismaClient();
// Middleware für Before/After-Tracking
prisma.$use(async (params: Prisma.MiddlewareParams, next: (params: Prisma.MiddlewareParams) => Promise<unknown>) => {
const { model, action, args } = params;
// Nur relevante Modelle und Aktionen tracken
if (!isAuditedModel(model)) {
return next(params);
}
// Update-Operationen: Vorherigen Stand abrufen
if (action === 'update' || action === 'updateMany') {
try {
const modelDelegate = (prisma as unknown as Record<string, { findUnique: (args: unknown) => Promise<unknown> }>)[
model!.charAt(0).toLowerCase() + model!.slice(1)
];
if (modelDelegate && args?.where) {
const before = await modelDelegate.findUnique({ where: args.where });
if (before) {
setBeforeValues(filterSensitiveFields(before as Record<string, unknown>));
}
}
} catch {
// Fehler beim Abrufen des vorherigen Stands ignorieren
}
}
// Delete-Operationen: Datensatz vor dem Löschen abrufen
if (action === 'delete' || action === 'deleteMany') {
try {
const modelDelegate = (prisma as unknown as Record<string, { findUnique: (args: unknown) => Promise<unknown> }>)[
model!.charAt(0).toLowerCase() + model!.slice(1)
];
if (modelDelegate && args?.where) {
const before = await modelDelegate.findUnique({ where: args.where });
if (before) {
setBeforeValues(filterSensitiveFields(before as Record<string, unknown>));
}
}
} catch {
// Fehler beim Abrufen ignorieren
}
}
// Operation ausführen
const result = await next(params);
// Nach Update/Create: Neuen Stand speichern
if ((action === 'update' || action === 'create') && result) {
setAfterValues(filterSensitiveFields(result as Record<string, unknown>));
}
return result;
});
return prisma;
}
// Singleton-Instanz
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export default prisma;
+213
View File
@@ -0,0 +1,213 @@
import { Response, NextFunction } from 'express';
import { AuditAction } from '@prisma/client';
import { AuthRequest } from '../types/index.js';
import { createAuditLog } from '../services/audit.service.js';
import { getAuditContext, AuditContext } from './auditContext.js';
// Resource-Typ-Mapping basierend auf Route-Patterns
const RESOURCE_MAPPING: Record<string, { type: string; extractId?: (req: AuthRequest) => string | undefined }> = {
'/api/customers': { type: 'Customer', extractId: (req) => req.params.id || req.params.customerId },
'/api/customers/*/bank-cards': { type: 'BankCard', extractId: (req) => req.params.bankCardId },
'/api/customers/*/documents': { type: 'IdentityDocument', extractId: (req) => req.params.documentId },
'/api/customers/*/addresses': { type: 'Address', extractId: (req) => req.params.addressId },
'/api/customers/*/meters': { type: 'Meter', extractId: (req) => req.params.meterId },
'/api/customers/*/consents': { type: 'CustomerConsent', extractId: (req) => req.params.type },
'/api/contracts': { type: 'Contract', extractId: (req) => req.params.id },
'/api/contracts/*/history': { type: 'ContractHistoryEntry', extractId: (req) => req.params.entryId },
'/api/contracts/*/tasks': { type: 'ContractTask', extractId: (req) => req.params.taskId },
'/api/users': { type: 'User', extractId: (req) => req.params.id },
'/api/providers': { type: 'Provider', extractId: (req) => req.params.id },
'/api/tariffs': { type: 'Tariff', extractId: (req) => req.params.id },
'/api/platforms': { type: 'SalesPlatform', extractId: (req) => req.params.id },
'/api/contract-categories': { type: 'ContractCategory', extractId: (req) => req.params.id },
'/api/cancellation-periods': { type: 'CancellationPeriod', extractId: (req) => req.params.id },
'/api/contract-durations': { type: 'ContractDuration', extractId: (req) => req.params.id },
'/api/settings': { type: 'AppSetting', extractId: (req) => req.params.key },
'/api/email-providers': { type: 'EmailProviderConfig', extractId: (req) => req.params.id },
'/api/auth': { type: 'Authentication' },
'/api/audit-logs': { type: 'AuditLog', extractId: (req) => req.params.id },
'/api/gdpr': { type: 'GDPR' },
};
// Routen die nicht geloggt werden sollen
const EXCLUDED_ROUTES = [
'/api/health',
'/api/uploads',
];
/**
* Bestimmt die Aktion basierend auf HTTP-Methode und Erfolg
*/
function determineAction(method: string, path: string, success: boolean): AuditAction {
// Spezielle Auth-Aktionen
if (path.includes('/auth/login')) {
return success ? 'LOGIN' : 'LOGIN_FAILED';
}
if (path.includes('/auth/logout')) {
return 'LOGOUT';
}
// Standard CRUD-Aktionen
switch (method.toUpperCase()) {
case 'GET':
return 'READ';
case 'POST':
return 'CREATE';
case 'PUT':
case 'PATCH':
return 'UPDATE';
case 'DELETE':
return 'DELETE';
default:
return 'READ';
}
}
/**
* Findet den passenden Resource-Typ für einen Pfad
*/
function findResourceMapping(path: string): { type: string; extractId?: (req: AuthRequest) => string | undefined } | null {
// Exakte Matches zuerst prüfen
for (const [pattern, mapping] of Object.entries(RESOURCE_MAPPING)) {
// Konvertiere Pattern zu Regex
const regexPattern = pattern
.replace(/\*/g, '[^/]+')
.replace(/\//g, '\\/');
const regex = new RegExp(`^${regexPattern}(?:/|$)`);
if (regex.test(path)) {
return mapping;
}
}
return null;
}
/**
* Extrahiert die betroffene Kunden-ID für DSGVO-Tracking
*/
function extractDataSubjectId(req: AuthRequest): number | undefined {
// Aus Route-Parameter
const customerId = req.params.customerId || req.params.id;
if (customerId && req.path.includes('/customers')) {
return parseInt(customerId);
}
// Aus Request-Body (bei Create)
if (req.body?.customerId) {
return parseInt(req.body.customerId);
}
// Bei Kundenportal-Zugriff
if (req.user?.customerId) {
return req.user.customerId;
}
return undefined;
}
/**
* Extrahiert die IP-Adresse des Clients
*/
function getClientIp(req: AuthRequest): string {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim();
}
return req.socket.remoteAddress || 'unknown';
}
/**
* Audit Middleware - loggt alle API-Aufrufe asynchron
*/
export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
const startTime = Date.now();
// Ausgeschlossene Routen überspringen
if (EXCLUDED_ROUTES.some((route) => req.path.startsWith(route))) {
next();
return;
}
// Resource-Mapping finden
const mapping = findResourceMapping(req.path);
if (!mapping) {
// Unbekannte Route - trotzdem loggen mit generischem Typ
next();
return;
}
// Original res.json überschreiben um Response zu capturen
const originalJson = res.json.bind(res);
let responseBody: unknown = null;
let responseSuccess = true;
res.json = function (body: unknown) {
responseBody = body;
if (typeof body === 'object' && body !== null && 'success' in body) {
responseSuccess = (body as { success: boolean }).success;
}
return originalJson(body);
};
// Response-Ende abfangen für Logging
res.on('finish', () => {
// Async Logging - blockiert nicht die Response
setImmediate(async () => {
try {
const durationMs = Date.now() - startTime;
const action = determineAction(req.method, req.path, responseSuccess);
const resourceId = mapping.extractId?.(req);
const dataSubjectId = extractDataSubjectId(req);
// Audit-Kontext abrufen (enthält Before/After-Werte von Prisma Middleware)
const auditContext = getAuditContext();
// Label für bessere Lesbarkeit generieren
let resourceLabel: string | undefined;
if (responseBody && typeof responseBody === 'object' && 'data' in responseBody) {
const data = (responseBody as { data: Record<string, unknown> }).data;
if (data) {
// Versuche verschiedene Label-Felder
resourceLabel =
(data.contractNumber as string) ||
(data.customerNumber as string) ||
(data.name as string) ||
(data.email as string) ||
(data.firstName && data.lastName
? `${data.firstName} ${data.lastName}`
: undefined);
}
}
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'anonymous',
userRole: req.user?.permissions?.join(', '),
customerId: req.user?.customerId,
isCustomerPortal: req.user?.isCustomerPortal,
action,
resourceType: mapping.type,
resourceId,
resourceLabel,
endpoint: req.path,
httpMethod: req.method,
ipAddress: getClientIp(req),
userAgent: req.headers['user-agent'],
changesBefore: auditContext?.before,
changesAfter: auditContext?.after,
dataSubjectId,
success: responseSuccess,
errorMessage: !responseSuccess && responseBody && typeof responseBody === 'object' && 'error' in responseBody
? (responseBody as { error: string }).error
: undefined,
durationMs,
});
} catch (error) {
// Audit-Logging darf niemals die Anwendung beeinträchtigen
console.error('[AuditMiddleware] Fehler beim Logging:', error);
}
});
});
next();
}
+60
View File
@@ -0,0 +1,60 @@
import { AsyncLocalStorage } from 'async_hooks';
/**
* Audit-Kontext für die Übertragung von Before/After-Werten
* zwischen Prisma-Middleware und Audit-Middleware
*/
export interface AuditContext {
before?: Record<string, unknown>;
after?: Record<string, unknown>;
}
// AsyncLocalStorage für den Audit-Kontext
const auditStorage = new AsyncLocalStorage<AuditContext>();
/**
* Startet einen neuen Audit-Kontext für einen Request
*/
export function runWithAuditContext<T>(fn: () => T): T {
return auditStorage.run({}, fn);
}
/**
* Setzt die "Before"-Werte im aktuellen Kontext
*/
export function setBeforeValues(values: Record<string, unknown>): void {
const context = auditStorage.getStore();
if (context) {
context.before = values;
}
}
/**
* Setzt die "After"-Werte im aktuellen Kontext
*/
export function setAfterValues(values: Record<string, unknown>): void {
const context = auditStorage.getStore();
if (context) {
context.after = values;
}
}
/**
* Holt den aktuellen Audit-Kontext
*/
export function getAuditContext(): AuditContext | undefined {
return auditStorage.getStore();
}
/**
* Express Middleware zum Initialisieren des Audit-Kontexts
*/
export function auditContextMiddleware(
req: unknown,
res: unknown,
next: () => void
): void {
runWithAuditContext(() => {
next();
});
}
+7 -2
View File
@@ -114,9 +114,14 @@ export function requireCustomerAccess(
return;
}
// Customers can only access their own data
// Customers can only access their own data + represented customers
const customerId = parseInt(req.params.customerId || req.params.id);
if (req.user.customerId && req.user.customerId === customerId) {
const allowedIds = [
req.user.customerId,
...((req.user as any).representedCustomerIds || []),
].filter(Boolean);
if (allowedIds.includes(customerId)) {
next();
return;
}
+32
View File
@@ -0,0 +1,32 @@
import { Router } from 'express';
import { authenticate, requirePermission } from '../middleware/auth.js';
import * as auditLogController from '../controllers/auditLog.controller.js';
const router = Router();
// Alle Routen erfordern Authentifizierung
router.use(authenticate);
// Audit-Logs abrufen
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
// Einzelnes Audit-Log abrufen
router.get('/:id', requirePermission('audit:read'), auditLogController.getAuditLogById);
// Audit-Logs für einen Kunden (DSGVO)
router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer);
// Audit-Logs exportieren
router.get('/export', requirePermission('audit:export'), auditLogController.exportAuditLogs);
// Hash-Ketten-Integrität prüfen
router.post('/verify', requirePermission('audit:admin'), auditLogController.verifyIntegrity);
// Retention-Policies
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
// Retention-Cleanup manuell ausführen
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
export default router;
@@ -0,0 +1,11 @@
import { Router } from 'express';
import * as controller from '../controllers/consent-public.controller.js';
const router = Router();
// Öffentliche Routes - KEINE Authentifizierung erforderlich
router.get('/:hash', controller.getConsentPage);
router.post('/:hash/grant', controller.grantAllConsents);
router.get('/:hash/pdf', controller.getConsentPdf);
export default router;
+13
View File
@@ -0,0 +1,13 @@
import { Router } from 'express';
import { authenticate, requirePermission } from '../middleware/auth.js';
import * as emailLogController from '../controllers/emailLog.controller.js';
const router = Router();
router.use(authenticate);
router.get('/', requirePermission('gdpr:admin'), emailLogController.getEmailLogs);
router.get('/stats', requirePermission('gdpr:admin'), emailLogController.getEmailLogStats);
router.get('/:id', requirePermission('gdpr:admin'), emailLogController.getEmailLogDetail);
export default router;
+84
View File
@@ -0,0 +1,84 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { authenticate, requirePermission } from '../middleware/auth.js';
import * as gdprController from '../controllers/gdpr.controller.js';
const router = Router();
// Multer für Vollmacht-Uploads
const uploadsDir = path.join(process.cwd(), 'uploads', 'authorizations');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const authUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, `vollmacht-${uniqueSuffix}${path.extname(file.originalname)}`);
},
}),
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('Nur PDF-Dateien sind erlaubt'));
}
},
limits: { fileSize: 10 * 1024 * 1024 },
});
// Alle Routen erfordern Authentifizierung
router.use(authenticate);
// Dashboard-Statistiken
router.get('/dashboard', requirePermission('gdpr:admin'), gdprController.getDashboardStats);
// Kundendaten exportieren (DSGVO Art. 15)
router.get('/customer/:customerId/export', requirePermission('gdpr:export'), gdprController.exportCustomerData);
// Löschanfragen
router.get('/deletions', requirePermission('gdpr:admin'), gdprController.getDeletionRequests);
router.get('/deletions/:id', requirePermission('gdpr:admin'), gdprController.getDeletionRequest);
router.post('/deletions', requirePermission('gdpr:delete'), gdprController.createDeletionRequest);
router.put('/deletions/:id/process', requirePermission('gdpr:admin'), gdprController.processDeletionRequest);
// Einwilligungen (Consents)
router.get('/customer/:customerId/consent-status', requirePermission('customers:read'), gdprController.checkConsentStatus);
router.get('/customer/:customerId/consents', requirePermission('customers:read'), gdprController.getCustomerConsents);
// Consent-Update: Nur authenticate (Check im Controller - nur Portal-User erlaubt)
router.put('/customer/:customerId/consents/:consentType', gdprController.updateCustomerConsent);
router.get('/consents/overview', requirePermission('gdpr:admin'), gdprController.getConsentOverview);
// Datenschutzerklärung (Editor)
router.get('/privacy-policy', requirePermission('gdpr:admin'), gdprController.getPrivacyPolicy);
router.put('/privacy-policy', requirePermission('gdpr:admin'), gdprController.updatePrivacyPolicy);
// Vollmacht-Vorlage (Editor)
router.get('/authorization-template', requirePermission('gdpr:admin'), gdprController.getAuthorizationTemplate);
router.put('/authorization-template', requirePermission('gdpr:admin'), gdprController.updateAuthorizationTemplate);
// Consent-Link senden
router.post('/customer/:customerId/send-consent-link', requirePermission('customers:update'), gdprController.sendConsentLink);
// Portal: Eigene Datenschutzseite (nur authenticate, Check im Controller)
router.get('/my-privacy', gdprController.getMyPrivacy);
router.get('/my-privacy/pdf', gdprController.getMyPrivacyPdf);
router.get('/my-consent-status', gdprController.getMyConsentStatus);
// Vollmachten (Admin)
router.get('/customer/:customerId/authorizations', requirePermission('customers:read'), gdprController.getAuthorizations);
router.post('/customer/:customerId/authorizations/:representativeId/send', requirePermission('customers:update'), gdprController.sendAuthorizationRequest);
router.post('/customer/:customerId/authorizations/:representativeId/grant', requirePermission('customers:update'), gdprController.grantAuthorization);
router.post('/customer/:customerId/authorizations/:representativeId/withdraw', requirePermission('customers:update'), gdprController.withdrawAuthorization);
router.post('/customer/:customerId/authorizations/:representativeId/upload', requirePermission('customers:update'), authUpload.single('document'), gdprController.uploadAuthorizationDocument);
router.delete('/customer/:customerId/authorizations/:representativeId/document', requirePermission('customers:update'), gdprController.deleteAuthorizationDocument);
// Portal: Vollmachten
router.get('/my-authorizations', gdprController.getMyAuthorizations);
router.put('/my-authorizations/:representativeId', gdprController.toggleMyAuthorization);
router.get('/my-authorization-status', gdprController.getMyAuthorizationStatus);
export default router;
+9
View File
@@ -13,4 +13,13 @@ router.post('/:meterId/readings', authenticate, requirePermission('customers:upd
router.put('/:meterId/readings/:readingId', authenticate, requirePermission('customers:update'), customerController.updateMeterReading);
router.delete('/:meterId/readings/:readingId', authenticate, requirePermission('customers:delete'), customerController.deleteMeterReading);
// Status-Update (Zählerstand als übertragen markieren)
router.patch('/:meterId/readings/:readingId/transfer', authenticate, requirePermission('customers:update'), customerController.markReadingTransferred);
// Portal: Zählerstand melden (Kunde)
router.post('/:meterId/readings/report', authenticate, customerController.reportMeterReading);
// Portal: Eigene Zähler laden
router.get('/my-meters', authenticate, customerController.getMyMeters);
export default router;
@@ -9,6 +9,9 @@ const DEFAULT_SETTINGS: Record<string, string> = {
deadlineCriticalDays: '14', // Rot: Kritisch
deadlineWarningDays: '42', // Gelb: Warnung (6 Wochen)
deadlineOkDays: '90', // Grün: OK (3 Monate)
// Ausweis-Ablauf: Fristenschwellen (in Tagen)
documentExpiryCriticalDays: '30', // Rot: Kritisch (Standard 30 Tage)
documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage)
};
export async function getSetting(key: string): Promise<string | null> {
+504
View File
@@ -0,0 +1,504 @@
import { AuditAction, AuditSensitivity, Prisma } from '@prisma/client';
import crypto from 'crypto';
import { encrypt, decrypt } from '../utils/encryption.js';
import prisma from '../lib/prisma.js';
export interface CreateAuditLogData {
userId?: number;
userEmail: string;
userRole?: string;
customerId?: number;
isCustomerPortal?: boolean;
action: AuditAction;
sensitivity?: AuditSensitivity;
resourceType: string;
resourceId?: string;
resourceLabel?: string;
endpoint: string;
httpMethod: string;
ipAddress: string;
userAgent?: string;
changesBefore?: Record<string, unknown>;
changesAfter?: Record<string, unknown>;
dataSubjectId?: number;
legalBasis?: string;
success?: boolean;
errorMessage?: string;
durationMs?: number;
}
export interface AuditLogSearchParams {
userId?: number;
customerId?: number;
dataSubjectId?: number;
action?: AuditAction;
sensitivity?: AuditSensitivity;
resourceType?: string;
resourceId?: string;
startDate?: Date;
endDate?: Date;
success?: boolean;
search?: string;
page?: number;
limit?: number;
}
/**
* Generiert einen SHA-256 Hash für einen Audit-Log-Eintrag
*/
function generateHash(data: {
userEmail: string;
action: AuditAction;
resourceType: string;
resourceId?: string | null;
endpoint: string;
createdAt: Date;
previousHash?: string | null;
}): string {
const content = JSON.stringify({
userEmail: data.userEmail,
action: data.action,
resourceType: data.resourceType,
resourceId: data.resourceId,
endpoint: data.endpoint,
createdAt: data.createdAt.toISOString(),
previousHash: data.previousHash || '',
});
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Bestimmt die Sensitivität basierend auf dem Ressourcentyp
*/
function determineSensitivity(resourceType: string): AuditSensitivity {
const sensitivityMap: Record<string, AuditSensitivity> = {
// CRITICAL
Authentication: 'CRITICAL',
BankCard: 'CRITICAL',
IdentityDocument: 'CRITICAL',
// HIGH
Customer: 'HIGH',
User: 'HIGH',
CustomerConsent: 'HIGH',
DataDeletionRequest: 'HIGH',
// MEDIUM
Contract: 'MEDIUM',
Address: 'MEDIUM',
Meter: 'MEDIUM',
MeterReading: 'MEDIUM',
StressfreiEmail: 'MEDIUM',
CachedEmail: 'MEDIUM',
// LOW
Provider: 'LOW',
Tariff: 'LOW',
SalesPlatform: 'LOW',
AppSetting: 'LOW',
ContractCategory: 'LOW',
CancellationPeriod: 'LOW',
ContractDuration: 'LOW',
};
return sensitivityMap[resourceType] || 'MEDIUM';
}
/**
* Prüft ob Änderungen verschlüsselt werden sollen
*/
function shouldEncryptChanges(resourceType: string): boolean {
const encryptedTypes = [
'BankCard',
'IdentityDocument',
'User',
'Customer', // Enthält Portal-Passwörter
];
return encryptedTypes.includes(resourceType);
}
/**
* Erstellt einen neuen Audit-Log-Eintrag mit Hash-Kette
*/
export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
try {
// Letzten Hash abrufen für die Kette
const lastLog = await prisma.auditLog.findFirst({
orderBy: { id: 'desc' },
select: { hash: true },
});
const previousHash = lastLog?.hash || null;
const createdAt = new Date();
// Sensitivität bestimmen falls nicht angegeben
const sensitivity = data.sensitivity || determineSensitivity(data.resourceType);
// Änderungen serialisieren und ggf. verschlüsseln
let changesBefore: string | null = null;
let changesAfter: string | null = null;
let changesEncrypted = false;
if (data.changesBefore || data.changesAfter) {
changesEncrypted = shouldEncryptChanges(data.resourceType);
if (data.changesBefore) {
const json = JSON.stringify(data.changesBefore);
changesBefore = changesEncrypted ? encrypt(json) : json;
}
if (data.changesAfter) {
const json = JSON.stringify(data.changesAfter);
changesAfter = changesEncrypted ? encrypt(json) : json;
}
}
// Hash generieren
const hash = generateHash({
userEmail: data.userEmail,
action: data.action,
resourceType: data.resourceType,
resourceId: data.resourceId,
endpoint: data.endpoint,
createdAt,
previousHash,
});
// Eintrag erstellen
await prisma.auditLog.create({
data: {
userId: data.userId,
userEmail: data.userEmail,
userRole: data.userRole,
customerId: data.customerId,
isCustomerPortal: data.isCustomerPortal || false,
action: data.action,
sensitivity,
resourceType: data.resourceType,
resourceId: data.resourceId,
resourceLabel: data.resourceLabel,
endpoint: data.endpoint,
httpMethod: data.httpMethod,
ipAddress: data.ipAddress,
userAgent: data.userAgent,
changesBefore,
changesAfter,
changesEncrypted,
dataSubjectId: data.dataSubjectId,
legalBasis: data.legalBasis,
success: data.success ?? true,
errorMessage: data.errorMessage,
durationMs: data.durationMs,
createdAt,
hash,
previousHash,
},
});
} catch (error) {
// Audit-Logging darf niemals die Hauptoperation blockieren
console.error('[AuditService] Fehler beim Erstellen des Audit-Logs:', error);
}
}
/**
* Sucht Audit-Logs mit Filtern und Paginierung
*/
export async function searchAuditLogs(params: AuditLogSearchParams) {
const {
userId,
customerId,
dataSubjectId,
action,
sensitivity,
resourceType,
resourceId,
startDate,
endDate,
success,
search,
page = 1,
limit = 50,
} = params;
const where: Prisma.AuditLogWhereInput = {};
if (userId !== undefined) where.userId = userId;
if (customerId !== undefined) where.customerId = customerId;
if (dataSubjectId !== undefined) where.dataSubjectId = dataSubjectId;
if (action) where.action = action;
if (sensitivity) where.sensitivity = sensitivity;
if (resourceType) where.resourceType = resourceType;
if (resourceId) where.resourceId = resourceId;
if (success !== undefined) where.success = success;
if (startDate || endDate) {
where.createdAt = {};
if (startDate) where.createdAt.gte = startDate;
if (endDate) where.createdAt.lte = endDate;
}
if (search) {
where.OR = [
{ userEmail: { contains: search } },
{ resourceLabel: { contains: search } },
{ endpoint: { contains: search } },
];
}
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.auditLog.count({ where }),
]);
// Entschlüsselung der Änderungen wenn nötig
const decryptedLogs = logs.map((log) => ({
...log,
changesBefore: log.changesBefore && log.changesEncrypted
? JSON.parse(decrypt(log.changesBefore))
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
changesAfter: log.changesAfter && log.changesEncrypted
? JSON.parse(decrypt(log.changesAfter))
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
}));
return {
data: decryptedLogs,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Holt einen einzelnen Audit-Log-Eintrag
*/
export async function getAuditLogById(id: number) {
const log = await prisma.auditLog.findUnique({
where: { id },
});
if (!log) return null;
return {
...log,
changesBefore: log.changesBefore && log.changesEncrypted
? JSON.parse(decrypt(log.changesBefore))
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
changesAfter: log.changesAfter && log.changesEncrypted
? JSON.parse(decrypt(log.changesAfter))
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
};
}
/**
* Holt alle Audit-Logs für eine betroffene Person (DSGVO)
*/
export async function getAuditLogsByDataSubject(customerId: number) {
const logs = await prisma.auditLog.findMany({
where: { dataSubjectId: customerId },
orderBy: { createdAt: 'desc' },
});
return logs.map((log) => ({
...log,
changesBefore: log.changesBefore && log.changesEncrypted
? JSON.parse(decrypt(log.changesBefore))
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
changesAfter: log.changesAfter && log.changesEncrypted
? JSON.parse(decrypt(log.changesAfter))
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
}));
}
/**
* Verifiziert die Integrität der Hash-Kette
*/
export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
valid: boolean;
checkedCount: number;
invalidEntries: number[];
}> {
const where: Prisma.AuditLogWhereInput = {};
if (fromId !== undefined) where.id = { gte: fromId };
if (toId !== undefined) where.id = { ...(where.id as object || {}), lte: toId };
const logs = await prisma.auditLog.findMany({
where,
orderBy: { id: 'asc' },
select: {
id: true,
userEmail: true,
action: true,
resourceType: true,
resourceId: true,
endpoint: true,
createdAt: true,
hash: true,
previousHash: true,
},
});
const invalidEntries: number[] = [];
for (let i = 0; i < logs.length; i++) {
const log = logs[i];
// Hash neu berechnen
const expectedHash = generateHash({
userEmail: log.userEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
endpoint: log.endpoint,
createdAt: log.createdAt,
previousHash: log.previousHash,
});
// Prüfen ob Hash übereinstimmt
if (log.hash !== expectedHash) {
invalidEntries.push(log.id);
continue;
}
// Prüfen ob previousHash mit dem Hash des vorherigen Eintrags übereinstimmt
if (i > 0) {
const previousLog = logs[i - 1];
if (log.previousHash !== previousLog.hash) {
invalidEntries.push(log.id);
}
}
}
return {
valid: invalidEntries.length === 0,
checkedCount: logs.length,
invalidEntries,
};
}
/**
* Exportiert Audit-Logs als JSON oder CSV
*/
export async function exportAuditLogs(
params: AuditLogSearchParams,
format: 'json' | 'csv' = 'json'
): Promise<string> {
// Alle Logs ohne Paginierung
const result = await searchAuditLogs({ ...params, limit: 100000, page: 1 });
const logs = result.data;
if (format === 'json') {
return JSON.stringify(logs, null, 2);
}
// CSV Export
const headers = [
'ID',
'Zeitstempel',
'Benutzer',
'Aktion',
'Ressource',
'Ressource-ID',
'Bezeichnung',
'Endpoint',
'IP-Adresse',
'Erfolg',
'Sensitivität',
];
const rows = logs.map((log) => [
log.id.toString(),
log.createdAt.toISOString(),
log.userEmail,
log.action,
log.resourceType,
log.resourceId || '',
log.resourceLabel || '',
log.endpoint,
log.ipAddress,
log.success ? 'Ja' : 'Nein',
log.sensitivity,
]);
const csvContent = [
headers.join(';'),
...rows.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(';')),
].join('\n');
return csvContent;
}
/**
* Löscht alte Audit-Logs basierend auf Retention-Policies
* Hinweis: Diese Funktion sollte nur von einem autorisierten Admin-Prozess aufgerufen werden
*/
export async function runRetentionCleanup(): Promise<{
deletedCount: number;
policies: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }>;
}> {
const policies = await prisma.auditRetentionPolicy.findMany({
where: { isActive: true },
});
const results: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }> = [];
let totalDeleted = 0;
for (const policy of policies) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - policy.retentionDays);
const where: Prisma.AuditLogWhereInput = {
createdAt: { lt: cutoffDate },
};
if (policy.resourceType !== '*') {
where.resourceType = policy.resourceType;
}
if (policy.sensitivity) {
where.sensitivity = policy.sensitivity;
}
const deleted = await prisma.auditLog.deleteMany({ where });
results.push({
resourceType: policy.resourceType,
sensitivity: policy.sensitivity,
deletedCount: deleted.count,
});
totalDeleted += deleted.count;
}
return {
deletedCount: totalDeleted,
policies: results,
};
}
/**
* Holt die Retention-Policies
*/
export async function getRetentionPolicies() {
return prisma.auditRetentionPolicy.findMany({
orderBy: [{ resourceType: 'asc' }, { sensitivity: 'asc' }],
});
}
/**
* Aktualisiert eine Retention-Policy
*/
export async function updateRetentionPolicy(
id: number,
data: { retentionDays?: number; description?: string; legalBasis?: string; isActive?: boolean }
) {
return prisma.auditRetentionPolicy.update({
where: { id },
data,
});
}
+3
View File
@@ -283,6 +283,9 @@ export async function getUserById(id: number) {
lastName: user.lastName,
isActive: user.isActive,
customerId: user.customerId,
whatsappNumber: user.whatsappNumber,
telegramUsername: user.telegramUsername,
signalNumber: user.signalNumber,
roles: user.roles.map((ur) => ur.role.name),
permissions: Array.from(permissions),
isCustomerPortal: false,
@@ -0,0 +1,207 @@
import prisma from '../lib/prisma.js';
import fs from 'fs';
import path from 'path';
/**
* Vollmachten für einen Kunden abrufen (wer darf diesen Kunden einsehen?)
*/
export async function getAuthorizationsForCustomer(customerId: number) {
return prisma.representativeAuthorization.findMany({
where: { customerId },
include: {
representative: {
select: { id: true, customerNumber: true, firstName: true, lastName: true },
},
},
orderBy: { createdAt: 'desc' },
});
}
/**
* Vollmachten die ein Vertreter erhalten hat (welche Kunden darf er einsehen?)
*/
export async function getAuthorizationsForRepresentative(representativeId: number) {
return prisma.representativeAuthorization.findMany({
where: { representativeId },
include: {
customer: {
select: { id: true, customerNumber: true, firstName: true, lastName: true },
},
},
orderBy: { createdAt: 'desc' },
});
}
/**
* Prüft ob ein Vertreter eine Vollmacht für einen Kunden hat
*/
export async function hasAuthorization(customerId: number, representativeId: number): Promise<boolean> {
const auth = await prisma.representativeAuthorization.findUnique({
where: {
customerId_representativeId: { customerId, representativeId },
},
});
return auth?.isGranted === true;
}
/**
* Vollmacht erteilen oder aktualisieren
*/
export async function grantAuthorization(
customerId: number,
representativeId: number,
data: { source?: string; documentPath?: string; notes?: string }
) {
return prisma.representativeAuthorization.upsert({
where: {
customerId_representativeId: { customerId, representativeId },
},
update: {
isGranted: true,
grantedAt: new Date(),
withdrawnAt: null,
source: data.source,
documentPath: data.documentPath ?? undefined,
notes: data.notes ?? undefined,
},
create: {
customerId,
representativeId,
isGranted: true,
grantedAt: new Date(),
source: data.source || 'crm-backend',
documentPath: data.documentPath,
notes: data.notes,
},
});
}
/**
* Vollmacht widerrufen + PDF löschen falls vorhanden
*/
export async function withdrawAuthorization(customerId: number, representativeId: number) {
// Erst prüfen ob eine PDF vorhanden ist
const existing = await prisma.representativeAuthorization.findUnique({
where: { customerId_representativeId: { customerId, representativeId } },
select: { documentPath: true },
});
// PDF vom Filesystem löschen
if (existing?.documentPath) {
try {
const filePath = path.join(process.cwd(), existing.documentPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (err) {
console.error('Fehler beim Löschen der Vollmacht-PDF:', err);
}
}
return prisma.representativeAuthorization.update({
where: {
customerId_representativeId: { customerId, representativeId },
},
data: {
isGranted: false,
withdrawnAt: new Date(),
documentPath: null,
},
});
}
/**
* Vollmacht-Dokument (PDF) hochladen
*/
export async function updateAuthorizationDocument(
customerId: number,
representativeId: number,
documentPath: string
) {
// Wenn Dokument hochgeladen wird, gilt das als Vollmacht erteilen
return prisma.representativeAuthorization.upsert({
where: {
customerId_representativeId: { customerId, representativeId },
},
update: {
documentPath,
isGranted: true,
grantedAt: new Date(),
withdrawnAt: null,
source: 'papier',
},
create: {
customerId,
representativeId,
documentPath,
isGranted: true,
grantedAt: new Date(),
source: 'papier',
},
});
}
/**
* Vollmacht-Dokument löschen
*/
export async function deleteAuthorizationDocument(customerId: number, representativeId: number) {
return prisma.representativeAuthorization.update({
where: {
customerId_representativeId: { customerId, representativeId },
},
data: {
documentPath: null,
},
});
}
/**
* Alle genehmigten Vertreter-IDs für einen Kunden
* (Welche Vertreter dürfen die Verträge dieses Kunden sehen?)
*/
export async function getAuthorizedRepresentativeIds(customerId: number): Promise<number[]> {
const auths = await prisma.representativeAuthorization.findMany({
where: { customerId, isGranted: true },
select: { representativeId: true },
});
return auths.map((a) => a.representativeId);
}
/**
* Alle Kunden-IDs für die ein Vertreter eine Vollmacht hat
*/
export async function getAuthorizedCustomerIds(representativeId: number): Promise<number[]> {
const auths = await prisma.representativeAuthorization.findMany({
where: { representativeId, isGranted: true },
select: { customerId: true },
});
return auths.map((a) => a.customerId);
}
/**
* Erstellt fehlende Vollmacht-Einträge für bestehende Vertreterbeziehungen
* (wird aufgerufen wenn man den Tab aufruft)
*/
export async function ensureAuthorizationEntries(customerId: number) {
// Alle aktiven Vertreter für diesen Kunden
const representatives = await prisma.customerRepresentative.findMany({
where: { customerId, isActive: true },
select: { representativeId: true },
});
for (const rep of representatives) {
// Erstelle Eintrag falls nicht vorhanden
await prisma.representativeAuthorization.upsert({
where: {
customerId_representativeId: { customerId, representativeId: rep.representativeId },
},
update: {}, // Nichts ändern wenn schon vorhanden
create: {
customerId,
representativeId: rep.representativeId,
isGranted: false,
},
});
}
}
@@ -0,0 +1,187 @@
import { ConsentType, ConsentStatus } from '@prisma/client';
import crypto from 'crypto';
import prisma from '../lib/prisma.js';
import * as consentService from './consent.service.js';
import * as appSettingService from './appSetting.service.js';
import PDFDocument from 'pdfkit';
/**
* Kunden-Lookup per consentHash
*/
export async function getCustomerByConsentHash(hash: string) {
const customer = await prisma.customer.findUnique({
where: { consentHash: hash },
select: {
id: true,
firstName: true,
lastName: true,
customerNumber: true,
salutation: true,
email: true,
},
});
if (!customer) return null;
const consents = await consentService.getCustomerConsents(customer.id);
return { customer, consents };
}
/**
* Alle 4 Einwilligungen über den öffentlichen Link erteilen
*/
export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
const customer = await prisma.customer.findUnique({
where: { consentHash: hash },
select: { id: true, firstName: true, lastName: true },
});
if (!customer) {
throw new Error('Ungültiger Link');
}
const results = [];
for (const type of Object.values(ConsentType)) {
const result = await consentService.updateConsent(customer.id, type, {
status: ConsentStatus.GRANTED,
source: 'public-link',
ipAddress,
createdBy: `${customer.firstName} ${customer.lastName} (Public-Link)`,
});
results.push(result);
}
return results;
}
/**
* consentHash generieren falls nicht vorhanden
*/
export async function ensureConsentHash(customerId: number): Promise<string> {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { consentHash: true },
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
if (customer.consentHash) {
return customer.consentHash;
}
const hash = crypto.randomUUID();
await prisma.customer.update({
where: { id: customerId },
data: { consentHash: hash },
});
return hash;
}
/**
* Platzhalter in Text ersetzen
*/
function replacePlaceholders(html: string, customer: {
firstName: string;
lastName: string;
customerNumber: string;
salutation?: string | null;
email?: string | null;
}): string {
return html
.replace(/\{\{vorname\}\}/gi, customer.firstName || '')
.replace(/\{\{nachname\}\}/gi, customer.lastName || '')
.replace(/\{\{kundennummer\}\}/gi, customer.customerNumber || '')
.replace(/\{\{anrede\}\}/gi, customer.salutation || '')
.replace(/\{\{email\}\}/gi, customer.email || '')
.replace(/\{\{datum\}\}/gi, new Date().toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}));
}
/**
* Datenschutzerklärung als HTML abrufen (mit Platzhaltern ersetzt)
*/
export async function getPrivacyPolicyHtml(customerId?: number): Promise<string> {
const html = await appSettingService.getSetting('privacyPolicyHtml');
if (!html) {
return '<p>Keine Datenschutzerklärung hinterlegt.</p>';
}
if (!customerId) return html;
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
firstName: true,
lastName: true,
customerNumber: true,
salutation: true,
email: true,
},
});
if (!customer) return html;
return replacePlaceholders(html, customer);
}
/**
* HTML zu Plain-Text konvertieren (für PDF)
*/
function htmlToText(html: string): string {
return html
.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, '\n$1\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<li[^>]*>(.*?)<\/li>/gi, ' • $1\n')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/**
* Datenschutzerklärung als PDF generieren
*/
export async function generateConsentPdf(customerId: number): Promise<Buffer> {
const html = await getPrivacyPolicyHtml(customerId);
const text = htmlToText(html);
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Titel
doc.fontSize(18).font('Helvetica-Bold').text('Datenschutzerklärung', { align: 'center' });
doc.moveDown(1);
// Datum
doc.fontSize(10).font('Helvetica')
.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE')}`, { align: 'right' });
doc.moveDown(1);
// Inhalt
doc.fontSize(11).font('Helvetica').text(text, {
align: 'left',
lineGap: 4,
});
doc.end();
});
}
+267
View File
@@ -0,0 +1,267 @@
import { ConsentType, ConsentStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
import fs from 'fs';
import path from 'path';
export interface UpdateConsentData {
status: ConsentStatus;
source?: string;
documentPath?: string;
version?: string;
ipAddress?: string;
createdBy: string;
}
/**
* Holt alle Einwilligungen eines Kunden
*/
export async function getCustomerConsents(customerId: number) {
const consents = await prisma.customerConsent.findMany({
where: { customerId },
orderBy: { consentType: 'asc' },
});
// Alle verfügbaren Consent-Typen mit Status
const allTypes = Object.values(ConsentType);
const consentMap = new Map(consents.map((c) => [c.consentType, c]));
return allTypes.map((type) => {
const existing = consentMap.get(type);
return existing || {
id: null,
customerId,
consentType: type,
status: 'PENDING' as ConsentStatus,
grantedAt: null,
withdrawnAt: null,
source: null,
documentPath: null,
version: null,
ipAddress: null,
createdBy: null,
createdAt: null,
updatedAt: null,
};
});
}
/**
* Aktualisiert oder erstellt eine Einwilligung
*/
export async function updateConsent(
customerId: number,
consentType: ConsentType,
data: UpdateConsentData
) {
// Prüfen ob Kunde existiert
const customer = await prisma.customer.findUnique({
where: { id: customerId },
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
const now = new Date();
const updateData = {
status: data.status,
source: data.source,
documentPath: data.documentPath,
version: data.version,
ipAddress: data.ipAddress,
grantedAt: data.status === 'GRANTED' ? now : undefined,
withdrawnAt: data.status === 'WITHDRAWN' ? now : undefined,
};
const result = await prisma.customerConsent.upsert({
where: {
customerId_consentType: { customerId, consentType },
},
update: updateData,
create: {
customerId,
consentType,
...updateData,
createdBy: data.createdBy,
},
});
// Bei Widerruf: Datenschutz-PDF löschen wenn keine Einwilligung mehr besteht
if (data.status === 'WITHDRAWN') {
await deletePrivacyPdfOnWithdraw(customerId);
}
return result;
}
/**
* Holt die Historie einer Einwilligung (aus Audit-Logs)
*/
export async function getConsentHistory(customerId: number, consentType: ConsentType) {
// Aus Audit-Logs die Änderungen dieser Einwilligung abrufen
const logs = await prisma.auditLog.findMany({
where: {
resourceType: 'CustomerConsent',
dataSubjectId: customerId,
changesAfter: { contains: consentType },
},
orderBy: { createdAt: 'desc' },
take: 50,
});
return logs;
}
/**
* Prüft ob eine bestimmte Einwilligung erteilt wurde
*/
export async function hasConsent(customerId: number, consentType: ConsentType): Promise<boolean> {
const consent = await prisma.customerConsent.findUnique({
where: {
customerId_consentType: { customerId, consentType },
},
});
return consent?.status === 'GRANTED';
}
/**
* Prüft ob ein Kunde die DSGVO-Einwilligung erfüllt hat.
* Erfüllt = entweder privacyPolicyPath vorhanden ODER alle Online-Consents GRANTED.
*/
export async function hasFullConsent(customerId: number): Promise<{
hasConsent: boolean;
hasPaperConsent: boolean;
hasOnlineConsent: boolean;
consentDetails: { type: string; status: string }[];
consentHash: string | null;
}> {
// Prüfe ob Papier-Datenschutzerklärung vorhanden
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { privacyPolicyPath: true, consentHash: true },
});
const hasPaperConsent = !!customer?.privacyPolicyPath;
// Online-Consents prüfen
const allTypes = Object.values(ConsentType);
const consents = await prisma.customerConsent.findMany({
where: { customerId },
});
const consentMap = new Map(consents.map((c) => [c.consentType, c.status]));
const consentDetails = allTypes.map((type) => ({
type,
status: (consentMap.get(type) || 'PENDING') as string,
}));
const hasOnlineConsent = allTypes.every(
(type) => consentMap.get(type) === 'GRANTED'
);
return {
hasConsent: hasPaperConsent || hasOnlineConsent,
hasPaperConsent,
hasOnlineConsent,
consentDetails,
consentHash: customer?.consentHash || null,
};
}
/**
* Widerruft alle Einwilligungen eines Kunden
*/
export async function withdrawAllConsents(customerId: number, withdrawnBy: string) {
const result = await prisma.customerConsent.updateMany({
where: {
customerId,
status: 'GRANTED',
},
data: {
status: 'WITHDRAWN',
withdrawnAt: new Date(),
},
});
// Datenschutz-PDF löschen
await deletePrivacyPdfOnWithdraw(customerId);
return result;
}
/**
* Löscht die Datenschutz-PDF bei Widerruf.
* Sobald auch nur eine Einwilligung widerrufen wird, ist die Gesamteinwilligung ungültig.
*/
async function deletePrivacyPdfOnWithdraw(customerId: number) {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { privacyPolicyPath: true },
});
if (customer?.privacyPolicyPath) {
try {
const filePath = path.join(process.cwd(), customer.privacyPolicyPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (err) {
console.error('Fehler beim Löschen der Datenschutz-PDF:', err);
}
await prisma.customer.update({
where: { id: customerId },
data: { privacyPolicyPath: null },
});
console.log(`Datenschutz-PDF für Kunde ${customerId} gelöscht (Einwilligung widerrufen)`);
}
}
/**
* Consent-Übersicht für DSGVO-Dashboard
*/
export async function getConsentOverview() {
const allConsents = await prisma.customerConsent.groupBy({
by: ['consentType', 'status'],
_count: { id: true },
});
// Gruppieren nach Typ
const overview: Record<string, { granted: number; withdrawn: number; pending: number }> = {};
for (const type of Object.values(ConsentType)) {
overview[type] = { granted: 0, withdrawn: 0, pending: 0 };
}
for (const row of allConsents) {
const type = row.consentType;
const status = row.status.toLowerCase() as 'granted' | 'withdrawn' | 'pending';
overview[type][status] = row._count.id;
}
return overview;
}
/**
* Consent-Typ Labels für UI
*/
export const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: string }> = {
DATA_PROCESSING: {
label: 'Datenverarbeitung',
description: 'Grundlegende Verarbeitung personenbezogener Daten zur Vertragserfüllung',
},
MARKETING_EMAIL: {
label: 'E-Mail-Marketing',
description: 'Zusendung von Werbung und Angeboten per E-Mail',
},
MARKETING_PHONE: {
label: 'Telefonmarketing',
description: 'Kontaktaufnahme zu Werbezwecken per Telefon',
},
DATA_SHARING_PARTNER: {
label: 'Datenweitergabe',
description: 'Weitergabe von Daten an Partnerunternehmen',
},
};
+252 -3
View File
@@ -53,11 +53,54 @@ export interface CockpitSummary {
openTasks: number;
pendingContracts: number;
reviewDue: number; // Erneute Prüfung fällig (Snooze abgelaufen)
missingConsents: number; // Fehlende oder widerrufene Einwilligungen
};
}
export interface DocumentAlert {
id: number;
type: string; // ID_CARD, PASSPORT, DRIVERS_LICENSE, OTHER
documentNumber: string;
expiryDate: string;
daysUntilExpiry: number;
urgency: UrgencyLevel;
customer: {
id: number;
customerNumber: string;
name: string;
};
}
export interface ReportedMeterReading {
id: number;
readingDate: string;
value: number;
unit: string;
notes?: string;
reportedBy?: string;
createdAt: string;
meter: {
id: number;
meterNumber: string;
type: string;
};
customer: {
id: number;
customerNumber: string;
name: string;
};
// Anbieter-Info für Quick-Login
providerPortal?: {
providerName: string;
portalUrl: string;
portalUsername?: string;
};
}
export interface CockpitResult {
contracts: CockpitContract[];
documentAlerts: DocumentAlert[];
reportedReadings: ReportedMeterReading[];
summary: CockpitSummary;
thresholds: {
criticalDays: number;
@@ -143,6 +186,8 @@ export async function getCockpitData(): Promise<CockpitResult> {
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
const warningDays = parseInt(settings.deadlineWarningDays) || 42;
const okDays = parseInt(settings.deadlineOkDays) || 90;
const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30;
const docExpiryWarningDays = parseInt(settings.documentExpiryWarningDays) || 90;
// Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für Schlussrechnung-Check)
const contracts = await prisma.contract.findMany({
@@ -231,9 +276,41 @@ export async function getCockpitData(): Promise<CockpitResult> {
openTasks: 0,
pendingContracts: 0,
reviewDue: 0,
missingConsents: 0,
},
};
// Consent-Daten batch-laden für alle Kunden
const allConsents = await prisma.customerConsent.findMany({
where: { status: 'GRANTED' },
select: { customerId: true, consentType: true },
});
// Map: customerId → Set<consentType>
const grantedConsentsMap = new Map<number, Set<string>>();
for (const c of allConsents) {
if (!grantedConsentsMap.has(c.customerId)) {
grantedConsentsMap.set(c.customerId, new Set());
}
grantedConsentsMap.get(c.customerId)!.add(c.consentType);
}
// Widerrufene Consents laden
const withdrawnConsents = await prisma.customerConsent.findMany({
where: { status: 'WITHDRAWN' },
select: { customerId: true, consentType: true },
});
const withdrawnConsentsMap = new Map<number, Set<string>>();
for (const c of withdrawnConsents) {
if (!withdrawnConsentsMap.has(c.customerId)) {
withdrawnConsentsMap.set(c.customerId, new Set());
}
withdrawnConsentsMap.get(c.customerId)!.add(c.consentType);
}
// Track welche Kunden bereits eine Consent-Warnung bekommen haben (nur einmal pro Kunde)
const customerConsentWarned = new Set<number>();
for (const contract of contracts) {
const issues: CockpitIssue[] = [];
@@ -407,17 +484,43 @@ export async function getCockpitData(): Promise<CockpitResult> {
summary.byCategory.missingData++;
}
// 7b. KEIN AUSWEIS (für DSL, FIBER, CABLE, MOBILE ist dies ein kritisches Problem)
if (!contract.identityDocumentId) {
// 7b. KEIN AUSWEIS (nur für Telekommunikationsprodukte relevant)
const requiresIdentityDocument = ['DSL', 'FIBER', 'CABLE', 'MOBILE'].includes(contract.type);
if (requiresIdentityDocument && !contract.identityDocumentId) {
issues.push({
type: 'missing_identity_document',
label: 'Ausweis fehlt',
urgency: requiresBankAndId ? 'critical' : 'warning',
urgency: 'critical',
details: 'Kein Ausweisdokument verknüpft',
});
summary.byCategory.missingData++;
}
// 7c. AUSWEIS LÄUFT AB (nur aktive Ausweise prüfen)
if (contract.identityDocument && contract.identityDocument.isActive && contract.identityDocument.expiryDate) {
const expiryDate = new Date(contract.identityDocument.expiryDate);
const today = new Date();
const daysUntilExpiry = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry < 0) {
issues.push({
type: 'identity_document_expired',
label: 'Ausweis abgelaufen',
urgency: 'critical',
details: `Ausweis seit ${Math.abs(daysUntilExpiry)} Tagen abgelaufen (${expiryDate.toLocaleDateString('de-DE')})`,
});
summary.byCategory.missingData++;
} else if (daysUntilExpiry <= docExpiryWarningDays) {
issues.push({
type: 'identity_document_expiring',
label: 'Ausweis läuft ab',
urgency: daysUntilExpiry <= docExpiryCriticalDays ? 'critical' : 'warning',
details: `Ausweis läuft in ${daysUntilExpiry} Tagen ab (${expiryDate.toLocaleDateString('de-DE')})`,
});
summary.byCategory.cancellationDeadlines++;
}
}
// 8. ENERGIE-SPEZIFISCH: KEIN ZÄHLER
if (['ELECTRICITY', 'GAS'].includes(contract.type) && contract.energyDetails) {
if (!contract.energyDetails.meterId) {
@@ -546,6 +649,36 @@ export async function getCockpitData(): Promise<CockpitResult> {
}
}
// #14 - Consent-Prüfung (nur für aktive Verträge, einmal pro Kunde)
if (['ACTIVE', 'PENDING', 'DRAFT'].includes(contract.status) && !customerConsentWarned.has(contract.customer.id)) {
const granted = grantedConsentsMap.get(contract.customer.id);
const withdrawn = withdrawnConsentsMap.get(contract.customer.id);
const requiredTypes = ['DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER'];
if (withdrawn && withdrawn.size > 0) {
// Mindestens eine Einwilligung widerrufen
issues.push({
type: 'consent_withdrawn',
label: 'Einwilligung widerrufen',
urgency: 'critical',
details: `${withdrawn.size} Einwilligung(en) widerrufen`,
});
summary.byCategory.missingConsents++;
customerConsentWarned.add(contract.customer.id);
} else if (!granted || granted.size < requiredTypes.length) {
// Nicht alle 4 Einwilligungen erteilt
const missing = requiredTypes.length - (granted?.size || 0);
issues.push({
type: 'missing_consents',
label: 'Fehlende Einwilligungen',
urgency: 'critical',
details: `${missing} von ${requiredTypes.length} Einwilligungen fehlen`,
});
summary.byCategory.missingConsents++;
customerConsentWarned.add(contract.customer.id);
}
}
// Nur Verträge mit Issues hinzufügen
if (issues.length > 0) {
const highestUrgency = getHighestUrgency(issues);
@@ -596,8 +729,16 @@ export async function getCockpitData(): Promise<CockpitResult> {
return urgencyOrder[a.highestUrgency] - urgencyOrder[b.highestUrgency];
});
// Vertragsunabhängige Ausweis-Warnungen
const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays);
// Gemeldete Zählerstände (REPORTED Status)
const reportedReadings = await getReportedMeterReadings();
return {
contracts: cockpitContracts,
documentAlerts,
reportedReadings,
summary,
thresholds: {
criticalDays,
@@ -606,3 +747,111 @@ export async function getCockpitData(): Promise<CockpitResult> {
},
};
}
/**
* Alle aktiven Ausweise die ablaufen oder abgelaufen sind (vertragsunabhängig)
*/
async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number): Promise<DocumentAlert[]> {
const now = new Date();
const inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000);
const documents = await prisma.identityDocument.findMany({
where: {
isActive: true,
expiryDate: { lte: inWarningDays },
},
include: {
customer: {
select: { id: true, customerNumber: true, firstName: true, lastName: true },
},
},
orderBy: { expiryDate: 'asc' },
});
return documents.map((doc) => {
const expiryDate = new Date(doc.expiryDate!);
const daysUntilExpiry = Math.ceil((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
let urgency: UrgencyLevel = 'warning';
if (daysUntilExpiry < 0) urgency = 'critical';
else if (daysUntilExpiry <= criticalDays) urgency = 'critical';
return {
id: doc.id,
type: doc.type,
documentNumber: doc.documentNumber,
expiryDate: expiryDate.toISOString(),
daysUntilExpiry,
urgency,
customer: {
id: doc.customer.id,
customerNumber: doc.customer.customerNumber,
name: `${doc.customer.firstName} ${doc.customer.lastName}`,
},
};
});
}
/**
* Vom Kunden gemeldete Zählerstände die noch nicht übertragen wurden
*/
async function getReportedMeterReadings(): Promise<ReportedMeterReading[]> {
const readings = await prisma.meterReading.findMany({
where: { status: 'REPORTED' },
include: {
meter: {
include: {
customer: {
select: { id: true, customerNumber: true, firstName: true, lastName: true },
},
// Energie-Verträge für diesen Zähler (um Provider-Portal-Daten zu bekommen)
energyDetails: {
include: {
contract: {
select: {
id: true,
portalUsername: true,
provider: {
select: { id: true, name: true, portalUrl: true },
},
},
},
},
take: 1,
},
},
},
},
orderBy: { createdAt: 'asc' },
});
return readings.map((r) => {
const contract = r.meter.energyDetails?.[0]?.contract;
const provider = contract?.provider;
return {
id: r.id,
readingDate: r.readingDate.toISOString(),
value: r.value,
unit: r.unit,
notes: r.notes ?? undefined,
reportedBy: r.reportedBy ?? undefined,
createdAt: r.createdAt.toISOString(),
meter: {
id: r.meter.id,
meterNumber: r.meter.meterNumber,
type: r.meter.type,
},
customer: {
id: r.meter.customer.id,
customerNumber: r.meter.customer.customerNumber,
name: `${r.meter.customer.firstName} ${r.meter.customer.lastName}`,
},
providerPortal: provider?.portalUrl ? {
providerName: provider.name,
portalUrl: provider.portalUrl,
portalUsername: contract?.portalUsername ?? undefined,
} : undefined,
};
});
}
+90
View File
@@ -0,0 +1,90 @@
import prisma from '../lib/prisma.js';
export interface CreateEmailLogData {
fromAddress: string;
toAddress: string;
subject: string;
context: string;
customerId?: number;
triggeredBy?: string;
smtpServer: string;
smtpPort: number;
smtpEncryption: string;
smtpUser: string;
success: boolean;
messageId?: string;
errorMessage?: string;
smtpResponse?: string;
}
export async function createEmailLog(data: CreateEmailLogData) {
return prisma.emailLog.create({ data });
}
export async function getEmailLogs(options?: {
page?: number;
limit?: number;
success?: boolean;
search?: string;
context?: string;
}) {
const page = options?.page || 1;
const limit = options?.limit || 50;
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
if (options?.success !== undefined) {
where.success = options.success;
}
if (options?.context) {
where.context = options.context;
}
if (options?.search) {
where.OR = [
{ fromAddress: { contains: options.search } },
{ toAddress: { contains: options.search } },
{ subject: { contains: options.search } },
{ errorMessage: { contains: options.search } },
];
}
const [logs, total] = await Promise.all([
prisma.emailLog.findMany({
where,
orderBy: { sentAt: 'desc' },
skip,
take: limit,
}),
prisma.emailLog.count({ where }),
]);
return {
data: logs,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
export async function getEmailLogById(id: number) {
return prisma.emailLog.findUnique({ where: { id } });
}
export async function getEmailLogStats() {
const [total, success, failed, last24h] = await Promise.all([
prisma.emailLog.count(),
prisma.emailLog.count({ where: { success: true } }),
prisma.emailLog.count({ where: { success: false } }),
prisma.emailLog.count({
where: { sentAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
}),
]);
return { total, success, failed, last24h };
}
@@ -73,6 +73,9 @@ export interface CreateProviderConfigData {
imapEncryption?: MailEncryption;
smtpEncryption?: MailEncryption;
allowSelfSignedCerts?: boolean;
// System-E-Mail
systemEmailAddress?: string;
systemEmailPassword?: string;
isActive?: boolean;
isDefault?: boolean;
}
@@ -86,9 +89,10 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
});
}
// Passwort verschlüsseln falls vorhanden
// Passwörter verschlüsseln falls vorhanden
const { encrypt } = await import('../../utils/encryption.js');
const passwordEncrypted = data.password ? encrypt(data.password) : null;
const systemEmailPasswordEncrypted = data.systemEmailPassword ? encrypt(data.systemEmailPassword) : null;
return prisma.emailProviderConfig.create({
data: {
@@ -103,6 +107,8 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
imapEncryption: data.imapEncryption ?? 'SSL',
smtpEncryption: data.smtpEncryption ?? 'SSL',
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
systemEmailAddress: data.systemEmailAddress || null,
systemEmailPasswordEncrypted,
isActive: data.isActive ?? true,
isDefault: data.isDefault ?? false,
},
@@ -134,20 +140,30 @@ export async function updateProviderConfig(
if (data.imapEncryption !== undefined) updateData.imapEncryption = data.imapEncryption;
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
if (data.systemEmailAddress !== undefined) updateData.systemEmailAddress = data.systemEmailAddress || null;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
const { encrypt } = await import('../../utils/encryption.js');
// Passwort-Logik:
// - Wenn neues Passwort übergeben → verschlüsseln und speichern
// - Wenn Benutzername gelöscht wird → Passwort auch löschen (gehören zusammen)
if (data.password) {
const { encrypt } = await import('../../utils/encryption.js');
updateData.passwordEncrypted = encrypt(data.password);
} else if (data.username !== undefined && !data.username) {
// Benutzername wird gelöscht → Passwort auch löschen
updateData.passwordEncrypted = null;
}
// System-E-Mail-Passwort
if (data.systemEmailPassword) {
updateData.systemEmailPasswordEncrypted = encrypt(data.systemEmailPassword);
} else if (data.systemEmailAddress !== undefined && !data.systemEmailAddress) {
// System-E-Mail wird gelöscht → Passwort auch löschen
updateData.systemEmailPasswordEncrypted = null;
}
return prisma.emailProviderConfig.update({
where: { id },
data: updateData,
@@ -564,3 +580,45 @@ export async function testProviderConnection(options?: {
};
}
}
// ==================== SYSTEM EMAIL ====================
export interface SystemEmailCredentials {
emailAddress: string;
password: string;
smtpServer: string;
smtpPort: number;
smtpEncryption: MailEncryption;
allowSelfSignedCerts: boolean;
}
/**
* System-E-Mail-Credentials vom aktiven Provider holen.
* Wird für automatisierten Versand (DSGVO, Benachrichtigungen etc.) verwendet.
*/
export async function getSystemEmailCredentials(): Promise<SystemEmailCredentials | null> {
const config = await getActiveProviderConfig();
if (!config?.systemEmailAddress || !config?.systemEmailPasswordEncrypted) {
return null;
}
let password: string;
try {
password = decrypt(config.systemEmailPasswordEncrypted);
} catch {
console.error('System-E-Mail-Passwort konnte nicht entschlüsselt werden');
return null;
}
const settings = await getImapSmtpSettings();
if (!settings) return null;
return {
emailAddress: config.systemEmailAddress,
password,
smtpServer: settings.smtpServer,
smtpPort: settings.smtpPort,
smtpEncryption: settings.smtpEncryption,
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
}
+565
View File
@@ -0,0 +1,565 @@
import { DeletionRequestStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
import { getAuditLogsByDataSubject } from './audit.service.js';
import { getCustomerConsents, withdrawAllConsents } from './consent.service.js';
import PDFDocument from 'pdfkit';
import fs from 'fs';
import path from 'path';
export interface CreateDeletionRequestData {
customerId: number;
requestSource: string;
requestedBy: string;
}
export interface ProcessDeletionRequestData {
processedBy: string;
action: 'complete' | 'partial' | 'reject';
retentionReason?: string;
}
/**
* Exportiert alle Daten eines Kunden (DSGVO Art. 15)
*/
export async function exportCustomerData(customerId: number) {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
include: {
addresses: true,
bankCards: {
select: {
id: true,
accountHolder: true,
iban: true,
bic: true,
bankName: true,
isActive: true,
createdAt: true,
},
},
identityDocuments: {
select: {
id: true,
type: true,
documentNumber: true,
issuingAuthority: true,
issueDate: true,
expiryDate: true,
isActive: true,
createdAt: true,
},
},
meters: {
include: {
readings: true,
},
},
contracts: {
include: {
address: true,
billingAddress: true,
provider: true,
tariff: true,
energyDetails: {
include: { invoices: true },
},
internetDetails: {
include: { phoneNumbers: true },
},
mobileDetails: {
include: { simCards: true },
},
tvDetails: true,
carInsuranceDetails: true,
historyEntries: true,
tasks: {
include: { subtasks: true },
},
},
},
stressfreiEmails: {
select: {
id: true,
email: true,
platform: true,
isActive: true,
createdAt: true,
},
},
consents: true,
},
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
// Audit-Logs für diesen Kunden
const accessLogs = await getAuditLogsByDataSubject(customerId);
// Sensible Felder entfernen
const exportData = {
exportDate: new Date().toISOString(),
dataSubject: {
id: customer.id,
customerNumber: customer.customerNumber,
name: `${customer.firstName} ${customer.lastName}`,
},
personalData: {
salutation: customer.salutation,
firstName: customer.firstName,
lastName: customer.lastName,
companyName: customer.companyName,
type: customer.type,
birthDate: customer.birthDate,
birthPlace: customer.birthPlace,
email: customer.email,
phone: customer.phone,
mobile: customer.mobile,
taxNumber: customer.taxNumber,
portalEnabled: customer.portalEnabled,
portalEmail: customer.portalEmail,
portalLastLogin: customer.portalLastLogin,
createdAt: customer.createdAt,
updatedAt: customer.updatedAt,
},
addresses: customer.addresses,
bankCards: customer.bankCards,
identityDocuments: customer.identityDocuments,
meters: customer.meters,
contracts: customer.contracts.map((c) => ({
...c,
// Sensible Daten entfernen
portalPasswordEncrypted: undefined,
})),
emails: customer.stressfreiEmails,
consents: customer.consents,
accessHistory: accessLogs.map((log) => ({
timestamp: log.createdAt,
action: log.action,
user: log.userEmail,
resource: log.resourceType,
ipAddress: log.ipAddress,
})),
};
return exportData;
}
/**
* Erstellt eine Löschanfrage
*/
export async function createDeletionRequest(data: CreateDeletionRequestData) {
// Prüfen ob Kunde existiert
const customer = await prisma.customer.findUnique({
where: { id: data.customerId },
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
// Prüfen ob bereits eine offene Anfrage existiert
const existingRequest = await prisma.dataDeletionRequest.findFirst({
where: {
customerId: data.customerId,
status: { in: ['PENDING', 'IN_PROGRESS'] },
},
});
if (existingRequest) {
throw new Error('Es existiert bereits eine offene Löschanfrage für diesen Kunden');
}
return prisma.dataDeletionRequest.create({
data: {
customerId: data.customerId,
requestSource: data.requestSource,
requestedBy: data.requestedBy,
status: 'PENDING',
},
});
}
/**
* Holt alle Löschanfragen mit Paginierung
*/
export async function getDeletionRequests(params: {
status?: DeletionRequestStatus;
page?: number;
limit?: number;
}) {
const { status, page = 1, limit = 20 } = params;
const where = status ? { status } : {};
const [requests, total] = await Promise.all([
prisma.dataDeletionRequest.findMany({
where,
orderBy: { requestedAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.dataDeletionRequest.count({ where }),
]);
// Kundendaten hinzufügen
const customerIds = requests.map((r) => r.customerId);
const customers = await prisma.customer.findMany({
where: { id: { in: customerIds } },
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
},
});
const customerMap = new Map(customers.map((c) => [c.id, c]));
const requestsWithCustomer = requests.map((r) => ({
...r,
customer: customerMap.get(r.customerId) || null,
}));
return {
data: requestsWithCustomer,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Holt eine einzelne Löschanfrage
*/
export async function getDeletionRequest(id: number) {
const request = await prisma.dataDeletionRequest.findUnique({
where: { id },
});
if (!request) return null;
const customer = await prisma.customer.findUnique({
where: { id: request.customerId },
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
email: true,
},
});
return { ...request, customer };
}
/**
* Bearbeitet eine Löschanfrage
*/
export async function processDeletionRequest(
requestId: number,
data: ProcessDeletionRequestData
) {
const request = await prisma.dataDeletionRequest.findUnique({
where: { id: requestId },
});
if (!request) {
throw new Error('Löschanfrage nicht gefunden');
}
if (request.status !== 'PENDING' && request.status !== 'IN_PROGRESS') {
throw new Error('Diese Anfrage wurde bereits bearbeitet');
}
// Status auf IN_PROGRESS setzen
await prisma.dataDeletionRequest.update({
where: { id: requestId },
data: { status: 'IN_PROGRESS' },
});
const customerId = request.customerId;
const deletedData: Record<string, number> = {};
const retainedData: Record<string, { count: number; reason: string }> = {};
try {
if (data.action === 'reject') {
// Anfrage ablehnen
return prisma.dataDeletionRequest.update({
where: { id: requestId },
data: {
status: 'REJECTED',
processedAt: new Date(),
processedBy: data.processedBy,
retentionReason: data.retentionReason,
},
});
}
// Einwilligungen widerrufen
await withdrawAllConsents(customerId, data.processedBy);
deletedData['consents'] = 1;
// Verträge prüfen - aktive Verträge müssen behalten werden
const contracts = await prisma.contract.findMany({
where: { customerId },
});
const activeContracts = contracts.filter(
(c) => c.status === 'ACTIVE' || c.status === 'PENDING'
);
if (activeContracts.length > 0) {
retainedData['contracts'] = {
count: activeContracts.length,
reason: 'Aktive Verträge müssen für die Vertragserfüllung aufbewahrt werden',
};
}
// Löschbare Daten anonymisieren (statt hart löschen)
if (data.action === 'complete' && activeContracts.length === 0) {
// Kunde vollständig anonymisieren
await anonymizeCustomer(customerId);
deletedData['customer'] = 1;
deletedData['addresses'] = 1;
deletedData['bankCards'] = 1;
deletedData['identityDocuments'] = 1;
} else {
// Teilweise Löschung - nur optionale Daten
const deletedAddresses = await prisma.address.deleteMany({
where: { customerId, isDefault: false },
});
deletedData['addresses'] = deletedAddresses.count;
// Inaktive Bankkarten löschen
const deletedBankCards = await prisma.bankCard.deleteMany({
where: { customerId, isActive: false },
});
deletedData['bankCards'] = deletedBankCards.count;
// Inaktive Dokumente löschen
const deletedDocs = await prisma.identityDocument.deleteMany({
where: { customerId, isActive: false },
});
deletedData['identityDocuments'] = deletedDocs.count;
}
// Löschnachweis generieren
const proofPath = await generateDeletionProof(requestId, customerId, deletedData, retainedData);
// Anfrage abschließen
const status = Object.keys(retainedData).length > 0 ? 'PARTIALLY_COMPLETED' : 'COMPLETED';
return prisma.dataDeletionRequest.update({
where: { id: requestId },
data: {
status,
processedAt: new Date(),
processedBy: data.processedBy,
deletedData: JSON.stringify(deletedData),
retainedData: JSON.stringify(retainedData),
retentionReason: data.retentionReason,
proofDocument: proofPath,
},
});
} catch (error) {
// Bei Fehler Status zurücksetzen
await prisma.dataDeletionRequest.update({
where: { id: requestId },
data: { status: 'PENDING' },
});
throw error;
}
}
/**
* Anonymisiert Kundendaten (DSGVO-konform)
*/
async function anonymizeCustomer(customerId: number) {
const anonymized = `[GELÖSCHT-${Date.now()}]`;
await prisma.customer.update({
where: { id: customerId },
data: {
firstName: anonymized,
lastName: anonymized,
salutation: null,
companyName: null,
birthDate: null,
birthPlace: null,
email: null,
phone: null,
mobile: null,
taxNumber: null,
notes: null,
portalEnabled: false,
portalEmail: null,
portalPasswordHash: null,
portalPasswordEncrypted: null,
},
});
// Adressen anonymisieren
await prisma.address.updateMany({
where: { customerId },
data: {
street: anonymized,
houseNumber: '',
postalCode: '00000',
city: anonymized,
},
});
// Bankkarten anonymisieren
await prisma.bankCard.updateMany({
where: { customerId },
data: {
accountHolder: anonymized,
iban: 'XX00000000000000000000',
bic: null,
bankName: null,
isActive: false,
},
});
// Ausweisdokumente anonymisieren
await prisma.identityDocument.updateMany({
where: { customerId },
data: {
documentNumber: anonymized,
issuingAuthority: null,
isActive: false,
},
});
}
/**
* Generiert ein Löschnachweis-PDF
*/
async function generateDeletionProof(
requestId: number,
customerId: number,
deletedData: Record<string, number>,
retainedData: Record<string, { count: number; reason: string }>
): Promise<string> {
const uploadsDir = path.join(process.cwd(), 'uploads', 'gdpr');
// Verzeichnis erstellen falls nicht vorhanden
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const filename = `loeschnachweis_${requestId}_${Date.now()}.pdf`;
const filepath = path.join(uploadsDir, filename);
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const writeStream = fs.createWriteStream(filepath);
doc.pipe(writeStream);
// Titel
doc.fontSize(18).text('Datenlöschungsnachweis', { align: 'center' });
doc.moveDown();
// Metadaten
doc.fontSize(12);
doc.text(`Anfrage-ID: ${requestId}`);
doc.text(`Kunden-ID: ${customerId}`);
doc.text(`Datum: ${new Date().toLocaleDateString('de-DE')}`);
doc.text(`Uhrzeit: ${new Date().toLocaleTimeString('de-DE')}`);
doc.moveDown();
// Gelöschte Daten
doc.fontSize(14).text('Gelöschte Daten:', { underline: true });
doc.fontSize(12);
for (const [category, count] of Object.entries(deletedData)) {
doc.text(`${category}: ${count} Einträge`);
}
doc.moveDown();
// Aufbewahrte Daten
if (Object.keys(retainedData).length > 0) {
doc.fontSize(14).text('Aufbewahrte Daten:', { underline: true });
doc.fontSize(12);
for (const [category, info] of Object.entries(retainedData)) {
doc.text(`${category}: ${info.count} Einträge`);
doc.fontSize(10).text(` Grund: ${info.reason}`, { indent: 20 });
doc.fontSize(12);
}
doc.moveDown();
}
// Rechtlicher Hinweis
doc.moveDown();
doc.fontSize(10).text(
'Dieses Dokument bestätigt die Durchführung der Datenlöschung gemäß Art. 17 DSGVO. ' +
'Daten, die aus gesetzlichen Gründen aufbewahrt werden müssen, wurden nicht gelöscht.',
{ align: 'justify' }
);
doc.end();
// Warten bis Datei geschrieben wurde
await new Promise<void>((resolve) => writeStream.on('finish', resolve));
return `gdpr/${filename}`;
}
/**
* Dashboard-Statistiken für DSGVO
*/
export async function getGDPRDashboardStats() {
const [
pendingDeletions,
completedDeletions,
recentExports,
consentStats,
] = await Promise.all([
// Offene Löschanfragen
prisma.dataDeletionRequest.count({
where: { status: { in: ['PENDING', 'IN_PROGRESS'] } },
}),
// Abgeschlossene Löschungen (letzte 30 Tage)
prisma.dataDeletionRequest.count({
where: {
status: { in: ['COMPLETED', 'PARTIALLY_COMPLETED'] },
processedAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
},
}),
// Letzte Datenexporte (aus Audit-Log)
prisma.auditLog.count({
where: {
action: 'EXPORT',
resourceType: 'GDPR',
createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
},
}),
// Consent-Statistik
prisma.customerConsent.groupBy({
by: ['status'],
_count: { id: true },
}),
]);
const consentByStatus = consentStats.reduce(
(acc, s) => {
acc[s.status.toLowerCase()] = s._count.id;
return acc;
},
{ granted: 0, withdrawn: 0, pending: 0 } as Record<string, number>
);
return {
deletionRequests: {
pending: pendingDeletions,
completedLast30Days: completedDeletions,
},
dataExports: {
last30Days: recentExports,
},
consents: consentByStatus,
};
}
+50 -1
View File
@@ -42,11 +42,19 @@ export interface SendEmailResult {
error?: string;
}
// Optionaler Logging-Kontext
export interface EmailLogContext {
context: string; // z.B. "consent-link", "authorization-request", "customer-email"
customerId?: number;
triggeredBy?: string; // User-Email
}
// E-Mail senden
export async function sendEmail(
credentials: SmtpCredentials,
fromAddress: string,
params: SendEmailParams
params: SendEmailParams,
logContext?: EmailLogContext
): Promise<SendEmailResult> {
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
@@ -155,6 +163,27 @@ export async function sendEmail(
// Nicht kritisch - E-Mail wurde trotzdem gesendet
}
// E-Mail-Log erstellen (async, nicht blockierend)
if (logContext) {
import('./emailLog.service.js').then(({ createEmailLog }) => {
createEmailLog({
fromAddress,
toAddress: Array.isArray(params.to) ? params.to.join(', ') : params.to,
subject: params.subject,
context: logContext.context,
customerId: logContext.customerId,
triggeredBy: logContext.triggeredBy,
smtpServer: credentials.host,
smtpPort: credentials.port,
smtpEncryption: credentials.encryption ?? 'SSL',
smtpUser: credentials.user,
success: true,
messageId: result.messageId,
smtpResponse: result.response,
}).catch((err) => console.error('EmailLog write error:', err));
});
}
return {
success: true,
messageId: result.messageId,
@@ -203,6 +232,26 @@ export async function sendEmail(
}
}
// E-Mail-Log erstellen (Fehler)
if (logContext) {
import('./emailLog.service.js').then(({ createEmailLog }) => {
createEmailLog({
fromAddress,
toAddress: Array.isArray(params.to) ? params.to.join(', ') : params.to,
subject: params.subject,
context: logContext.context,
customerId: logContext.customerId,
triggeredBy: logContext.triggeredBy,
smtpServer: credentials.host,
smtpPort: credentials.port,
smtpEncryption: credentials.encryption ?? 'SSL',
smtpUser: credentials.user,
success: false,
errorMessage,
}).catch((err) => console.error('EmailLog write error:', err));
});
}
return {
success: false,
error: errorMessage,
+87 -17
View File
@@ -47,6 +47,9 @@ export async function getAllUsers(filters: UserFilters) {
lastName: true,
isActive: true,
customerId: true,
whatsappNumber: true,
telegramUsername: true,
signalNumber: true,
createdAt: true,
roles: {
include: {
@@ -62,21 +65,25 @@ export async function getAllUsers(filters: UserFilters) {
prisma.user.count({ where }),
]);
// Get Developer role ID
const developerRole = await prisma.role.findFirst({
where: { name: 'Developer' },
});
// Get hidden role IDs
const [developerRole, gdprRole] = await Promise.all([
prisma.role.findFirst({ where: { name: 'Developer' } }),
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
]);
return {
users: users.map((u) => {
// Check if user has developer role assigned
const hasDeveloperAccess = developerRole
? u.roles.some((ur) => ur.roleId === developerRole.id)
: false;
const hasGdprAccess = gdprRole
? u.roles.some((ur) => ur.roleId === gdprRole.id)
: false;
return {
...u,
roles: u.roles.map((r) => r.role),
hasDeveloperAccess,
hasGdprAccess,
};
}),
pagination: buildPaginationResponse(page, limit, total),
@@ -93,6 +100,9 @@ export async function getUserById(id: number) {
lastName: true,
isActive: true,
customerId: true,
whatsappNumber: true,
telegramUsername: true,
signalNumber: true,
createdAt: true,
updatedAt: true,
roles: {
@@ -135,6 +145,10 @@ export async function createUser(data: {
roleIds: number[];
customerId?: number;
hasDeveloperAccess?: boolean;
hasGdprAccess?: boolean;
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
}) {
const hashedPassword = await bcrypt.hash(data.password, 10);
@@ -145,6 +159,9 @@ export async function createUser(data: {
firstName: data.firstName,
lastName: data.lastName,
customerId: data.customerId,
whatsappNumber: data.whatsappNumber || null,
telegramUsername: data.telegramUsername || null,
signalNumber: data.signalNumber || null,
roles: {
create: data.roleIds.map((roleId) => ({ roleId })),
},
@@ -167,6 +184,11 @@ export async function createUser(data: {
await setUserDeveloperAccess(user.id, true);
}
// DSGVO-Zugriff setzen falls aktiviert
if (data.hasGdprAccess) {
await setUserGdprAccess(user.id, true);
}
return user;
}
@@ -181,9 +203,13 @@ export async function updateUser(
roleIds?: number[];
customerId?: number;
hasDeveloperAccess?: boolean;
hasGdprAccess?: boolean;
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
}
) {
const { roleIds, password, hasDeveloperAccess, ...userData } = data;
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, ...userData } = data;
// Check if this would remove the last admin
const isBeingDeactivated = userData.isActive === false;
@@ -311,18 +337,20 @@ export async function updateUser(
}
// Handle developer access
console.log('updateUser - hasDeveloperAccess:', hasDeveloperAccess);
if (hasDeveloperAccess !== undefined) {
await setUserDeveloperAccess(id, hasDeveloperAccess);
}
// Handle GDPR access
if (hasGdprAccess !== undefined) {
await setUserGdprAccess(id, hasGdprAccess);
}
return getUserById(id);
}
// Helper to set developer access for a user
async function setUserDeveloperAccess(userId: number, enabled: boolean) {
console.log('setUserDeveloperAccess called - userId:', userId, 'enabled:', enabled);
// Get or create developer:access permission
let developerPerm = await prisma.permission.findFirst({
where: { resource: 'developer', action: 'access' },
@@ -356,11 +384,7 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
where: { userId, roleId: developerRole.id },
});
console.log('setUserDeveloperAccess - developerRole.id:', developerRole.id, 'hasRole:', hasRole);
if (enabled && !hasRole) {
// Add Developer role
console.log('Adding Developer role');
await prisma.userRole.create({
data: { userId, roleId: developerRole.id },
});
@@ -370,8 +394,6 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
data: { tokenInvalidatedAt: new Date() },
});
} else if (!enabled && hasRole) {
// Remove Developer role
console.log('Removing Developer role');
await prisma.userRole.delete({
where: { userId_roleId: { userId, roleId: developerRole.id } },
});
@@ -380,8 +402,56 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
} else {
console.log('No action needed - enabled:', enabled, 'hasRole:', !!hasRole);
}
}
// Helper to set GDPR access for a user
async function setUserGdprAccess(userId: number, enabled: boolean) {
// Get or create DSGVO role
let gdprRole = await prisma.role.findFirst({
where: { name: 'DSGVO' },
});
if (!gdprRole) {
// Create DSGVO role with all audit:* and gdpr:* permissions
const gdprPermissions = await prisma.permission.findMany({
where: {
OR: [{ resource: 'audit' }, { resource: 'gdpr' }],
},
});
gdprRole = await prisma.role.create({
data: {
name: 'DSGVO',
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
permissions: {
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
},
},
});
}
// Check if user already has DSGVO role
const hasRole = await prisma.userRole.findFirst({
where: { userId, roleId: gdprRole.id },
});
if (enabled && !hasRole) {
await prisma.userRole.create({
data: { userId, roleId: gdprRole.id },
});
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
} else if (!enabled && hasRole) {
await prisma.userRole.delete({
where: { userId_roleId: { userId, roleId: gdprRole.id } },
});
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
}
}