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:
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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 & 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' });
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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();
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user