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:
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user