Files
opencrm/backend/src/controllers/gdpr.controller.ts
T
duffyduck 334c40803f Security-Hardening Runde 4: 9 Live-IDORs + Error-Handler
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>
2026-04-24 09:59:37 +02:00

1027 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; zustimmen
</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Alternativ können Sie auch diesen Link in Ihren Browser kopieren:<br>
<a href="${consentUrl}" style="color: #2563eb; word-break: break-all;">${consentUrl}</a>
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Hacker-Net Telekommunikation Stefan Hacker<br>
Am Wunderburgpark 5b, 26135 Oldenburg<br>
info@hacker-net.de
</p>
</div>
`;
const result = await sendEmail(credentials, systemEmail.emailAddress, {
to: customer.email,
subject: 'Datenschutzerklärung Bitte um Ihre Zustimmung',
html: emailHtml,
}, {
context: 'consent-link',
customerId,
triggeredBy: req.user?.email,
});
if (!result.success) {
return res.status(400).json({
success: false,
error: `E-Mail-Versand fehlgeschlagen: ${result.error}`,
});
}
}
// Audit-Log
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'READ',
resourceType: 'CustomerConsent',
resourceId: customerId.toString(),
resourceLabel: `Consent-Link gesendet (${channel})`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: customerId,
legalBasis: 'DSGVO Art. 6 Abs. 1a',
});
res.json({
success: true,
data: {
url: consentUrl,
channel,
hash,
},
});
} catch (error) {
console.error('Fehler beim Senden des Consent-Links:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
}
}
// ==================== VOLLMACHTEN ====================
/**
* Vollmacht-Anfrage an Kunden senden (per E-Mail, WhatsApp, Telegram, Signal)
*/
export async function sendAuthorizationRequest(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const representativeId = parseInt(req.params.representativeId);
const { channel } = req.body;
// Kunde (Vollmachtgeber) laden
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { id: true, firstName: true, lastName: true, email: true },
});
// Vertreter (Bevollmächtigter) laden
const representative = await prisma.customer.findUnique({
where: { id: representativeId },
select: { id: true, firstName: true, lastName: true },
});
if (!customer || !representative) {
return res.status(404).json({ success: false, error: 'Kunde oder Vertreter nicht gefunden' });
}
const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173';
const portalUrl = `${baseUrl}/privacy`;
// E-Mail senden
if (channel === 'email') {
if (!customer.email) {
return res.status(400).json({ success: false, error: 'Kunde hat keine E-Mail-Adresse hinterlegt' });
}
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
return res.status(400).json({
success: false,
error: 'Keine System-E-Mail konfiguriert. Bitte in den Email-Provider-Einstellungen eine System-E-Mail-Adresse und Passwort hinterlegen.',
});
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const emailHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Vollmacht Ihre Zustimmung erforderlich</h2>
<p>Sehr geehrte(r) ${customer.firstName} ${customer.lastName},</p>
<p>
<strong>${representative.firstName} ${representative.lastName}</strong> möchte als Ihr Vertreter Zugriff auf Ihre Vertragsdaten erhalten.
</p>
<p>
Damit dies möglich ist, benötigen wir Ihre Vollmacht. Sie können diese bequem über unser Kundenportal erteilen:
</p>
<p style="text-align: center; margin: 32px 0;">
<a href="${portalUrl}"
style="background-color: #2563eb; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; display: inline-block;">
Vollmacht im Portal erteilen
</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Alternativ können Sie auch diesen Link in Ihren Browser kopieren:<br>
<a href="${portalUrl}" style="color: #2563eb; word-break: break-all;">${portalUrl}</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Sie können die Vollmacht jederzeit im Portal unter "Datenschutz" widerrufen.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Hacker-Net Telekommunikation Stefan Hacker<br>
Am Wunderburgpark 5b, 26135 Oldenburg<br>
info@hacker-net.de
</p>
</div>
`;
const result = await sendEmail(credentials, systemEmail.emailAddress, {
to: customer.email,
subject: `Vollmacht für ${representative.firstName} ${representative.lastName} Bitte um Ihre Zustimmung`,
html: emailHtml,
}, {
context: 'authorization-request',
customerId,
triggeredBy: req.user?.email,
});
if (!result.success) {
return res.status(400).json({
success: false,
error: `E-Mail-Versand fehlgeschlagen: ${result.error}`,
});
}
}
// Messaging-Text für WhatsApp/Telegram/Signal
const messageText = `Hallo ${customer.firstName}, ${representative.firstName} ${representative.lastName} möchte als Ihr Vertreter Zugriff auf Ihre Vertragsdaten. Bitte erteilen Sie die Vollmacht im Portal: ${portalUrl}`;
res.json({
success: true,
data: { channel, portalUrl, messageText },
});
} catch (error) {
console.error('Fehler beim Senden der Vollmacht-Anfrage:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
}
}
/**
* Vollmachten für einen Kunden abrufen (Admin-Ansicht)
*/
export async function getAuthorizations(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
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' });
}
}