From 897abc7b2162f5bc090c255e97e180e51ada3cba Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 24 May 2026 15:30:11 +0200 Subject: [PATCH] =?UTF-8?q?Datenschutzerkl=C3=A4rung=20als=20unterschreibb?= =?UTF-8?q?are=20PDF-Vorlage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Endpoint GET /api/gdpr/customer/:customerId/privacy-pdf generiert eine PDF mit: - Titel - Personalisiertem Kopf (Name / Firma + Kundennummer + Datum) - Voller Datenschutzerklärung (HTML → Text) - Einwilligungsklausel - Unterschriftenblock (Ort/Datum links, Unterschrift rechts, zweite Linie "Name in Druckbuchstaben" mit vorausgefuelltem Kundennamen) Auth: customers:read + canAccessCustomer. Filename: "datenschutzerklaerung-.pdf". Im Tab "Einwilligungen / Datenschutz" beim Kunden gibt es jetzt direkt neben dem Upload-Feld den Link "Vorlage zum Unterschreiben" – Ausdrucken, unterschreiben lassen, scannen, wieder hochladen. Verifiziert auf dev: Magic-Bytes %PDF-1.3, %%EOF-Marker am Ende, 2 KB Output, pdftotext zeigt korrekten Aufbau inkl. Unterschrift- Linien. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/controllers/gdpr.controller.ts | 32 ++++++ backend/src/routes/gdpr.routes.ts | 3 + .../src/services/consent-public.service.ts | 102 ++++++++++++++++++ .../src/pages/customers/CustomerDetail.tsx | 22 +++- frontend/src/services/api.ts | 8 ++ 5 files changed, 162 insertions(+), 5 deletions(-) diff --git a/backend/src/controllers/gdpr.controller.ts b/backend/src/controllers/gdpr.controller.ts index 2b474df3..7becc3c9 100644 --- a/backend/src/controllers/gdpr.controller.ts +++ b/backend/src/controllers/gdpr.controller.ts @@ -1114,3 +1114,35 @@ export async function getMyAuthorizationStatus(req: AuthRequest, res: Response) res.status(500).json({ success: false, error: 'Fehler beim Laden' }); } } + +/** + * Unterschreibbare Datenschutzerklärung (Papierform) als PDF generieren. + * Verwendung: Mitarbeiter klickt im Tab "Einwilligungen / Datenschutz" + * auf "Vorlage zum Unterschreiben", PDF kommt mit personalisiertem + * Kopf + Unterschriftsfeld zum Ausdrucken zurück. + * + * GET /api/gdpr/customer/:customerId/privacy-pdf + */ +export async function getSignablePrivacyPdf(req: AuthRequest, res: Response) { + try { + const customerId = parseInt(req.params.customerId, 10); + if (!Number.isFinite(customerId) || customerId < 1) { + return res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' }); + } + if (!(await canAccessCustomer(req, res, customerId))) return; + + const pdf = await consentPublicService.generateSignablePrivacyPdf(customerId); + const customer = await prisma.customer.findUnique({ + where: { id: customerId }, + select: { customerNumber: true }, + }); + const filename = `datenschutzerklaerung-${customer?.customerNumber || customerId}.pdf`; + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(pdf); + } catch (error) { + console.error('Fehler bei Datenschutz-PDF:', error); + res.status(500).json({ success: false, error: 'Fehler beim Generieren der PDF' }); + } +} diff --git a/backend/src/routes/gdpr.routes.ts b/backend/src/routes/gdpr.routes.ts index 6129b94a..31e191c4 100644 --- a/backend/src/routes/gdpr.routes.ts +++ b/backend/src/routes/gdpr.routes.ts @@ -71,6 +71,9 @@ router.put('/website-privacy-policy', requirePermission('gdpr:admin'), gdprContr // Consent-Link senden router.post('/customer/:customerId/send-consent-link', requirePermission('customers:update'), gdprController.sendConsentLink); +// Unterschreibbare Datenschutzerklärung als PDF (Papierform) +router.get('/customer/:customerId/privacy-pdf', requirePermission('customers:read'), gdprController.getSignablePrivacyPdf); + // Portal: Eigene Datenschutzseite (nur authenticate, Check im Controller) router.get('/my-privacy', gdprController.getMyPrivacy); router.get('/my-privacy/pdf', gdprController.getMyPrivacyPdf); diff --git a/backend/src/services/consent-public.service.ts b/backend/src/services/consent-public.service.ts index a462dfbe..2acea292 100644 --- a/backend/src/services/consent-public.service.ts +++ b/backend/src/services/consent-public.service.ts @@ -185,3 +185,105 @@ export async function generateConsentPdf(customerId: number): Promise { 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(); + }); +} diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index 63c1f614..ef3afaa2 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -4122,11 +4122,23 @@ function ConsentTab({

Unterschriebene Datenschutzerklärung als PDF hochladen. Dies gilt als vollständige Einwilligung.

- + )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ae188fad..16f543f9 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1812,6 +1812,14 @@ export const gdprApi = { const res = await api.post>(`/gdpr/customer/${customerId}/send-consent-link`, { channel }); return res.data; }, + // Unterschreibbare Datenschutzerklärung als PDF (Papierform). + // Liefert die URL inkl. Auth-Token, damit window.open/ klappt + // (Browser senden bei plain links keinen Authorization-Header). + getSignablePrivacyPdfUrl: (customerId: number): string => { + const token = getAccessToken(); + const base = `/api/gdpr/customer/${customerId}/privacy-pdf`; + return token ? `${base}?token=${encodeURIComponent(token)}` : base; + }, // Portal: Eigene Datenschutzseite getMyPrivacy: async () => { const res = await api.get>('/gdpr/my-privacy');