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
@@ -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();
});
}