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 { 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 { const html = await appSettingService.getSetting('privacyPolicyHtml'); if (!html) { return '

Keine Datenschutzerklärung hinterlegt.

'; } 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]>/gi, '\n$1\n') .replace(//gi, '\n') .replace(/<\/p>/gi, '\n\n') .replace(/]*>(.*?)<\/li>/gi, ' • $1\n') .replace(/<[^>]+>/g, '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /g, ' ') .replace(/\n{3,}/g, '\n\n') .trim(); } /** * Datenschutzerklärung als PDF generieren */ export async function generateConsentPdf(customerId: number): Promise { 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(); }); } /** * Datenschutzerklärung als unterschreibbare PDF (Papierform) generieren. * Zusätzlich zum normalen Text wird unten eine Einwilligungs-Klausel + * ein Unterschriften-Block angefügt (Ort/Datum + Unterschrift + * Name in Druckbuchstaben). Das fertige PDF wird ausgedruckt, vom * Kunden unterschrieben und im Tab "Einwilligungen / Datenschutz" * wieder hochgeladen. */ export async function generateSignablePrivacyPdf(customerId: number): Promise { const html = await getPrivacyPolicyHtml(customerId); const text = htmlToText(html); const customer = await prisma.customer.findUnique({ where: { id: customerId }, select: { firstName: true, lastName: true, customerNumber: true, companyName: true, salutation: true, }, }); const printedName = customer ? (customer.companyName?.trim() ? customer.companyName.trim() : `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim()) : ''; 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(0.5); // Kundenkopf if (printedName) { doc.fontSize(11).font('Helvetica-Bold').text(printedName, { align: 'center' }); } if (customer?.customerNumber) { doc.fontSize(10).font('Helvetica').text(`Kundennummer: ${customer.customerNumber}`, { align: 'center' }); } doc.moveDown(0.5); 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 }); // Genug Platz vor dem Unterschriftenblock – wenn nicht mehr genug // Platz auf der Seite, neue Seite anfangen. if (doc.y > doc.page.height - doc.page.margins.bottom - 220) { doc.addPage(); } else { doc.moveDown(2); } // Einwilligungsklausel doc.fontSize(11).font('Helvetica-Bold').text('Einwilligung', { underline: false }); doc.moveDown(0.3); doc.fontSize(10).font('Helvetica').text( 'Mit meiner Unterschrift bestätige ich, dass ich die vorstehende ' + 'Datenschutzerklärung gelesen und verstanden habe und mit der ' + 'Verarbeitung meiner personenbezogenen Daten zum Zweck der ' + 'Vertragserfüllung einverstanden bin. Diese Einwilligung kann ' + 'jederzeit für die Zukunft widerrufen werden.', { align: 'left', lineGap: 3 }, ); doc.moveDown(1.5); // Unterschriftenblock: links Ort/Datum, rechts Unterschrift const startY = doc.y; const leftX = doc.page.margins.left; const rightX = doc.page.width / 2 + 10; const lineWidth = doc.page.width / 2 - doc.page.margins.left - 10; // Linien const lineY = startY + 35; doc.moveTo(leftX, lineY).lineTo(leftX + lineWidth, lineY).stroke(); doc.moveTo(rightX, lineY).lineTo(rightX + lineWidth, lineY).stroke(); // Labels unter den Linien doc.fontSize(9).font('Helvetica'); doc.text('Ort, Datum', leftX, lineY + 4, { width: lineWidth, align: 'left' }); doc.text('Unterschrift', rightX, lineY + 4, { width: lineWidth, align: 'left' }); // Zweite Zeile: Name in Druckbuchstaben (vorausgefüllt mit Kunde) doc.moveDown(3); const nameY = doc.y; doc.fontSize(11).font('Helvetica'); if (printedName) { doc.text(printedName, rightX, nameY, { width: lineWidth, align: 'left' }); } doc.moveTo(rightX, nameY + 16).lineTo(rightX + lineWidth, nameY + 16).stroke(); doc.fontSize(9).text('Name in Druckbuchstaben', rightX, nameY + 20, { width: lineWidth, align: 'left' }); doc.end(); }); }