334c40803f
Live-Pentest gegen Dev-Server mit Portal-Token deckte auf, dass customer.* und gdpr.* Endpoints nur den Data-Sanitizer, aber KEINEN canAccessCustomer-Check hatten. Ein Portal-Kunde mit customers:read konnte per ID-Manipulation komplette Fremddatensätze auslesen. - customer.controller.getCustomer + getAddresses + getBankCards + getDocuments + getMeters + getRepresentatives + getPortalSettings: canAccessCustomer - gdpr.controller.getCustomerConsents + getAuthorizations + checkConsentStatus: canAccessCustomer - createAddress/createBankCard/createDocument/createMeter (customerId aus URL): canAccessCustomer (Defense-in-Depth – wird aktuell schon per Permission geblockt, aber im Controller ungeschützt) - Global Error-Handler: err.status respektieren (PayloadTooLargeError → 413 "Anfrage zu groß", SyntaxError → 400 "Ungültiges JSON" statt pauschal 500) Live-verifiziert: ✓ /api/customers/4 als Portal → 200 VORHER, 403 NACHHER ✓ 9 andere IDOR-Endpoints gleiches Muster ✓ Eigene Daten (/api/customers/1) weiter 200 ✓ 12 MB Body → 413, malformed JSON → 400 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1027 lines
36 KiB
TypeScript
1027 lines
36 KiB
TypeScript
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 { canAccessCustomer } from '../utils/accessControl.js';
|
||
import { createAuditLog, logChange } from '../services/audit.service.js';
|
||
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
||
import prisma from '../lib/prisma.js';
|
||
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';
|
||
|
||
/**
|
||
* 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',
|
||
});
|
||
|
||
await logChange({
|
||
req, action: 'CREATE', resourceType: 'DeletionRequest',
|
||
resourceId: request.id.toString(),
|
||
label: `Löschanfrage erstellt für Kunde #${customerId}`,
|
||
customerId,
|
||
});
|
||
|
||
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' });
|
||
}
|
||
|
||
// Path-Traversal-Schutz: proofDocument aus der DB darf nur unter uploads/ liegen
|
||
const uploadsDir = path.resolve(process.cwd(), 'uploads');
|
||
const filepath = path.resolve(uploadsDir, request.proofDocument);
|
||
if (!filepath.startsWith(uploadsDir + path.sep)) {
|
||
return res.status(400).json({ success: false, error: 'Ungültiger Dateipfad' });
|
||
}
|
||
|
||
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);
|
||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||
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);
|
||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||
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 consentLabels: Record<string, string> = {
|
||
DATA_PROCESSING: 'Datenverarbeitung',
|
||
MARKETING_EMAIL: 'E-Mail-Marketing',
|
||
MARKETING_PHONE: 'Telefonmarketing',
|
||
DATA_SHARING_PARTNER: 'Datenweitergabe',
|
||
};
|
||
|
||
const consent = await consentService.updateConsent(customerId, consentType, {
|
||
status,
|
||
source: source || 'portal',
|
||
documentPath,
|
||
version,
|
||
ipAddress: req.socket.remoteAddress,
|
||
createdBy: req.user?.email || 'unknown',
|
||
});
|
||
|
||
const consentName = consentLabels[consentType] || consentType;
|
||
await logChange({
|
||
req, action: 'UPDATE', resourceType: 'CustomerConsent',
|
||
label: status === 'GRANTED' ? `Einwilligung "${consentName}" erteilt` : `Einwilligung "${consentName}" widerrufen`,
|
||
details: { einwilligung: consentName, status, quelle: source || 'portal' },
|
||
customerId,
|
||
});
|
||
|
||
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);
|
||
|
||
await logChange({
|
||
req, action: 'UPDATE', resourceType: 'PrivacyPolicy',
|
||
label: `Datenschutzerklärung aktualisiert`,
|
||
});
|
||
|
||
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);
|
||
|
||
await logChange({
|
||
req, action: 'UPDATE', resourceType: 'AuthorizationTemplate',
|
||
label: `Vollmacht-Vorlage aktualisiert`,
|
||
});
|
||
|
||
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' });
|
||
}
|
||
}
|
||
|
||
// ==================== IMPRESSUM & WEBSITE-DATENSCHUTZ ====================
|
||
|
||
export async function getImprint(req: AuthRequest, res: Response) {
|
||
try {
|
||
const html = await appSettingService.getSetting('imprintHtml');
|
||
res.json({ success: true, data: { html: html || '' } });
|
||
} catch (error) {
|
||
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
|
||
}
|
||
}
|
||
|
||
export async function updateImprint(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('imprintHtml', html);
|
||
await logChange({ req, action: 'UPDATE', resourceType: 'AppSetting', label: 'Impressum aktualisiert' });
|
||
res.json({ success: true, message: 'Impressum gespeichert' });
|
||
} catch (error) {
|
||
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
|
||
}
|
||
}
|
||
|
||
export async function getWebsitePrivacyPolicy(req: AuthRequest, res: Response) {
|
||
try {
|
||
const html = await appSettingService.getSetting('websitePrivacyPolicyHtml');
|
||
res.json({ success: true, data: { html: html || '' } });
|
||
} catch (error) {
|
||
res.status(500).json({ success: false, error: 'Fehler beim Abrufen' });
|
||
}
|
||
}
|
||
|
||
export async function updateWebsitePrivacyPolicy(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('websitePrivacyPolicyHtml', html);
|
||
await logChange({ req, action: 'UPDATE', resourceType: 'AppSetting', label: 'Website-Datenschutzerklärung aktualisiert' });
|
||
res.json({ success: true, message: 'Website-Datenschutzerklärung gespeichert' });
|
||
} catch (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);
|
||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||
// 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,
|
||
});
|
||
|
||
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
|
||
const repName = rep ? `${rep.firstName} ${rep.lastName}` : `#${representativeId}`;
|
||
await logChange({
|
||
req, action: 'UPDATE', resourceType: 'Authorization',
|
||
resourceId: auth.id.toString(),
|
||
label: `Vollmacht für ${repName} erteilt (Admin)`,
|
||
customerId,
|
||
});
|
||
|
||
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);
|
||
|
||
const rep = await prisma.customer.findUnique({ where: { id: representativeId }, select: { firstName: true, lastName: true } });
|
||
const repName = rep ? `${rep.firstName} ${rep.lastName}` : `#${representativeId}`;
|
||
await logChange({
|
||
req, action: 'UPDATE', resourceType: 'Authorization',
|
||
resourceId: auth.id.toString(),
|
||
label: `Vollmacht für ${repName} widerrufen (Admin)`,
|
||
customerId,
|
||
});
|
||
|
||
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
|
||
);
|
||
|
||
await logChange({
|
||
req, action: 'CREATE', resourceType: 'AuthorizationDocument',
|
||
resourceId: auth.id.toString(),
|
||
label: `Vollmacht-PDF hochgeladen für Vertreter #${representativeId}`,
|
||
customerId,
|
||
});
|
||
|
||
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);
|
||
|
||
await logChange({
|
||
req, action: 'DELETE', resourceType: 'AuthorizationDocument',
|
||
resourceId: auth.id.toString(),
|
||
label: `Vollmacht-PDF gelöscht für Vertreter #${representativeId}`,
|
||
customerId,
|
||
});
|
||
|
||
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;
|
||
|
||
// Vertreter-Name laden
|
||
const representative = await prisma.customer.findUnique({
|
||
where: { id: representativeId },
|
||
select: { firstName: true, lastName: true },
|
||
});
|
||
const repName = representative ? `${representative.firstName} ${representative.lastName}` : `#${representativeId}`;
|
||
|
||
let auth;
|
||
if (grant) {
|
||
auth = await authorizationService.grantAuthorization(user.customerId, representativeId, {
|
||
source: 'portal',
|
||
});
|
||
await logChange({ req, action: 'UPDATE', resourceType: 'RepresentativeAuthorization', label: `Vollmacht für ${repName} erteilt`, details: { status: 'erteilt', vertreter: repName, quelle: 'portal' }, customerId: user.customerId });
|
||
} else {
|
||
auth = await authorizationService.withdrawAuthorization(user.customerId, representativeId);
|
||
await logChange({ req, action: 'UPDATE', resourceType: 'RepresentativeAuthorization', label: `Vollmacht für ${repName} widerrufen`, details: { status: 'widerrufen', vertreter: repName, quelle: 'portal' }, customerId: user.customerId });
|
||
}
|
||
|
||
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' });
|
||
}
|
||
}
|