Datenschutzerklärung als unterschreibbare PDF-Vorlage

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-<kundennummer>.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) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 15:30:11 +02:00
parent 69a52ffe03
commit 897abc7b21
5 changed files with 162 additions and 5 deletions
@@ -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' });
}
}
+3
View File
@@ -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);
@@ -185,3 +185,105 @@ export async function generateConsentPdf(customerId: number): Promise<Buffer> {
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<Buffer> {
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();
});
}
@@ -4122,11 +4122,23 @@ function ConsentTab({
<p className="text-sm text-gray-500 mb-2">
Unterschriebene Datenschutzerklärung als PDF hochladen. Dies gilt als vollständige Einwilligung.
</p>
<FileUpload
onUpload={handlePrivacyPolicyUpload}
accept=".pdf"
label="PDF hochladen"
/>
<div className="flex flex-wrap items-center gap-3">
<a
href={gdprApi.getSignablePrivacyPdfUrl(customerId)}
download
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline"
title="PDF mit personalisiertem Kopf + Unterschriftsfeld zum Ausdrucken"
>
<Download className="w-4 h-4" />
Vorlage zum Unterschreiben
</a>
<span className="text-gray-300">·</span>
<FileUpload
onUpload={handlePrivacyPolicyUpload}
accept=".pdf"
label="Unterschriebene PDF hochladen"
/>
</div>
</div>
)}
</div>
+8
View File
@@ -1812,6 +1812,14 @@ export const gdprApi = {
const res = await api.post<ApiResponse<{ url: string; channel: string; hash: string }>>(`/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/<a download> 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<ApiResponse<{ privacyPolicyHtml: string; consents: CustomerConsent[] }>>('/gdpr/my-privacy');