Files
opencrm/backend/src/services/consent-public.service.ts
T
duffyduck 897abc7b21 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>
2026-05-24 15:30:11 +02:00

290 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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();
});
}