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' });
|
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
|
// Consent-Link senden
|
||||||
router.post('/customer/:customerId/send-consent-link', requirePermission('customers:update'), gdprController.sendConsentLink);
|
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)
|
// Portal: Eigene Datenschutzseite (nur authenticate, Check im Controller)
|
||||||
router.get('/my-privacy', gdprController.getMyPrivacy);
|
router.get('/my-privacy', gdprController.getMyPrivacy);
|
||||||
router.get('/my-privacy/pdf', gdprController.getMyPrivacyPdf);
|
router.get('/my-privacy/pdf', gdprController.getMyPrivacyPdf);
|
||||||
|
|||||||
@@ -185,3 +185,105 @@ export async function generateConsentPdf(customerId: number): Promise<Buffer> {
|
|||||||
doc.end();
|
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">
|
<p className="text-sm text-gray-500 mb-2">
|
||||||
Unterschriebene Datenschutzerklärung als PDF hochladen. Dies gilt als vollständige Einwilligung.
|
Unterschriebene Datenschutzerklärung als PDF hochladen. Dies gilt als vollständige Einwilligung.
|
||||||
</p>
|
</p>
|
||||||
<FileUpload
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
onUpload={handlePrivacyPolicyUpload}
|
<a
|
||||||
accept=".pdf"
|
href={gdprApi.getSignablePrivacyPdfUrl(customerId)}
|
||||||
label="PDF hochladen"
|
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>
|
||||||
)}
|
)}
|
||||||
</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 });
|
const res = await api.post<ApiResponse<{ url: string; channel: string; hash: string }>>(`/gdpr/customer/${customerId}/send-consent-link`, { channel });
|
||||||
return res.data;
|
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
|
// Portal: Eigene Datenschutzseite
|
||||||
getMyPrivacy: async () => {
|
getMyPrivacy: async () => {
|
||||||
const res = await api.get<ApiResponse<{ privacyPolicyHtml: string; consents: CustomerConsent[] }>>('/gdpr/my-privacy');
|
const res = await api.get<ApiResponse<{ privacyPolicyHtml: string; consents: CustomerConsent[] }>>('/gdpr/my-privacy');
|
||||||
|
|||||||
Reference in New Issue
Block a user