897abc7b21
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>
290 lines
8.7 KiB
TypeScript
290 lines
8.7 KiB
TypeScript
import { ConsentType, ConsentStatus } from '@prisma/client';
|
||
import crypto from 'crypto';
|
||
import prisma from '../lib/prisma.js';
|
||
import * as consentService from './consent.service.js';
|
||
import * as appSettingService from './appSetting.service.js';
|
||
import PDFDocument from 'pdfkit';
|
||
|
||
/**
|
||
* Kunden-Lookup per consentHash
|
||
*/
|
||
export async function getCustomerByConsentHash(hash: string) {
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { consentHash: hash },
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
customerNumber: true,
|
||
salutation: true,
|
||
email: true,
|
||
},
|
||
});
|
||
|
||
if (!customer) return null;
|
||
|
||
const consents = await consentService.getCustomerConsents(customer.id);
|
||
|
||
return { customer, consents };
|
||
}
|
||
|
||
/**
|
||
* Alle 4 Einwilligungen über den öffentlichen Link erteilen
|
||
*/
|
||
export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { consentHash: hash },
|
||
select: { id: true, firstName: true, lastName: true },
|
||
});
|
||
|
||
if (!customer) {
|
||
throw new Error('Ungültiger Link');
|
||
}
|
||
|
||
const results = [];
|
||
for (const type of Object.values(ConsentType)) {
|
||
const result = await consentService.updateConsent(customer.id, type, {
|
||
status: ConsentStatus.GRANTED,
|
||
source: 'public-link',
|
||
ipAddress,
|
||
createdBy: `${customer.firstName} ${customer.lastName} (Public-Link)`,
|
||
});
|
||
results.push(result);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* consentHash generieren falls nicht vorhanden
|
||
*/
|
||
export async function ensureConsentHash(customerId: number): Promise<string> {
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: { consentHash: true },
|
||
});
|
||
|
||
if (!customer) {
|
||
throw new Error('Kunde nicht gefunden');
|
||
}
|
||
|
||
if (customer.consentHash) {
|
||
return customer.consentHash;
|
||
}
|
||
|
||
const hash = crypto.randomUUID();
|
||
await prisma.customer.update({
|
||
where: { id: customerId },
|
||
data: { consentHash: hash },
|
||
});
|
||
|
||
return hash;
|
||
}
|
||
|
||
/**
|
||
* Platzhalter in Text ersetzen
|
||
*/
|
||
function replacePlaceholders(html: string, customer: {
|
||
firstName: string;
|
||
lastName: string;
|
||
customerNumber: string;
|
||
salutation?: string | null;
|
||
email?: string | null;
|
||
}): string {
|
||
return html
|
||
.replace(/\{\{vorname\}\}/gi, customer.firstName || '')
|
||
.replace(/\{\{nachname\}\}/gi, customer.lastName || '')
|
||
.replace(/\{\{kundennummer\}\}/gi, customer.customerNumber || '')
|
||
.replace(/\{\{anrede\}\}/gi, customer.salutation || '')
|
||
.replace(/\{\{email\}\}/gi, customer.email || '')
|
||
.replace(/\{\{datum\}\}/gi, new Date().toLocaleDateString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Datenschutzerklärung als HTML abrufen (mit Platzhaltern ersetzt)
|
||
*/
|
||
export async function getPrivacyPolicyHtml(customerId?: number): Promise<string> {
|
||
const html = await appSettingService.getSetting('privacyPolicyHtml');
|
||
|
||
if (!html) {
|
||
return '<p>Keine Datenschutzerklärung hinterlegt.</p>';
|
||
}
|
||
|
||
if (!customerId) return html;
|
||
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: {
|
||
firstName: true,
|
||
lastName: true,
|
||
customerNumber: true,
|
||
salutation: true,
|
||
email: true,
|
||
},
|
||
});
|
||
|
||
if (!customer) return html;
|
||
|
||
return replacePlaceholders(html, customer);
|
||
}
|
||
|
||
/**
|
||
* HTML zu Plain-Text konvertieren (für PDF)
|
||
*/
|
||
function htmlToText(html: string): string {
|
||
return html
|
||
.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, '\n$1\n')
|
||
.replace(/<br\s*\/?>/gi, '\n')
|
||
.replace(/<\/p>/gi, '\n\n')
|
||
.replace(/<li[^>]*>(.*?)<\/li>/gi, ' • $1\n')
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/ /g, ' ')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
}
|
||
|
||
/**
|
||
* Datenschutzerklärung als PDF generieren
|
||
*/
|
||
export async function generateConsentPdf(customerId: number): Promise<Buffer> {
|
||
const html = await getPrivacyPolicyHtml(customerId);
|
||
const text = htmlToText(html);
|
||
|
||
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(1);
|
||
|
||
// Datum
|
||
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,
|
||
});
|
||
|
||
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();
|
||
});
|
||
}
|