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

This commit is contained in:
2026-03-21 11:59:53 +01:00
parent 89cf92eaf5
commit f2876f877e
1491 changed files with 265550 additions and 1292 deletions
@@ -9,6 +9,9 @@ const DEFAULT_SETTINGS: Record<string, string> = {
deadlineCriticalDays: '14', // Rot: Kritisch
deadlineWarningDays: '42', // Gelb: Warnung (6 Wochen)
deadlineOkDays: '90', // Grün: OK (3 Monate)
// Ausweis-Ablauf: Fristenschwellen (in Tagen)
documentExpiryCriticalDays: '30', // Rot: Kritisch (Standard 30 Tage)
documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage)
};
export async function getSetting(key: string): Promise<string | null> {
+504
View File
@@ -0,0 +1,504 @@
import { AuditAction, AuditSensitivity, Prisma } from '@prisma/client';
import crypto from 'crypto';
import { encrypt, decrypt } from '../utils/encryption.js';
import prisma from '../lib/prisma.js';
export interface CreateAuditLogData {
userId?: number;
userEmail: string;
userRole?: string;
customerId?: number;
isCustomerPortal?: boolean;
action: AuditAction;
sensitivity?: AuditSensitivity;
resourceType: string;
resourceId?: string;
resourceLabel?: string;
endpoint: string;
httpMethod: string;
ipAddress: string;
userAgent?: string;
changesBefore?: Record<string, unknown>;
changesAfter?: Record<string, unknown>;
dataSubjectId?: number;
legalBasis?: string;
success?: boolean;
errorMessage?: string;
durationMs?: number;
}
export interface AuditLogSearchParams {
userId?: number;
customerId?: number;
dataSubjectId?: number;
action?: AuditAction;
sensitivity?: AuditSensitivity;
resourceType?: string;
resourceId?: string;
startDate?: Date;
endDate?: Date;
success?: boolean;
search?: string;
page?: number;
limit?: number;
}
/**
* Generiert einen SHA-256 Hash für einen Audit-Log-Eintrag
*/
function generateHash(data: {
userEmail: string;
action: AuditAction;
resourceType: string;
resourceId?: string | null;
endpoint: string;
createdAt: Date;
previousHash?: string | null;
}): string {
const content = JSON.stringify({
userEmail: data.userEmail,
action: data.action,
resourceType: data.resourceType,
resourceId: data.resourceId,
endpoint: data.endpoint,
createdAt: data.createdAt.toISOString(),
previousHash: data.previousHash || '',
});
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Bestimmt die Sensitivität basierend auf dem Ressourcentyp
*/
function determineSensitivity(resourceType: string): AuditSensitivity {
const sensitivityMap: Record<string, AuditSensitivity> = {
// CRITICAL
Authentication: 'CRITICAL',
BankCard: 'CRITICAL',
IdentityDocument: 'CRITICAL',
// HIGH
Customer: 'HIGH',
User: 'HIGH',
CustomerConsent: 'HIGH',
DataDeletionRequest: 'HIGH',
// MEDIUM
Contract: 'MEDIUM',
Address: 'MEDIUM',
Meter: 'MEDIUM',
MeterReading: 'MEDIUM',
StressfreiEmail: 'MEDIUM',
CachedEmail: 'MEDIUM',
// LOW
Provider: 'LOW',
Tariff: 'LOW',
SalesPlatform: 'LOW',
AppSetting: 'LOW',
ContractCategory: 'LOW',
CancellationPeriod: 'LOW',
ContractDuration: 'LOW',
};
return sensitivityMap[resourceType] || 'MEDIUM';
}
/**
* Prüft ob Änderungen verschlüsselt werden sollen
*/
function shouldEncryptChanges(resourceType: string): boolean {
const encryptedTypes = [
'BankCard',
'IdentityDocument',
'User',
'Customer', // Enthält Portal-Passwörter
];
return encryptedTypes.includes(resourceType);
}
/**
* Erstellt einen neuen Audit-Log-Eintrag mit Hash-Kette
*/
export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
try {
// Letzten Hash abrufen für die Kette
const lastLog = await prisma.auditLog.findFirst({
orderBy: { id: 'desc' },
select: { hash: true },
});
const previousHash = lastLog?.hash || null;
const createdAt = new Date();
// Sensitivität bestimmen falls nicht angegeben
const sensitivity = data.sensitivity || determineSensitivity(data.resourceType);
// Änderungen serialisieren und ggf. verschlüsseln
let changesBefore: string | null = null;
let changesAfter: string | null = null;
let changesEncrypted = false;
if (data.changesBefore || data.changesAfter) {
changesEncrypted = shouldEncryptChanges(data.resourceType);
if (data.changesBefore) {
const json = JSON.stringify(data.changesBefore);
changesBefore = changesEncrypted ? encrypt(json) : json;
}
if (data.changesAfter) {
const json = JSON.stringify(data.changesAfter);
changesAfter = changesEncrypted ? encrypt(json) : json;
}
}
// Hash generieren
const hash = generateHash({
userEmail: data.userEmail,
action: data.action,
resourceType: data.resourceType,
resourceId: data.resourceId,
endpoint: data.endpoint,
createdAt,
previousHash,
});
// Eintrag erstellen
await prisma.auditLog.create({
data: {
userId: data.userId,
userEmail: data.userEmail,
userRole: data.userRole,
customerId: data.customerId,
isCustomerPortal: data.isCustomerPortal || false,
action: data.action,
sensitivity,
resourceType: data.resourceType,
resourceId: data.resourceId,
resourceLabel: data.resourceLabel,
endpoint: data.endpoint,
httpMethod: data.httpMethod,
ipAddress: data.ipAddress,
userAgent: data.userAgent,
changesBefore,
changesAfter,
changesEncrypted,
dataSubjectId: data.dataSubjectId,
legalBasis: data.legalBasis,
success: data.success ?? true,
errorMessage: data.errorMessage,
durationMs: data.durationMs,
createdAt,
hash,
previousHash,
},
});
} catch (error) {
// Audit-Logging darf niemals die Hauptoperation blockieren
console.error('[AuditService] Fehler beim Erstellen des Audit-Logs:', error);
}
}
/**
* Sucht Audit-Logs mit Filtern und Paginierung
*/
export async function searchAuditLogs(params: AuditLogSearchParams) {
const {
userId,
customerId,
dataSubjectId,
action,
sensitivity,
resourceType,
resourceId,
startDate,
endDate,
success,
search,
page = 1,
limit = 50,
} = params;
const where: Prisma.AuditLogWhereInput = {};
if (userId !== undefined) where.userId = userId;
if (customerId !== undefined) where.customerId = customerId;
if (dataSubjectId !== undefined) where.dataSubjectId = dataSubjectId;
if (action) where.action = action;
if (sensitivity) where.sensitivity = sensitivity;
if (resourceType) where.resourceType = resourceType;
if (resourceId) where.resourceId = resourceId;
if (success !== undefined) where.success = success;
if (startDate || endDate) {
where.createdAt = {};
if (startDate) where.createdAt.gte = startDate;
if (endDate) where.createdAt.lte = endDate;
}
if (search) {
where.OR = [
{ userEmail: { contains: search } },
{ resourceLabel: { contains: search } },
{ endpoint: { contains: search } },
];
}
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.auditLog.count({ where }),
]);
// Entschlüsselung der Änderungen wenn nötig
const decryptedLogs = logs.map((log) => ({
...log,
changesBefore: log.changesBefore && log.changesEncrypted
? JSON.parse(decrypt(log.changesBefore))
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
changesAfter: log.changesAfter && log.changesEncrypted
? JSON.parse(decrypt(log.changesAfter))
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
}));
return {
data: decryptedLogs,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Holt einen einzelnen Audit-Log-Eintrag
*/
export async function getAuditLogById(id: number) {
const log = await prisma.auditLog.findUnique({
where: { id },
});
if (!log) return null;
return {
...log,
changesBefore: log.changesBefore && log.changesEncrypted
? JSON.parse(decrypt(log.changesBefore))
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
changesAfter: log.changesAfter && log.changesEncrypted
? JSON.parse(decrypt(log.changesAfter))
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
};
}
/**
* Holt alle Audit-Logs für eine betroffene Person (DSGVO)
*/
export async function getAuditLogsByDataSubject(customerId: number) {
const logs = await prisma.auditLog.findMany({
where: { dataSubjectId: customerId },
orderBy: { createdAt: 'desc' },
});
return logs.map((log) => ({
...log,
changesBefore: log.changesBefore && log.changesEncrypted
? JSON.parse(decrypt(log.changesBefore))
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
changesAfter: log.changesAfter && log.changesEncrypted
? JSON.parse(decrypt(log.changesAfter))
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
}));
}
/**
* Verifiziert die Integrität der Hash-Kette
*/
export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
valid: boolean;
checkedCount: number;
invalidEntries: number[];
}> {
const where: Prisma.AuditLogWhereInput = {};
if (fromId !== undefined) where.id = { gte: fromId };
if (toId !== undefined) where.id = { ...(where.id as object || {}), lte: toId };
const logs = await prisma.auditLog.findMany({
where,
orderBy: { id: 'asc' },
select: {
id: true,
userEmail: true,
action: true,
resourceType: true,
resourceId: true,
endpoint: true,
createdAt: true,
hash: true,
previousHash: true,
},
});
const invalidEntries: number[] = [];
for (let i = 0; i < logs.length; i++) {
const log = logs[i];
// Hash neu berechnen
const expectedHash = generateHash({
userEmail: log.userEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
endpoint: log.endpoint,
createdAt: log.createdAt,
previousHash: log.previousHash,
});
// Prüfen ob Hash übereinstimmt
if (log.hash !== expectedHash) {
invalidEntries.push(log.id);
continue;
}
// Prüfen ob previousHash mit dem Hash des vorherigen Eintrags übereinstimmt
if (i > 0) {
const previousLog = logs[i - 1];
if (log.previousHash !== previousLog.hash) {
invalidEntries.push(log.id);
}
}
}
return {
valid: invalidEntries.length === 0,
checkedCount: logs.length,
invalidEntries,
};
}
/**
* Exportiert Audit-Logs als JSON oder CSV
*/
export async function exportAuditLogs(
params: AuditLogSearchParams,
format: 'json' | 'csv' = 'json'
): Promise<string> {
// Alle Logs ohne Paginierung
const result = await searchAuditLogs({ ...params, limit: 100000, page: 1 });
const logs = result.data;
if (format === 'json') {
return JSON.stringify(logs, null, 2);
}
// CSV Export
const headers = [
'ID',
'Zeitstempel',
'Benutzer',
'Aktion',
'Ressource',
'Ressource-ID',
'Bezeichnung',
'Endpoint',
'IP-Adresse',
'Erfolg',
'Sensitivität',
];
const rows = logs.map((log) => [
log.id.toString(),
log.createdAt.toISOString(),
log.userEmail,
log.action,
log.resourceType,
log.resourceId || '',
log.resourceLabel || '',
log.endpoint,
log.ipAddress,
log.success ? 'Ja' : 'Nein',
log.sensitivity,
]);
const csvContent = [
headers.join(';'),
...rows.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(';')),
].join('\n');
return csvContent;
}
/**
* Löscht alte Audit-Logs basierend auf Retention-Policies
* Hinweis: Diese Funktion sollte nur von einem autorisierten Admin-Prozess aufgerufen werden
*/
export async function runRetentionCleanup(): Promise<{
deletedCount: number;
policies: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }>;
}> {
const policies = await prisma.auditRetentionPolicy.findMany({
where: { isActive: true },
});
const results: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }> = [];
let totalDeleted = 0;
for (const policy of policies) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - policy.retentionDays);
const where: Prisma.AuditLogWhereInput = {
createdAt: { lt: cutoffDate },
};
if (policy.resourceType !== '*') {
where.resourceType = policy.resourceType;
}
if (policy.sensitivity) {
where.sensitivity = policy.sensitivity;
}
const deleted = await prisma.auditLog.deleteMany({ where });
results.push({
resourceType: policy.resourceType,
sensitivity: policy.sensitivity,
deletedCount: deleted.count,
});
totalDeleted += deleted.count;
}
return {
deletedCount: totalDeleted,
policies: results,
};
}
/**
* Holt die Retention-Policies
*/
export async function getRetentionPolicies() {
return prisma.auditRetentionPolicy.findMany({
orderBy: [{ resourceType: 'asc' }, { sensitivity: 'asc' }],
});
}
/**
* Aktualisiert eine Retention-Policy
*/
export async function updateRetentionPolicy(
id: number,
data: { retentionDays?: number; description?: string; legalBasis?: string; isActive?: boolean }
) {
return prisma.auditRetentionPolicy.update({
where: { id },
data,
});
}
+3
View File
@@ -283,6 +283,9 @@ export async function getUserById(id: number) {
lastName: user.lastName,
isActive: user.isActive,
customerId: user.customerId,
whatsappNumber: user.whatsappNumber,
telegramUsername: user.telegramUsername,
signalNumber: user.signalNumber,
roles: user.roles.map((ur) => ur.role.name),
permissions: Array.from(permissions),
isCustomerPortal: false,
@@ -0,0 +1,207 @@
import prisma from '../lib/prisma.js';
import fs from 'fs';
import path from 'path';
/**
* Vollmachten für einen Kunden abrufen (wer darf diesen Kunden einsehen?)
*/
export async function getAuthorizationsForCustomer(customerId: number) {
return prisma.representativeAuthorization.findMany({
where: { customerId },
include: {
representative: {
select: { id: true, customerNumber: true, firstName: true, lastName: true },
},
},
orderBy: { createdAt: 'desc' },
});
}
/**
* Vollmachten die ein Vertreter erhalten hat (welche Kunden darf er einsehen?)
*/
export async function getAuthorizationsForRepresentative(representativeId: number) {
return prisma.representativeAuthorization.findMany({
where: { representativeId },
include: {
customer: {
select: { id: true, customerNumber: true, firstName: true, lastName: true },
},
},
orderBy: { createdAt: 'desc' },
});
}
/**
* Prüft ob ein Vertreter eine Vollmacht für einen Kunden hat
*/
export async function hasAuthorization(customerId: number, representativeId: number): Promise<boolean> {
const auth = await prisma.representativeAuthorization.findUnique({
where: {
customerId_representativeId: { customerId, representativeId },
},
});
return auth?.isGranted === true;
}
/**
* Vollmacht erteilen oder aktualisieren
*/
export async function grantAuthorization(
customerId: number,
representativeId: number,
data: { source?: string; documentPath?: string; notes?: string }
) {
return prisma.representativeAuthorization.upsert({
where: {
customerId_representativeId: { customerId, representativeId },
},
update: {
isGranted: true,
grantedAt: new Date(),
withdrawnAt: null,
source: data.source,
documentPath: data.documentPath ?? undefined,
notes: data.notes ?? undefined,
},
create: {
customerId,
representativeId,
isGranted: true,
grantedAt: new Date(),
source: data.source || 'crm-backend',
documentPath: data.documentPath,
notes: data.notes,
},
});
}
/**
* Vollmacht widerrufen + PDF löschen falls vorhanden
*/
export async function withdrawAuthorization(customerId: number, representativeId: number) {
// Erst prüfen ob eine PDF vorhanden ist
const existing = await prisma.representativeAuthorization.findUnique({
where: { customerId_representativeId: { customerId, representativeId } },
select: { documentPath: true },
});
// PDF vom Filesystem löschen
if (existing?.documentPath) {
try {
const filePath = path.join(process.cwd(), existing.documentPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (err) {
console.error('Fehler beim Löschen der Vollmacht-PDF:', err);
}
}
return prisma.representativeAuthorization.update({
where: {
customerId_representativeId: { customerId, representativeId },
},
data: {
isGranted: false,
withdrawnAt: new Date(),
documentPath: null,
},
});
}
/**
* Vollmacht-Dokument (PDF) hochladen
*/
export async function updateAuthorizationDocument(
customerId: number,
representativeId: number,
documentPath: string
) {
// Wenn Dokument hochgeladen wird, gilt das als Vollmacht erteilen
return prisma.representativeAuthorization.upsert({
where: {
customerId_representativeId: { customerId, representativeId },
},
update: {
documentPath,
isGranted: true,
grantedAt: new Date(),
withdrawnAt: null,
source: 'papier',
},
create: {
customerId,
representativeId,
documentPath,
isGranted: true,
grantedAt: new Date(),
source: 'papier',
},
});
}
/**
* Vollmacht-Dokument löschen
*/
export async function deleteAuthorizationDocument(customerId: number, representativeId: number) {
return prisma.representativeAuthorization.update({
where: {
customerId_representativeId: { customerId, representativeId },
},
data: {
documentPath: null,
},
});
}
/**
* Alle genehmigten Vertreter-IDs für einen Kunden
* (Welche Vertreter dürfen die Verträge dieses Kunden sehen?)
*/
export async function getAuthorizedRepresentativeIds(customerId: number): Promise<number[]> {
const auths = await prisma.representativeAuthorization.findMany({
where: { customerId, isGranted: true },
select: { representativeId: true },
});
return auths.map((a) => a.representativeId);
}
/**
* Alle Kunden-IDs für die ein Vertreter eine Vollmacht hat
*/
export async function getAuthorizedCustomerIds(representativeId: number): Promise<number[]> {
const auths = await prisma.representativeAuthorization.findMany({
where: { representativeId, isGranted: true },
select: { customerId: true },
});
return auths.map((a) => a.customerId);
}
/**
* Erstellt fehlende Vollmacht-Einträge für bestehende Vertreterbeziehungen
* (wird aufgerufen wenn man den Tab aufruft)
*/
export async function ensureAuthorizationEntries(customerId: number) {
// Alle aktiven Vertreter für diesen Kunden
const representatives = await prisma.customerRepresentative.findMany({
where: { customerId, isActive: true },
select: { representativeId: true },
});
for (const rep of representatives) {
// Erstelle Eintrag falls nicht vorhanden
await prisma.representativeAuthorization.upsert({
where: {
customerId_representativeId: { customerId, representativeId: rep.representativeId },
},
update: {}, // Nichts ändern wenn schon vorhanden
create: {
customerId,
representativeId: rep.representativeId,
isGranted: false,
},
});
}
}
@@ -0,0 +1,187 @@
import { ConsentType, ConsentStatus } from '@prisma/client';
import crypto from 'crypto';
import prisma from '../lib/prisma.js';
import * as consentService from './consent.service.js';
import * as appSettingService from './appSetting.service.js';
import PDFDocument from 'pdfkit';
/**
* Kunden-Lookup per consentHash
*/
export async function getCustomerByConsentHash(hash: string) {
const customer = await prisma.customer.findUnique({
where: { consentHash: hash },
select: {
id: true,
firstName: true,
lastName: true,
customerNumber: true,
salutation: true,
email: true,
},
});
if (!customer) return null;
const consents = await consentService.getCustomerConsents(customer.id);
return { customer, consents };
}
/**
* Alle 4 Einwilligungen über den öffentlichen Link erteilen
*/
export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
const customer = await prisma.customer.findUnique({
where: { consentHash: hash },
select: { id: true, firstName: true, lastName: true },
});
if (!customer) {
throw new Error('Ungültiger Link');
}
const results = [];
for (const type of Object.values(ConsentType)) {
const result = await consentService.updateConsent(customer.id, type, {
status: ConsentStatus.GRANTED,
source: 'public-link',
ipAddress,
createdBy: `${customer.firstName} ${customer.lastName} (Public-Link)`,
});
results.push(result);
}
return results;
}
/**
* consentHash generieren falls nicht vorhanden
*/
export async function ensureConsentHash(customerId: number): Promise<string> {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { consentHash: true },
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
if (customer.consentHash) {
return customer.consentHash;
}
const hash = crypto.randomUUID();
await prisma.customer.update({
where: { id: customerId },
data: { consentHash: hash },
});
return hash;
}
/**
* Platzhalter in Text ersetzen
*/
function replacePlaceholders(html: string, customer: {
firstName: string;
lastName: string;
customerNumber: string;
salutation?: string | null;
email?: string | null;
}): string {
return html
.replace(/\{\{vorname\}\}/gi, customer.firstName || '')
.replace(/\{\{nachname\}\}/gi, customer.lastName || '')
.replace(/\{\{kundennummer\}\}/gi, customer.customerNumber || '')
.replace(/\{\{anrede\}\}/gi, customer.salutation || '')
.replace(/\{\{email\}\}/gi, customer.email || '')
.replace(/\{\{datum\}\}/gi, new Date().toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}));
}
/**
* Datenschutzerklärung als HTML abrufen (mit Platzhaltern ersetzt)
*/
export async function getPrivacyPolicyHtml(customerId?: number): Promise<string> {
const html = await appSettingService.getSetting('privacyPolicyHtml');
if (!html) {
return '<p>Keine Datenschutzerklärung hinterlegt.</p>';
}
if (!customerId) return html;
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: {
firstName: true,
lastName: true,
customerNumber: true,
salutation: true,
email: true,
},
});
if (!customer) return html;
return replacePlaceholders(html, customer);
}
/**
* HTML zu Plain-Text konvertieren (für PDF)
*/
function htmlToText(html: string): string {
return html
.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, '\n$1\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<li[^>]*>(.*?)<\/li>/gi, ' • $1\n')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/**
* Datenschutzerklärung als PDF generieren
*/
export async function generateConsentPdf(customerId: number): Promise<Buffer> {
const html = await getPrivacyPolicyHtml(customerId);
const text = htmlToText(html);
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Titel
doc.fontSize(18).font('Helvetica-Bold').text('Datenschutzerklärung', { align: 'center' });
doc.moveDown(1);
// Datum
doc.fontSize(10).font('Helvetica')
.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE')}`, { align: 'right' });
doc.moveDown(1);
// Inhalt
doc.fontSize(11).font('Helvetica').text(text, {
align: 'left',
lineGap: 4,
});
doc.end();
});
}
+267
View File
@@ -0,0 +1,267 @@
import { ConsentType, ConsentStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
import fs from 'fs';
import path from 'path';
export interface UpdateConsentData {
status: ConsentStatus;
source?: string;
documentPath?: string;
version?: string;
ipAddress?: string;
createdBy: string;
}
/**
* Holt alle Einwilligungen eines Kunden
*/
export async function getCustomerConsents(customerId: number) {
const consents = await prisma.customerConsent.findMany({
where: { customerId },
orderBy: { consentType: 'asc' },
});
// Alle verfügbaren Consent-Typen mit Status
const allTypes = Object.values(ConsentType);
const consentMap = new Map(consents.map((c) => [c.consentType, c]));
return allTypes.map((type) => {
const existing = consentMap.get(type);
return existing || {
id: null,
customerId,
consentType: type,
status: 'PENDING' as ConsentStatus,
grantedAt: null,
withdrawnAt: null,
source: null,
documentPath: null,
version: null,
ipAddress: null,
createdBy: null,
createdAt: null,
updatedAt: null,
};
});
}
/**
* Aktualisiert oder erstellt eine Einwilligung
*/
export async function updateConsent(
customerId: number,
consentType: ConsentType,
data: UpdateConsentData
) {
// Prüfen ob Kunde existiert
const customer = await prisma.customer.findUnique({
where: { id: customerId },
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
const now = new Date();
const updateData = {
status: data.status,
source: data.source,
documentPath: data.documentPath,
version: data.version,
ipAddress: data.ipAddress,
grantedAt: data.status === 'GRANTED' ? now : undefined,
withdrawnAt: data.status === 'WITHDRAWN' ? now : undefined,
};
const result = await prisma.customerConsent.upsert({
where: {
customerId_consentType: { customerId, consentType },
},
update: updateData,
create: {
customerId,
consentType,
...updateData,
createdBy: data.createdBy,
},
});
// Bei Widerruf: Datenschutz-PDF löschen wenn keine Einwilligung mehr besteht
if (data.status === 'WITHDRAWN') {
await deletePrivacyPdfOnWithdraw(customerId);
}
return result;
}
/**
* Holt die Historie einer Einwilligung (aus Audit-Logs)
*/
export async function getConsentHistory(customerId: number, consentType: ConsentType) {
// Aus Audit-Logs die Änderungen dieser Einwilligung abrufen
const logs = await prisma.auditLog.findMany({
where: {
resourceType: 'CustomerConsent',
dataSubjectId: customerId,
changesAfter: { contains: consentType },
},
orderBy: { createdAt: 'desc' },
take: 50,
});
return logs;
}
/**
* Prüft ob eine bestimmte Einwilligung erteilt wurde
*/
export async function hasConsent(customerId: number, consentType: ConsentType): Promise<boolean> {
const consent = await prisma.customerConsent.findUnique({
where: {
customerId_consentType: { customerId, consentType },
},
});
return consent?.status === 'GRANTED';
}
/**
* Prüft ob ein Kunde die DSGVO-Einwilligung erfüllt hat.
* Erfüllt = entweder privacyPolicyPath vorhanden ODER alle Online-Consents GRANTED.
*/
export async function hasFullConsent(customerId: number): Promise<{
hasConsent: boolean;
hasPaperConsent: boolean;
hasOnlineConsent: boolean;
consentDetails: { type: string; status: string }[];
consentHash: string | null;
}> {
// Prüfe ob Papier-Datenschutzerklärung vorhanden
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { privacyPolicyPath: true, consentHash: true },
});
const hasPaperConsent = !!customer?.privacyPolicyPath;
// Online-Consents prüfen
const allTypes = Object.values(ConsentType);
const consents = await prisma.customerConsent.findMany({
where: { customerId },
});
const consentMap = new Map(consents.map((c) => [c.consentType, c.status]));
const consentDetails = allTypes.map((type) => ({
type,
status: (consentMap.get(type) || 'PENDING') as string,
}));
const hasOnlineConsent = allTypes.every(
(type) => consentMap.get(type) === 'GRANTED'
);
return {
hasConsent: hasPaperConsent || hasOnlineConsent,
hasPaperConsent,
hasOnlineConsent,
consentDetails,
consentHash: customer?.consentHash || null,
};
}
/**
* Widerruft alle Einwilligungen eines Kunden
*/
export async function withdrawAllConsents(customerId: number, withdrawnBy: string) {
const result = await prisma.customerConsent.updateMany({
where: {
customerId,
status: 'GRANTED',
},
data: {
status: 'WITHDRAWN',
withdrawnAt: new Date(),
},
});
// Datenschutz-PDF löschen
await deletePrivacyPdfOnWithdraw(customerId);
return result;
}
/**
* Löscht die Datenschutz-PDF bei Widerruf.
* Sobald auch nur eine Einwilligung widerrufen wird, ist die Gesamteinwilligung ungültig.
*/
async function deletePrivacyPdfOnWithdraw(customerId: number) {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { privacyPolicyPath: true },
});
if (customer?.privacyPolicyPath) {
try {
const filePath = path.join(process.cwd(), customer.privacyPolicyPath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (err) {
console.error('Fehler beim Löschen der Datenschutz-PDF:', err);
}
await prisma.customer.update({
where: { id: customerId },
data: { privacyPolicyPath: null },
});
console.log(`Datenschutz-PDF für Kunde ${customerId} gelöscht (Einwilligung widerrufen)`);
}
}
/**
* Consent-Übersicht für DSGVO-Dashboard
*/
export async function getConsentOverview() {
const allConsents = await prisma.customerConsent.groupBy({
by: ['consentType', 'status'],
_count: { id: true },
});
// Gruppieren nach Typ
const overview: Record<string, { granted: number; withdrawn: number; pending: number }> = {};
for (const type of Object.values(ConsentType)) {
overview[type] = { granted: 0, withdrawn: 0, pending: 0 };
}
for (const row of allConsents) {
const type = row.consentType;
const status = row.status.toLowerCase() as 'granted' | 'withdrawn' | 'pending';
overview[type][status] = row._count.id;
}
return overview;
}
/**
* Consent-Typ Labels für UI
*/
export const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: string }> = {
DATA_PROCESSING: {
label: 'Datenverarbeitung',
description: 'Grundlegende Verarbeitung personenbezogener Daten zur Vertragserfüllung',
},
MARKETING_EMAIL: {
label: 'E-Mail-Marketing',
description: 'Zusendung von Werbung und Angeboten per E-Mail',
},
MARKETING_PHONE: {
label: 'Telefonmarketing',
description: 'Kontaktaufnahme zu Werbezwecken per Telefon',
},
DATA_SHARING_PARTNER: {
label: 'Datenweitergabe',
description: 'Weitergabe von Daten an Partnerunternehmen',
},
};
+252 -3
View File
@@ -53,11 +53,54 @@ export interface CockpitSummary {
openTasks: number;
pendingContracts: number;
reviewDue: number; // Erneute Prüfung fällig (Snooze abgelaufen)
missingConsents: number; // Fehlende oder widerrufene Einwilligungen
};
}
export interface DocumentAlert {
id: number;
type: string; // ID_CARD, PASSPORT, DRIVERS_LICENSE, OTHER
documentNumber: string;
expiryDate: string;
daysUntilExpiry: number;
urgency: UrgencyLevel;
customer: {
id: number;
customerNumber: string;
name: string;
};
}
export interface ReportedMeterReading {
id: number;
readingDate: string;
value: number;
unit: string;
notes?: string;
reportedBy?: string;
createdAt: string;
meter: {
id: number;
meterNumber: string;
type: string;
};
customer: {
id: number;
customerNumber: string;
name: string;
};
// Anbieter-Info für Quick-Login
providerPortal?: {
providerName: string;
portalUrl: string;
portalUsername?: string;
};
}
export interface CockpitResult {
contracts: CockpitContract[];
documentAlerts: DocumentAlert[];
reportedReadings: ReportedMeterReading[];
summary: CockpitSummary;
thresholds: {
criticalDays: number;
@@ -143,6 +186,8 @@ export async function getCockpitData(): Promise<CockpitResult> {
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
const warningDays = parseInt(settings.deadlineWarningDays) || 42;
const okDays = parseInt(settings.deadlineOkDays) || 90;
const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30;
const docExpiryWarningDays = parseInt(settings.documentExpiryWarningDays) || 90;
// Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für Schlussrechnung-Check)
const contracts = await prisma.contract.findMany({
@@ -231,9 +276,41 @@ export async function getCockpitData(): Promise<CockpitResult> {
openTasks: 0,
pendingContracts: 0,
reviewDue: 0,
missingConsents: 0,
},
};
// Consent-Daten batch-laden für alle Kunden
const allConsents = await prisma.customerConsent.findMany({
where: { status: 'GRANTED' },
select: { customerId: true, consentType: true },
});
// Map: customerId → Set<consentType>
const grantedConsentsMap = new Map<number, Set<string>>();
for (const c of allConsents) {
if (!grantedConsentsMap.has(c.customerId)) {
grantedConsentsMap.set(c.customerId, new Set());
}
grantedConsentsMap.get(c.customerId)!.add(c.consentType);
}
// Widerrufene Consents laden
const withdrawnConsents = await prisma.customerConsent.findMany({
where: { status: 'WITHDRAWN' },
select: { customerId: true, consentType: true },
});
const withdrawnConsentsMap = new Map<number, Set<string>>();
for (const c of withdrawnConsents) {
if (!withdrawnConsentsMap.has(c.customerId)) {
withdrawnConsentsMap.set(c.customerId, new Set());
}
withdrawnConsentsMap.get(c.customerId)!.add(c.consentType);
}
// Track welche Kunden bereits eine Consent-Warnung bekommen haben (nur einmal pro Kunde)
const customerConsentWarned = new Set<number>();
for (const contract of contracts) {
const issues: CockpitIssue[] = [];
@@ -407,17 +484,43 @@ export async function getCockpitData(): Promise<CockpitResult> {
summary.byCategory.missingData++;
}
// 7b. KEIN AUSWEIS (für DSL, FIBER, CABLE, MOBILE ist dies ein kritisches Problem)
if (!contract.identityDocumentId) {
// 7b. KEIN AUSWEIS (nur für Telekommunikationsprodukte relevant)
const requiresIdentityDocument = ['DSL', 'FIBER', 'CABLE', 'MOBILE'].includes(contract.type);
if (requiresIdentityDocument && !contract.identityDocumentId) {
issues.push({
type: 'missing_identity_document',
label: 'Ausweis fehlt',
urgency: requiresBankAndId ? 'critical' : 'warning',
urgency: 'critical',
details: 'Kein Ausweisdokument verknüpft',
});
summary.byCategory.missingData++;
}
// 7c. AUSWEIS LÄUFT AB (nur aktive Ausweise prüfen)
if (contract.identityDocument && contract.identityDocument.isActive && contract.identityDocument.expiryDate) {
const expiryDate = new Date(contract.identityDocument.expiryDate);
const today = new Date();
const daysUntilExpiry = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry < 0) {
issues.push({
type: 'identity_document_expired',
label: 'Ausweis abgelaufen',
urgency: 'critical',
details: `Ausweis seit ${Math.abs(daysUntilExpiry)} Tagen abgelaufen (${expiryDate.toLocaleDateString('de-DE')})`,
});
summary.byCategory.missingData++;
} else if (daysUntilExpiry <= docExpiryWarningDays) {
issues.push({
type: 'identity_document_expiring',
label: 'Ausweis läuft ab',
urgency: daysUntilExpiry <= docExpiryCriticalDays ? 'critical' : 'warning',
details: `Ausweis läuft in ${daysUntilExpiry} Tagen ab (${expiryDate.toLocaleDateString('de-DE')})`,
});
summary.byCategory.cancellationDeadlines++;
}
}
// 8. ENERGIE-SPEZIFISCH: KEIN ZÄHLER
if (['ELECTRICITY', 'GAS'].includes(contract.type) && contract.energyDetails) {
if (!contract.energyDetails.meterId) {
@@ -546,6 +649,36 @@ export async function getCockpitData(): Promise<CockpitResult> {
}
}
// #14 - Consent-Prüfung (nur für aktive Verträge, einmal pro Kunde)
if (['ACTIVE', 'PENDING', 'DRAFT'].includes(contract.status) && !customerConsentWarned.has(contract.customer.id)) {
const granted = grantedConsentsMap.get(contract.customer.id);
const withdrawn = withdrawnConsentsMap.get(contract.customer.id);
const requiredTypes = ['DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER'];
if (withdrawn && withdrawn.size > 0) {
// Mindestens eine Einwilligung widerrufen
issues.push({
type: 'consent_withdrawn',
label: 'Einwilligung widerrufen',
urgency: 'critical',
details: `${withdrawn.size} Einwilligung(en) widerrufen`,
});
summary.byCategory.missingConsents++;
customerConsentWarned.add(contract.customer.id);
} else if (!granted || granted.size < requiredTypes.length) {
// Nicht alle 4 Einwilligungen erteilt
const missing = requiredTypes.length - (granted?.size || 0);
issues.push({
type: 'missing_consents',
label: 'Fehlende Einwilligungen',
urgency: 'critical',
details: `${missing} von ${requiredTypes.length} Einwilligungen fehlen`,
});
summary.byCategory.missingConsents++;
customerConsentWarned.add(contract.customer.id);
}
}
// Nur Verträge mit Issues hinzufügen
if (issues.length > 0) {
const highestUrgency = getHighestUrgency(issues);
@@ -596,8 +729,16 @@ export async function getCockpitData(): Promise<CockpitResult> {
return urgencyOrder[a.highestUrgency] - urgencyOrder[b.highestUrgency];
});
// Vertragsunabhängige Ausweis-Warnungen
const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays);
// Gemeldete Zählerstände (REPORTED Status)
const reportedReadings = await getReportedMeterReadings();
return {
contracts: cockpitContracts,
documentAlerts,
reportedReadings,
summary,
thresholds: {
criticalDays,
@@ -606,3 +747,111 @@ export async function getCockpitData(): Promise<CockpitResult> {
},
};
}
/**
* Alle aktiven Ausweise die ablaufen oder abgelaufen sind (vertragsunabhängig)
*/
async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number): Promise<DocumentAlert[]> {
const now = new Date();
const inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000);
const documents = await prisma.identityDocument.findMany({
where: {
isActive: true,
expiryDate: { lte: inWarningDays },
},
include: {
customer: {
select: { id: true, customerNumber: true, firstName: true, lastName: true },
},
},
orderBy: { expiryDate: 'asc' },
});
return documents.map((doc) => {
const expiryDate = new Date(doc.expiryDate!);
const daysUntilExpiry = Math.ceil((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
let urgency: UrgencyLevel = 'warning';
if (daysUntilExpiry < 0) urgency = 'critical';
else if (daysUntilExpiry <= criticalDays) urgency = 'critical';
return {
id: doc.id,
type: doc.type,
documentNumber: doc.documentNumber,
expiryDate: expiryDate.toISOString(),
daysUntilExpiry,
urgency,
customer: {
id: doc.customer.id,
customerNumber: doc.customer.customerNumber,
name: `${doc.customer.firstName} ${doc.customer.lastName}`,
},
};
});
}
/**
* Vom Kunden gemeldete Zählerstände die noch nicht übertragen wurden
*/
async function getReportedMeterReadings(): Promise<ReportedMeterReading[]> {
const readings = await prisma.meterReading.findMany({
where: { status: 'REPORTED' },
include: {
meter: {
include: {
customer: {
select: { id: true, customerNumber: true, firstName: true, lastName: true },
},
// Energie-Verträge für diesen Zähler (um Provider-Portal-Daten zu bekommen)
energyDetails: {
include: {
contract: {
select: {
id: true,
portalUsername: true,
provider: {
select: { id: true, name: true, portalUrl: true },
},
},
},
},
take: 1,
},
},
},
},
orderBy: { createdAt: 'asc' },
});
return readings.map((r) => {
const contract = r.meter.energyDetails?.[0]?.contract;
const provider = contract?.provider;
return {
id: r.id,
readingDate: r.readingDate.toISOString(),
value: r.value,
unit: r.unit,
notes: r.notes ?? undefined,
reportedBy: r.reportedBy ?? undefined,
createdAt: r.createdAt.toISOString(),
meter: {
id: r.meter.id,
meterNumber: r.meter.meterNumber,
type: r.meter.type,
},
customer: {
id: r.meter.customer.id,
customerNumber: r.meter.customer.customerNumber,
name: `${r.meter.customer.firstName} ${r.meter.customer.lastName}`,
},
providerPortal: provider?.portalUrl ? {
providerName: provider.name,
portalUrl: provider.portalUrl,
portalUsername: contract?.portalUsername ?? undefined,
} : undefined,
};
});
}
+90
View File
@@ -0,0 +1,90 @@
import prisma from '../lib/prisma.js';
export interface CreateEmailLogData {
fromAddress: string;
toAddress: string;
subject: string;
context: string;
customerId?: number;
triggeredBy?: string;
smtpServer: string;
smtpPort: number;
smtpEncryption: string;
smtpUser: string;
success: boolean;
messageId?: string;
errorMessage?: string;
smtpResponse?: string;
}
export async function createEmailLog(data: CreateEmailLogData) {
return prisma.emailLog.create({ data });
}
export async function getEmailLogs(options?: {
page?: number;
limit?: number;
success?: boolean;
search?: string;
context?: string;
}) {
const page = options?.page || 1;
const limit = options?.limit || 50;
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
if (options?.success !== undefined) {
where.success = options.success;
}
if (options?.context) {
where.context = options.context;
}
if (options?.search) {
where.OR = [
{ fromAddress: { contains: options.search } },
{ toAddress: { contains: options.search } },
{ subject: { contains: options.search } },
{ errorMessage: { contains: options.search } },
];
}
const [logs, total] = await Promise.all([
prisma.emailLog.findMany({
where,
orderBy: { sentAt: 'desc' },
skip,
take: limit,
}),
prisma.emailLog.count({ where }),
]);
return {
data: logs,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
export async function getEmailLogById(id: number) {
return prisma.emailLog.findUnique({ where: { id } });
}
export async function getEmailLogStats() {
const [total, success, failed, last24h] = await Promise.all([
prisma.emailLog.count(),
prisma.emailLog.count({ where: { success: true } }),
prisma.emailLog.count({ where: { success: false } }),
prisma.emailLog.count({
where: { sentAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
}),
]);
return { total, success, failed, last24h };
}
@@ -73,6 +73,9 @@ export interface CreateProviderConfigData {
imapEncryption?: MailEncryption;
smtpEncryption?: MailEncryption;
allowSelfSignedCerts?: boolean;
// System-E-Mail
systemEmailAddress?: string;
systemEmailPassword?: string;
isActive?: boolean;
isDefault?: boolean;
}
@@ -86,9 +89,10 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
});
}
// Passwort verschlüsseln falls vorhanden
// Passwörter verschlüsseln falls vorhanden
const { encrypt } = await import('../../utils/encryption.js');
const passwordEncrypted = data.password ? encrypt(data.password) : null;
const systemEmailPasswordEncrypted = data.systemEmailPassword ? encrypt(data.systemEmailPassword) : null;
return prisma.emailProviderConfig.create({
data: {
@@ -103,6 +107,8 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
imapEncryption: data.imapEncryption ?? 'SSL',
smtpEncryption: data.smtpEncryption ?? 'SSL',
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
systemEmailAddress: data.systemEmailAddress || null,
systemEmailPasswordEncrypted,
isActive: data.isActive ?? true,
isDefault: data.isDefault ?? false,
},
@@ -134,20 +140,30 @@ export async function updateProviderConfig(
if (data.imapEncryption !== undefined) updateData.imapEncryption = data.imapEncryption;
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
if (data.systemEmailAddress !== undefined) updateData.systemEmailAddress = data.systemEmailAddress || null;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
const { encrypt } = await import('../../utils/encryption.js');
// Passwort-Logik:
// - Wenn neues Passwort übergeben → verschlüsseln und speichern
// - Wenn Benutzername gelöscht wird → Passwort auch löschen (gehören zusammen)
if (data.password) {
const { encrypt } = await import('../../utils/encryption.js');
updateData.passwordEncrypted = encrypt(data.password);
} else if (data.username !== undefined && !data.username) {
// Benutzername wird gelöscht → Passwort auch löschen
updateData.passwordEncrypted = null;
}
// System-E-Mail-Passwort
if (data.systemEmailPassword) {
updateData.systemEmailPasswordEncrypted = encrypt(data.systemEmailPassword);
} else if (data.systemEmailAddress !== undefined && !data.systemEmailAddress) {
// System-E-Mail wird gelöscht → Passwort auch löschen
updateData.systemEmailPasswordEncrypted = null;
}
return prisma.emailProviderConfig.update({
where: { id },
data: updateData,
@@ -564,3 +580,45 @@ export async function testProviderConnection(options?: {
};
}
}
// ==================== SYSTEM EMAIL ====================
export interface SystemEmailCredentials {
emailAddress: string;
password: string;
smtpServer: string;
smtpPort: number;
smtpEncryption: MailEncryption;
allowSelfSignedCerts: boolean;
}
/**
* System-E-Mail-Credentials vom aktiven Provider holen.
* Wird für automatisierten Versand (DSGVO, Benachrichtigungen etc.) verwendet.
*/
export async function getSystemEmailCredentials(): Promise<SystemEmailCredentials | null> {
const config = await getActiveProviderConfig();
if (!config?.systemEmailAddress || !config?.systemEmailPasswordEncrypted) {
return null;
}
let password: string;
try {
password = decrypt(config.systemEmailPasswordEncrypted);
} catch {
console.error('System-E-Mail-Passwort konnte nicht entschlüsselt werden');
return null;
}
const settings = await getImapSmtpSettings();
if (!settings) return null;
return {
emailAddress: config.systemEmailAddress,
password,
smtpServer: settings.smtpServer,
smtpPort: settings.smtpPort,
smtpEncryption: settings.smtpEncryption,
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
}
+565
View File
@@ -0,0 +1,565 @@
import { DeletionRequestStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
import { getAuditLogsByDataSubject } from './audit.service.js';
import { getCustomerConsents, withdrawAllConsents } from './consent.service.js';
import PDFDocument from 'pdfkit';
import fs from 'fs';
import path from 'path';
export interface CreateDeletionRequestData {
customerId: number;
requestSource: string;
requestedBy: string;
}
export interface ProcessDeletionRequestData {
processedBy: string;
action: 'complete' | 'partial' | 'reject';
retentionReason?: string;
}
/**
* Exportiert alle Daten eines Kunden (DSGVO Art. 15)
*/
export async function exportCustomerData(customerId: number) {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
include: {
addresses: true,
bankCards: {
select: {
id: true,
accountHolder: true,
iban: true,
bic: true,
bankName: true,
isActive: true,
createdAt: true,
},
},
identityDocuments: {
select: {
id: true,
type: true,
documentNumber: true,
issuingAuthority: true,
issueDate: true,
expiryDate: true,
isActive: true,
createdAt: true,
},
},
meters: {
include: {
readings: true,
},
},
contracts: {
include: {
address: true,
billingAddress: true,
provider: true,
tariff: true,
energyDetails: {
include: { invoices: true },
},
internetDetails: {
include: { phoneNumbers: true },
},
mobileDetails: {
include: { simCards: true },
},
tvDetails: true,
carInsuranceDetails: true,
historyEntries: true,
tasks: {
include: { subtasks: true },
},
},
},
stressfreiEmails: {
select: {
id: true,
email: true,
platform: true,
isActive: true,
createdAt: true,
},
},
consents: true,
},
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
// Audit-Logs für diesen Kunden
const accessLogs = await getAuditLogsByDataSubject(customerId);
// Sensible Felder entfernen
const exportData = {
exportDate: new Date().toISOString(),
dataSubject: {
id: customer.id,
customerNumber: customer.customerNumber,
name: `${customer.firstName} ${customer.lastName}`,
},
personalData: {
salutation: customer.salutation,
firstName: customer.firstName,
lastName: customer.lastName,
companyName: customer.companyName,
type: customer.type,
birthDate: customer.birthDate,
birthPlace: customer.birthPlace,
email: customer.email,
phone: customer.phone,
mobile: customer.mobile,
taxNumber: customer.taxNumber,
portalEnabled: customer.portalEnabled,
portalEmail: customer.portalEmail,
portalLastLogin: customer.portalLastLogin,
createdAt: customer.createdAt,
updatedAt: customer.updatedAt,
},
addresses: customer.addresses,
bankCards: customer.bankCards,
identityDocuments: customer.identityDocuments,
meters: customer.meters,
contracts: customer.contracts.map((c) => ({
...c,
// Sensible Daten entfernen
portalPasswordEncrypted: undefined,
})),
emails: customer.stressfreiEmails,
consents: customer.consents,
accessHistory: accessLogs.map((log) => ({
timestamp: log.createdAt,
action: log.action,
user: log.userEmail,
resource: log.resourceType,
ipAddress: log.ipAddress,
})),
};
return exportData;
}
/**
* Erstellt eine Löschanfrage
*/
export async function createDeletionRequest(data: CreateDeletionRequestData) {
// Prüfen ob Kunde existiert
const customer = await prisma.customer.findUnique({
where: { id: data.customerId },
});
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
// Prüfen ob bereits eine offene Anfrage existiert
const existingRequest = await prisma.dataDeletionRequest.findFirst({
where: {
customerId: data.customerId,
status: { in: ['PENDING', 'IN_PROGRESS'] },
},
});
if (existingRequest) {
throw new Error('Es existiert bereits eine offene Löschanfrage für diesen Kunden');
}
return prisma.dataDeletionRequest.create({
data: {
customerId: data.customerId,
requestSource: data.requestSource,
requestedBy: data.requestedBy,
status: 'PENDING',
},
});
}
/**
* Holt alle Löschanfragen mit Paginierung
*/
export async function getDeletionRequests(params: {
status?: DeletionRequestStatus;
page?: number;
limit?: number;
}) {
const { status, page = 1, limit = 20 } = params;
const where = status ? { status } : {};
const [requests, total] = await Promise.all([
prisma.dataDeletionRequest.findMany({
where,
orderBy: { requestedAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.dataDeletionRequest.count({ where }),
]);
// Kundendaten hinzufügen
const customerIds = requests.map((r) => r.customerId);
const customers = await prisma.customer.findMany({
where: { id: { in: customerIds } },
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
},
});
const customerMap = new Map(customers.map((c) => [c.id, c]));
const requestsWithCustomer = requests.map((r) => ({
...r,
customer: customerMap.get(r.customerId) || null,
}));
return {
data: requestsWithCustomer,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Holt eine einzelne Löschanfrage
*/
export async function getDeletionRequest(id: number) {
const request = await prisma.dataDeletionRequest.findUnique({
where: { id },
});
if (!request) return null;
const customer = await prisma.customer.findUnique({
where: { id: request.customerId },
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
email: true,
},
});
return { ...request, customer };
}
/**
* Bearbeitet eine Löschanfrage
*/
export async function processDeletionRequest(
requestId: number,
data: ProcessDeletionRequestData
) {
const request = await prisma.dataDeletionRequest.findUnique({
where: { id: requestId },
});
if (!request) {
throw new Error('Löschanfrage nicht gefunden');
}
if (request.status !== 'PENDING' && request.status !== 'IN_PROGRESS') {
throw new Error('Diese Anfrage wurde bereits bearbeitet');
}
// Status auf IN_PROGRESS setzen
await prisma.dataDeletionRequest.update({
where: { id: requestId },
data: { status: 'IN_PROGRESS' },
});
const customerId = request.customerId;
const deletedData: Record<string, number> = {};
const retainedData: Record<string, { count: number; reason: string }> = {};
try {
if (data.action === 'reject') {
// Anfrage ablehnen
return prisma.dataDeletionRequest.update({
where: { id: requestId },
data: {
status: 'REJECTED',
processedAt: new Date(),
processedBy: data.processedBy,
retentionReason: data.retentionReason,
},
});
}
// Einwilligungen widerrufen
await withdrawAllConsents(customerId, data.processedBy);
deletedData['consents'] = 1;
// Verträge prüfen - aktive Verträge müssen behalten werden
const contracts = await prisma.contract.findMany({
where: { customerId },
});
const activeContracts = contracts.filter(
(c) => c.status === 'ACTIVE' || c.status === 'PENDING'
);
if (activeContracts.length > 0) {
retainedData['contracts'] = {
count: activeContracts.length,
reason: 'Aktive Verträge müssen für die Vertragserfüllung aufbewahrt werden',
};
}
// Löschbare Daten anonymisieren (statt hart löschen)
if (data.action === 'complete' && activeContracts.length === 0) {
// Kunde vollständig anonymisieren
await anonymizeCustomer(customerId);
deletedData['customer'] = 1;
deletedData['addresses'] = 1;
deletedData['bankCards'] = 1;
deletedData['identityDocuments'] = 1;
} else {
// Teilweise Löschung - nur optionale Daten
const deletedAddresses = await prisma.address.deleteMany({
where: { customerId, isDefault: false },
});
deletedData['addresses'] = deletedAddresses.count;
// Inaktive Bankkarten löschen
const deletedBankCards = await prisma.bankCard.deleteMany({
where: { customerId, isActive: false },
});
deletedData['bankCards'] = deletedBankCards.count;
// Inaktive Dokumente löschen
const deletedDocs = await prisma.identityDocument.deleteMany({
where: { customerId, isActive: false },
});
deletedData['identityDocuments'] = deletedDocs.count;
}
// Löschnachweis generieren
const proofPath = await generateDeletionProof(requestId, customerId, deletedData, retainedData);
// Anfrage abschließen
const status = Object.keys(retainedData).length > 0 ? 'PARTIALLY_COMPLETED' : 'COMPLETED';
return prisma.dataDeletionRequest.update({
where: { id: requestId },
data: {
status,
processedAt: new Date(),
processedBy: data.processedBy,
deletedData: JSON.stringify(deletedData),
retainedData: JSON.stringify(retainedData),
retentionReason: data.retentionReason,
proofDocument: proofPath,
},
});
} catch (error) {
// Bei Fehler Status zurücksetzen
await prisma.dataDeletionRequest.update({
where: { id: requestId },
data: { status: 'PENDING' },
});
throw error;
}
}
/**
* Anonymisiert Kundendaten (DSGVO-konform)
*/
async function anonymizeCustomer(customerId: number) {
const anonymized = `[GELÖSCHT-${Date.now()}]`;
await prisma.customer.update({
where: { id: customerId },
data: {
firstName: anonymized,
lastName: anonymized,
salutation: null,
companyName: null,
birthDate: null,
birthPlace: null,
email: null,
phone: null,
mobile: null,
taxNumber: null,
notes: null,
portalEnabled: false,
portalEmail: null,
portalPasswordHash: null,
portalPasswordEncrypted: null,
},
});
// Adressen anonymisieren
await prisma.address.updateMany({
where: { customerId },
data: {
street: anonymized,
houseNumber: '',
postalCode: '00000',
city: anonymized,
},
});
// Bankkarten anonymisieren
await prisma.bankCard.updateMany({
where: { customerId },
data: {
accountHolder: anonymized,
iban: 'XX00000000000000000000',
bic: null,
bankName: null,
isActive: false,
},
});
// Ausweisdokumente anonymisieren
await prisma.identityDocument.updateMany({
where: { customerId },
data: {
documentNumber: anonymized,
issuingAuthority: null,
isActive: false,
},
});
}
/**
* Generiert ein Löschnachweis-PDF
*/
async function generateDeletionProof(
requestId: number,
customerId: number,
deletedData: Record<string, number>,
retainedData: Record<string, { count: number; reason: string }>
): Promise<string> {
const uploadsDir = path.join(process.cwd(), 'uploads', 'gdpr');
// Verzeichnis erstellen falls nicht vorhanden
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const filename = `loeschnachweis_${requestId}_${Date.now()}.pdf`;
const filepath = path.join(uploadsDir, filename);
const doc = new PDFDocument({ size: 'A4', margin: 50 });
const writeStream = fs.createWriteStream(filepath);
doc.pipe(writeStream);
// Titel
doc.fontSize(18).text('Datenlöschungsnachweis', { align: 'center' });
doc.moveDown();
// Metadaten
doc.fontSize(12);
doc.text(`Anfrage-ID: ${requestId}`);
doc.text(`Kunden-ID: ${customerId}`);
doc.text(`Datum: ${new Date().toLocaleDateString('de-DE')}`);
doc.text(`Uhrzeit: ${new Date().toLocaleTimeString('de-DE')}`);
doc.moveDown();
// Gelöschte Daten
doc.fontSize(14).text('Gelöschte Daten:', { underline: true });
doc.fontSize(12);
for (const [category, count] of Object.entries(deletedData)) {
doc.text(`${category}: ${count} Einträge`);
}
doc.moveDown();
// Aufbewahrte Daten
if (Object.keys(retainedData).length > 0) {
doc.fontSize(14).text('Aufbewahrte Daten:', { underline: true });
doc.fontSize(12);
for (const [category, info] of Object.entries(retainedData)) {
doc.text(`${category}: ${info.count} Einträge`);
doc.fontSize(10).text(` Grund: ${info.reason}`, { indent: 20 });
doc.fontSize(12);
}
doc.moveDown();
}
// Rechtlicher Hinweis
doc.moveDown();
doc.fontSize(10).text(
'Dieses Dokument bestätigt die Durchführung der Datenlöschung gemäß Art. 17 DSGVO. ' +
'Daten, die aus gesetzlichen Gründen aufbewahrt werden müssen, wurden nicht gelöscht.',
{ align: 'justify' }
);
doc.end();
// Warten bis Datei geschrieben wurde
await new Promise<void>((resolve) => writeStream.on('finish', resolve));
return `gdpr/${filename}`;
}
/**
* Dashboard-Statistiken für DSGVO
*/
export async function getGDPRDashboardStats() {
const [
pendingDeletions,
completedDeletions,
recentExports,
consentStats,
] = await Promise.all([
// Offene Löschanfragen
prisma.dataDeletionRequest.count({
where: { status: { in: ['PENDING', 'IN_PROGRESS'] } },
}),
// Abgeschlossene Löschungen (letzte 30 Tage)
prisma.dataDeletionRequest.count({
where: {
status: { in: ['COMPLETED', 'PARTIALLY_COMPLETED'] },
processedAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
},
}),
// Letzte Datenexporte (aus Audit-Log)
prisma.auditLog.count({
where: {
action: 'EXPORT',
resourceType: 'GDPR',
createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
},
}),
// Consent-Statistik
prisma.customerConsent.groupBy({
by: ['status'],
_count: { id: true },
}),
]);
const consentByStatus = consentStats.reduce(
(acc, s) => {
acc[s.status.toLowerCase()] = s._count.id;
return acc;
},
{ granted: 0, withdrawn: 0, pending: 0 } as Record<string, number>
);
return {
deletionRequests: {
pending: pendingDeletions,
completedLast30Days: completedDeletions,
},
dataExports: {
last30Days: recentExports,
},
consents: consentByStatus,
};
}
+50 -1
View File
@@ -42,11 +42,19 @@ export interface SendEmailResult {
error?: string;
}
// Optionaler Logging-Kontext
export interface EmailLogContext {
context: string; // z.B. "consent-link", "authorization-request", "customer-email"
customerId?: number;
triggeredBy?: string; // User-Email
}
// E-Mail senden
export async function sendEmail(
credentials: SmtpCredentials,
fromAddress: string,
params: SendEmailParams
params: SendEmailParams,
logContext?: EmailLogContext
): Promise<SendEmailResult> {
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
@@ -155,6 +163,27 @@ export async function sendEmail(
// Nicht kritisch - E-Mail wurde trotzdem gesendet
}
// E-Mail-Log erstellen (async, nicht blockierend)
if (logContext) {
import('./emailLog.service.js').then(({ createEmailLog }) => {
createEmailLog({
fromAddress,
toAddress: Array.isArray(params.to) ? params.to.join(', ') : params.to,
subject: params.subject,
context: logContext.context,
customerId: logContext.customerId,
triggeredBy: logContext.triggeredBy,
smtpServer: credentials.host,
smtpPort: credentials.port,
smtpEncryption: credentials.encryption ?? 'SSL',
smtpUser: credentials.user,
success: true,
messageId: result.messageId,
smtpResponse: result.response,
}).catch((err) => console.error('EmailLog write error:', err));
});
}
return {
success: true,
messageId: result.messageId,
@@ -203,6 +232,26 @@ export async function sendEmail(
}
}
// E-Mail-Log erstellen (Fehler)
if (logContext) {
import('./emailLog.service.js').then(({ createEmailLog }) => {
createEmailLog({
fromAddress,
toAddress: Array.isArray(params.to) ? params.to.join(', ') : params.to,
subject: params.subject,
context: logContext.context,
customerId: logContext.customerId,
triggeredBy: logContext.triggeredBy,
smtpServer: credentials.host,
smtpPort: credentials.port,
smtpEncryption: credentials.encryption ?? 'SSL',
smtpUser: credentials.user,
success: false,
errorMessage,
}).catch((err) => console.error('EmailLog write error:', err));
});
}
return {
success: false,
error: errorMessage,
+87 -17
View File
@@ -47,6 +47,9 @@ export async function getAllUsers(filters: UserFilters) {
lastName: true,
isActive: true,
customerId: true,
whatsappNumber: true,
telegramUsername: true,
signalNumber: true,
createdAt: true,
roles: {
include: {
@@ -62,21 +65,25 @@ export async function getAllUsers(filters: UserFilters) {
prisma.user.count({ where }),
]);
// Get Developer role ID
const developerRole = await prisma.role.findFirst({
where: { name: 'Developer' },
});
// Get hidden role IDs
const [developerRole, gdprRole] = await Promise.all([
prisma.role.findFirst({ where: { name: 'Developer' } }),
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
]);
return {
users: users.map((u) => {
// Check if user has developer role assigned
const hasDeveloperAccess = developerRole
? u.roles.some((ur) => ur.roleId === developerRole.id)
: false;
const hasGdprAccess = gdprRole
? u.roles.some((ur) => ur.roleId === gdprRole.id)
: false;
return {
...u,
roles: u.roles.map((r) => r.role),
hasDeveloperAccess,
hasGdprAccess,
};
}),
pagination: buildPaginationResponse(page, limit, total),
@@ -93,6 +100,9 @@ export async function getUserById(id: number) {
lastName: true,
isActive: true,
customerId: true,
whatsappNumber: true,
telegramUsername: true,
signalNumber: true,
createdAt: true,
updatedAt: true,
roles: {
@@ -135,6 +145,10 @@ export async function createUser(data: {
roleIds: number[];
customerId?: number;
hasDeveloperAccess?: boolean;
hasGdprAccess?: boolean;
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
}) {
const hashedPassword = await bcrypt.hash(data.password, 10);
@@ -145,6 +159,9 @@ export async function createUser(data: {
firstName: data.firstName,
lastName: data.lastName,
customerId: data.customerId,
whatsappNumber: data.whatsappNumber || null,
telegramUsername: data.telegramUsername || null,
signalNumber: data.signalNumber || null,
roles: {
create: data.roleIds.map((roleId) => ({ roleId })),
},
@@ -167,6 +184,11 @@ export async function createUser(data: {
await setUserDeveloperAccess(user.id, true);
}
// DSGVO-Zugriff setzen falls aktiviert
if (data.hasGdprAccess) {
await setUserGdprAccess(user.id, true);
}
return user;
}
@@ -181,9 +203,13 @@ export async function updateUser(
roleIds?: number[];
customerId?: number;
hasDeveloperAccess?: boolean;
hasGdprAccess?: boolean;
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
}
) {
const { roleIds, password, hasDeveloperAccess, ...userData } = data;
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, ...userData } = data;
// Check if this would remove the last admin
const isBeingDeactivated = userData.isActive === false;
@@ -311,18 +337,20 @@ export async function updateUser(
}
// Handle developer access
console.log('updateUser - hasDeveloperAccess:', hasDeveloperAccess);
if (hasDeveloperAccess !== undefined) {
await setUserDeveloperAccess(id, hasDeveloperAccess);
}
// Handle GDPR access
if (hasGdprAccess !== undefined) {
await setUserGdprAccess(id, hasGdprAccess);
}
return getUserById(id);
}
// Helper to set developer access for a user
async function setUserDeveloperAccess(userId: number, enabled: boolean) {
console.log('setUserDeveloperAccess called - userId:', userId, 'enabled:', enabled);
// Get or create developer:access permission
let developerPerm = await prisma.permission.findFirst({
where: { resource: 'developer', action: 'access' },
@@ -356,11 +384,7 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
where: { userId, roleId: developerRole.id },
});
console.log('setUserDeveloperAccess - developerRole.id:', developerRole.id, 'hasRole:', hasRole);
if (enabled && !hasRole) {
// Add Developer role
console.log('Adding Developer role');
await prisma.userRole.create({
data: { userId, roleId: developerRole.id },
});
@@ -370,8 +394,6 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
data: { tokenInvalidatedAt: new Date() },
});
} else if (!enabled && hasRole) {
// Remove Developer role
console.log('Removing Developer role');
await prisma.userRole.delete({
where: { userId_roleId: { userId, roleId: developerRole.id } },
});
@@ -380,8 +402,56 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
} else {
console.log('No action needed - enabled:', enabled, 'hasRole:', !!hasRole);
}
}
// Helper to set GDPR access for a user
async function setUserGdprAccess(userId: number, enabled: boolean) {
// Get or create DSGVO role
let gdprRole = await prisma.role.findFirst({
where: { name: 'DSGVO' },
});
if (!gdprRole) {
// Create DSGVO role with all audit:* and gdpr:* permissions
const gdprPermissions = await prisma.permission.findMany({
where: {
OR: [{ resource: 'audit' }, { resource: 'gdpr' }],
},
});
gdprRole = await prisma.role.create({
data: {
name: 'DSGVO',
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
permissions: {
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
},
},
});
}
// Check if user already has DSGVO role
const hasRole = await prisma.userRole.findFirst({
where: { userId, roleId: gdprRole.id },
});
if (enabled && !hasRole) {
await prisma.userRole.create({
data: { userId, roleId: gdprRole.id },
});
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
} else if (!enabled && hasRole) {
await prisma.userRole.delete({
where: { userId_roleId: { userId, roleId: gdprRole.id } },
});
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
}
}