Refactor membership PDF generation logic to improve maintainability and validation; remove deprecated form filling methods and enhance email notification process. Update membership page styles for better layout and user experience.

This commit is contained in:
Torsten Schulz (local)
2025-10-23 15:04:45 +02:00
parent 28a2d05ab5
commit 95ea3a26bc
11 changed files with 862 additions and 800 deletions

View File

@@ -1,20 +1,16 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-2">
Mitgliedschaft
</h1>
<div class="w-24 h-1 bg-primary-600 mb-8"></div>
<div class="w-24 h-1 bg-primary-600 mb-4"></div>
<!-- Mitgliedschaftspläne (ohne "Noch Fragen" Box) -->
<div class="mb-12">
<section id="membership" class="py-16 sm:py-20 bg-gradient-to-b from-gray-50 to-white">
<div class="mb-4">
<section id="membership" class="py-8 sm:py-12 bg-gradient-to-b from-gray-50 to-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
Mitgliedschaft
</h2>
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6"></div>
<div class="text-center mb-8">
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
Werden Sie Teil unserer Tischtennis-Familie - Wählen Sie die passende Mitgliedschaft für sich
</p>
@@ -25,7 +21,7 @@
</div>
<!-- Satzung Download -->
<div class="mt-16 bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<div class="mt-4 bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<div class="text-center mb-8">
<h3 class="text-3xl font-display font-bold text-gray-900 mb-4">
Vereinsatzung
@@ -388,19 +384,8 @@
</ul>
</div>
<!-- Submit Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center pt-6">
<button
type="button"
id="fillDummyData"
class="px-6 py-3 bg-gray-600 hover:bg-gray-700 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
>
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Mit Testdaten füllen
</button>
<!-- Submit Button -->
<div class="flex justify-center pt-6">
<button
type="submit"
id="submitBtn"
@@ -439,29 +424,8 @@
// Einfaches JavaScript ohne Vue.js-Reaktivität
onMounted(() => {
const form = document.getElementById('membershipForm')
const fillDummyBtn = document.getElementById('fillDummyData')
const submitBtn = document.getElementById('submitBtn')
// Testdaten füllen
fillDummyBtn.addEventListener('click', () => {
document.getElementById('nachname').value = 'Mustermann'
document.getElementById('vorname').value = 'Max'
document.getElementById('strasse').value = 'Musterstraße 123'
document.getElementById('plz').value = '60437'
document.getElementById('ort').value = 'Frankfurt am Main'
document.getElementById('geburtsdatum').value = '1990-05-15'
document.getElementById('telefon_privat').value = '069 12345678'
document.getElementById('email').value = 'max.mustermann@example.com'
document.getElementById('telefon_mobil').value = '0171 1234567'
document.getElementById('kontoinhaber').value = 'Max Mustermann'
document.getElementById('iban').value = 'DE89 3704 0044 0532 0130 00'
document.getElementById('bic').value = 'COBADEFFXXX'
document.getElementById('bank').value = 'Commerzbank AG'
document.querySelector('input[name="lastschrift_erlaubt"]').checked = true
document.querySelector('input[name="datenschutz_einverstanden"]').checked = true
document.querySelector('input[name="satzung_anerkannt"]').checked = true
})
// Formular absenden
form.addEventListener('submit', async (e) => {
e.preventDefault()

View File

@@ -1 +0,0 @@
gNCsLBXSvmB3axKuSGH1YPk8nnWkAXzK8gu81Xfc2MvGz4iZ4IBhCtl+geidl/ZkdZC+qYXgb3BnrY4dmRdVb++IJU3TpLdkRthdkVKuTZOwXE/YxqlNApFT+bWUw21V0riC1Clkx0zY4kW33zZHCDk+rpPfjW+fk8jRp9uvKFChu9SlT5DMriO/s3R0IU/fU5YN9DG1FCnZT1LkpH6Lr7FhbqHAo6VFpF6Xo4KkuyS+WMIvdxS/mf3tyx1Th1Wc3kE/ljHeJviRBOXTQlOK4DkJZfir4JAhPdgXzYZan2z9WDCVq5DjzsAGZ1x7FSJHbM62Fg3NWlrnnY+FtTkHaBHRfTa7tVSeCE/re0b03HOQtwzt12gxt9/LwYXiKidFaRpceXP7oJVurOJaW/KnquAJMRs9XaY6EZQ0G3+HrDy6yx4/uj9lnlhIAGAvWEAKrVDLk2HWc6B7ud7lbU3J+a8AYRMz2rvdkdXUs0NgPJ7CfyCoQTIcvK9VVL5h3+8o/54L6qQEAV2rmcCqt32/0XXB3+ZlkpXZFvqWBn3hAV7WhKou3R8wdxBZimPZn7gYb0R4IsN1+cYCblz3pzgehss0hIewHRy+nnzE/aw5zNeE/m2ia0YAJFGtt31F9JqmQc2kvtcQWn4MmB1rJYbV5Htw79ihCCU38ZfIWIThjea8Sbx1DzxKJyr7NlAx7XzlUs4m66QUQOd3O7GP2d3L6Q==

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
/**
* Email Service - Handles membership application email notifications
* Clean Code: Single Responsibility Principle
*/
import nodemailer from 'nodemailer'
import fs from 'fs/promises'
import path from 'path'
/**
* Gets the correct data path for config files
* @param {string} filename - Config filename
* @returns {string} Full path to config file
*/
function getDataPath(filename) {
const isProduction = process.env.NODE_ENV === 'production'
if (isProduction) {
return path.join(process.cwd(), '..', 'server', 'data', filename)
} else {
return path.join(process.cwd(), 'server', 'data', filename)
}
}
/**
* Loads configuration from config.json
* @returns {Promise<Object>} Configuration object
*/
async function loadConfig() {
try {
const configPath = getDataPath('config.json')
const configData = await fs.readFile(configPath, 'utf8')
return JSON.parse(configData)
} catch (error) {
console.error('Could not load config.json:', error.message)
return {}
}
}
/**
* Gets email recipients based on membership type and environment
* @param {Object} data - Form data
* @param {Object} config - Configuration
* @returns {Array<string>} Email addresses
*/
function getEmailRecipients(data, config) {
const isProduction = process.env.NODE_ENV === 'production'
if (!isProduction) {
return ['tsschulz@tsschulz.de']
}
const recipients = []
// Add 1. Vorsitzender
if (config.vorsitzender && config.vorsitzender.email) {
recipients.push(config.vorsitzender.email)
}
// Add Schriftführer
if (config.schriftfuehrer && config.schriftfuehrer.email) {
recipients.push(config.schriftfuehrer.email)
}
// For minors, also add 1. Trainer
if (!data.isVolljaehrig && config.trainer && config.trainer.email) {
recipients.push(config.trainer.email)
}
// Fallback if no recipients found
if (recipients.length === 0) {
recipients.push('tsschulz@tsschulz.de')
}
return recipients
}
/**
* Creates email transporter
* @returns {Object} Nodemailer transporter
*/
function createTransporter() {
return nodemailer.createTransporter({
host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
})
}
/**
* Sends membership application email
* @param {Object} data - Form data
* @param {string} pdfPath - Path to generated PDF
* @returns {Promise<Object>} Email result
*/
export async function sendMembershipEmail(data, pdfPath) {
try {
const config = await loadConfig()
const recipients = getEmailRecipients(data, config)
// Create transporter
const transporter = createTransporter()
// Email content
const subject = `Neuer Mitgliedschaftsantrag - ${data.vorname} ${data.nachname}`
const message = `Ein neuer Mitgliedschaftsantrag wurde eingereicht.
Antragsteller: ${data.vorname} ${data.nachname}
Mitgliedschaftsart: ${data.mitgliedschaftsart}
Volljährig: ${data.isVolljaehrig ? 'Ja' : 'Nein'}
Das ausgefüllte Formular ist als Anhang verfügbar.`
// Email options
const mailOptions = {
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: recipients.join(', '),
subject: subject,
text: message,
attachments: [
{
filename: path.basename(pdfPath),
path: pdfPath
}
]
}
// Send email
const info = await transporter.sendMail(mailOptions)
return {
success: true,
message: message,
recipients: recipients,
messageId: info.messageId
}
} catch (error) {
console.error('Email sending failed:', error.message)
return {
success: false,
message: `E-Mail-Versand fehlgeschlagen: ${error.message}`,
error: error.message
}
}
}

View File

@@ -0,0 +1,135 @@
/**
* PDF Field Mapper - Maps form data to PDF field names
* Clean Code: Single Responsibility Principle
*/
/**
* Formats a date string to DD.MM.YYYY format with leading zeros
* @param {string} dateString - Date string (YYYY-MM-DD format)
* @returns {string} Formatted date string (DD.MM.YYYY)
*/
function formatDateWithLeadingZeros(dateString) {
if (!dateString) return ''
try {
const date = new Date(dateString)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = date.getFullYear()
return `${day}.${month}.${year}`
} catch (error) {
return dateString
}
}
/**
* Maps form data to PDF field values based on field name patterns
* @param {Object} data - Form data
* @param {string} fieldName - PDF field name
* @returns {string} Mapped value
*/
export function mapFieldValue(data, fieldName) {
const name = fieldName.toLowerCase()
// Specific field mappings
const specificMappings = {
'sepa_mitglied': () => `${data.vorname || ''} ${data.nachname || ''}`.trim(),
'sepa_kontoinhaber': () => data.kontoinhaber || `${data.vorname || ''} ${data.nachname || ''}`.trim(),
'sepa_plz_ort': () => `${data.plz || ''} ${data.ort || ''}`.trim(),
'page3_anschrift': () => `${data.strasse || ''}, ${data.plz || ''} ${data.ort || ''}`.trim(),
'vorname': () => data.vorname || '',
'given': () => data.vorname || '',
'nachname': () => data.nachname || '',
'zuname': () => data.nachname || '',
'strasse': () => data.strasse || '',
'straße': () => data.strasse || '',
'street': () => data.strasse || '',
'plz': () => data.plz || '',
'ort': () => data.ort || '',
'stadt': () => data.ort || '',
'plz_ort': () => `${data.plz || ''} ${data.ort || ''}`.trim(),
'wohnort': () => `${data.plz || ''} ${data.ort || ''}`.trim(),
'geburtsdatum': () => formatDateWithLeadingZeros(data.geburtsdatum),
'geb': () => formatDateWithLeadingZeros(data.geburtsdatum),
'telefon': () => data.telefon_privat || data.telefon_mobil || '',
'tel': () => data.telefon_privat || data.telefon_mobil || '',
'email': () => data.email || '',
'kontoinhaber': () => data.kontoinhaber || '',
'kontoinh': () => data.kontoinhaber || '',
'iban': () => data.iban || '',
'bic': () => data.bic || '',
'bank': () => data.bank || '',
'kreditinstitut': () => data.bank || '',
'mitgliedschaft': () => data.mitgliedschaftsart || '',
'art': () => data.mitgliedschaftsart || ''
}
// Check for specific mappings first
for (const [pattern, mapper] of Object.entries(specificMappings)) {
if (name.includes(pattern)) {
return mapper()
}
}
// Special handling for PLZ/Ort combinations - more comprehensive
if (name.includes('plz') && (name.includes('ort') || name.includes('wohnort') || name.includes('stadt'))) {
return `${data.plz || ''} ${data.ort || ''}`.trim()
}
// Handle fields that might contain PLZ/Ort but don't explicitly mention both
if (name.includes('plz') && !name.includes('vorname') && !name.includes('nachname') && !name.includes('strasse') && !name.includes('str')) {
return `${data.plz || ''} ${data.ort || ''}`.trim()
}
if (name.includes('wohnort') || name.includes('ort') || name.includes('stadt')) {
if (!name.includes('vorname') && !name.includes('nachname') && !name.includes('strasse') && !name.includes('str')) {
return `${data.plz || ''} ${data.ort || ''}`.trim()
}
}
// Generic name mapping (avoid conflicts with specific mappings)
if (name.includes('name') && !name.includes('vorname') && !name.includes('given')) {
return data.nachname || ''
}
// Date fields
if (name.includes('datum')) {
return data.sign_datum || data.sepa_datum || data.page3_datum || formatDateWithLeadingZeros(new Date().toISOString().split('T')[0])
}
return ''
}
/**
* Checks if a field should be checked based on membership type
* @param {string} fieldName - PDF field name
* @param {string} membershipType - Membership type ('aktiv' or 'passiv')
* @returns {boolean} Whether the field should be checked
*/
export function shouldCheckField(fieldName, membershipType) {
const name = fieldName.toLowerCase()
if (name.includes('aktiv') && membershipType === 'aktiv') {
return true
}
if (name.includes('passiv') && membershipType === 'passiv') {
return true
}
if (name.includes('mitglied') && name.includes(membershipType)) {
return true
}
return false
}
/**
* Checks if a field should be checked based on boolean value
* @param {string} value - Value to check
* @returns {boolean} Whether the field should be checked
*/
export function shouldCheckByValue(value) {
const stringValue = String(value).toLowerCase()
return stringValue === 'true' || stringValue === 'ja' || stringValue === 'checked'
}

View File

@@ -0,0 +1,228 @@
/**
* PDF Form Filler - Handles filling PDF form fields
* Clean Code: Single Responsibility Principle
*/
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
import { mapFieldValue, shouldCheckField, shouldCheckByValue } from './pdf-field-mapper.js'
/**
* Safely sets text field value if it's empty
* @param {Object} field - PDF form field
* @param {string} value - Value to set
*/
export function setTextFieldIfEmpty(field, value) {
if (typeof field.setText !== 'function') {
return
}
try {
// Check if field already has content
if (typeof field.getText === 'function') {
const currentValue = field.getText()
if (currentValue && String(currentValue).trim() !== '') {
return // Field already has content, don't overwrite
}
}
} catch (error) {
// Ignore getter errors and proceed to set
}
if (value != null && String(value).trim() !== '') {
field.setText(value)
}
}
/**
* Safely sets checkbox field based on membership type
* @param {Object} field - PDF form field
* @param {string} fieldName - Field name
* @param {Object} data - Form data
*/
export function setCheckboxIfNeeded(field, fieldName, data) {
if (!(typeof field.check === 'function' || typeof field.isChecked === 'function')) {
return
}
const lowerName = fieldName.toLowerCase()
try {
// Handle membership type checkboxes
if (lowerName.includes('aktiv') || lowerName.includes('passiv') || lowerName.includes('mitglied')) {
if (typeof field.isChecked === 'function' && field.isChecked()) {
return // Already checked
}
if (shouldCheckField(lowerName, data.mitgliedschaftsart)) {
field.check && field.check()
}
return
}
// Handle other boolean fields
const mappedValue = mapFieldValue(data, lowerName)
if (shouldCheckByValue(mappedValue)) {
try {
if (!(typeof field.isChecked === 'function' && field.isChecked())) {
field.check && field.check()
}
} catch (error) {
field.check && field.check()
}
}
} catch (error) {
// Ignore errors
}
}
/**
* Fills all form fields in a PDF document
* @param {PDFDocument} pdfDoc - PDF document
* @param {Object} form - PDF form
* @param {Object} data - Form data
*/
export async function fillFormFields(pdfDoc, form, data) {
const fields = form.getFields()
for (const field of fields) {
const fieldName = field.getName()
const lowerName = fieldName.toLowerCase()
// Handle text fields
if (typeof field.setText === 'function') {
const value = mapFieldValue(data, lowerName)
setTextFieldIfEmpty(field, value)
continue
}
// Handle checkbox fields
if (typeof field.check === 'function' || typeof field.isChecked === 'function') {
setCheckboxIfNeeded(field, lowerName, data)
continue
}
}
// Update field appearances
try {
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)
form.updateFieldAppearances(helveticaFont)
} catch (error) {
console.warn('Could not update field appearances:', error.message)
}
}
/**
* Fills PDF form fields with fallback to positional drawing
* @param {PDFDocument} pdfDoc - PDF document
* @param {Object} form - PDF form
* @param {Object} data - Form data
*/
export async function fillPdfForm(pdfDoc, form, data) {
try {
await fillFormFields(pdfDoc, form, data)
// Check if PLZ/Ort field on page 1 is empty and fix it
await fixPLZOrtField(pdfDoc, data)
} catch (error) {
console.warn('Form filling failed, using fallback:', error.message)
await fillFormFieldsPositionally(pdfDoc, data)
}
}
/**
* Fixes the PLZ/Ort field on page 1 if it's empty
* @param {PDFDocument} pdfDoc - PDF document
* @param {Object} data - Form data
*/
async function fixPLZOrtField(pdfDoc, data) {
try {
const pages = pdfDoc.getPages()
const firstPage = pages[0]
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)
// Draw PLZ/Ort at the correct position on page 1
const plzOrtText = `${data.plz || ''} ${data.ort || ''}`.trim()
// Instead of drawing rectangles, let's find and fix the actual form field
const form = pdfDoc.getForm()
const fields = form.getFields()
// Look for PLZ/Ort related fields and fix them
for (const field of fields) {
const fieldName = field.getName().toLowerCase()
if (fieldName.includes('plz') && fieldName.includes('ort')) {
if (typeof field.setText === 'function') {
field.setText(plzOrtText)
}
} else if (fieldName.includes('plz') && !fieldName.includes('vorname') && !fieldName.includes('nachname')) {
if (typeof field.setText === 'function') {
field.setText(plzOrtText)
}
}
}
} catch (error) {
console.warn('Could not fix PLZ/Ort field:', error.message)
}
}
/**
* Fallback: Fill form fields by drawing text at specific positions
* @param {PDFDocument} pdfDoc - PDF document
* @param {Object} data - Form data
*/
async function fillFormFieldsPositionally(pdfDoc, data) {
try {
const pages = pdfDoc.getPages()
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)
// First page coordinates
const firstPage = pages[0]
const coords = {
nachname: { x: 156, y: 160 },
vorname: { x: 470, y: 160 },
strasse: { x: 156, y: 132 },
plz_ort: { x: 470, y: 132 },
geburtsdatum: { x: 156, y: 104 },
telefon: { x: 470, y: 104 },
email: { x: 156, y: 76 },
telefon_mobil: { x: 470, y: 76 }
}
// Fill first page fields
firstPage.drawText(data.nachname || '', { x: coords.nachname.x, y: coords.nachname.y, size: 11, font: helveticaFont })
firstPage.drawText(data.vorname || '', { x: coords.vorname.x, y: coords.vorname.y, size: 11, font: helveticaFont })
firstPage.drawText(data.strasse || '', { x: coords.strasse.x, y: coords.strasse.y, size: 11, font: helveticaFont })
firstPage.drawText(`${data.plz || ''} ${data.ort || ''}`.trim(), { x: coords.plz_ort.x, y: coords.plz_ort.y, size: 11, font: helveticaFont })
firstPage.drawText(new Date(data.geburtsdatum).toLocaleDateString('de-DE') || '', { x: coords.geburtsdatum.x, y: coords.geburtsdatum.y, size: 11, font: helveticaFont })
firstPage.drawText(data.telefon_privat || data.telefon_mobil || '', { x: coords.telefon.x, y: coords.telefon.y, size: 11, font: helveticaFont })
firstPage.drawText(data.email || '', { x: coords.email.x, y: coords.email.y, size: 11, font: helveticaFont })
firstPage.drawText(data.telefon_mobil || '', { x: coords.telefon_mobil.x, y: coords.telefon_mobil.y, size: 11, font: helveticaFont })
// Bank data on second page
const secondPage = pages[1]
if (secondPage) {
const bankCoords = {
kontoinhaber: { x: 156, y: 400 },
iban: { x: 156, y: 370 },
bic: { x: 156, y: 340 },
bank: { x: 156, y: 310 }
}
secondPage.drawText(data.kontoinhaber || '', { x: bankCoords.kontoinhaber.x, y: bankCoords.kontoinhaber.y, size: 11, font: helveticaFont })
secondPage.drawText(data.iban || '', { x: bankCoords.iban.x, y: bankCoords.iban.y, size: 11, font: helveticaFont })
secondPage.drawText(data.bic || '', { x: bankCoords.bic.x, y: bankCoords.bic.y, size: 11, font: helveticaFont })
secondPage.drawText(data.bank || '', { x: bankCoords.bank.x, y: bankCoords.bank.y, size: 11, font: helveticaFont })
}
// Membership checkbox
if (data.mitgliedschaftsart === 'aktiv') {
firstPage.drawText('X', { x: 116, y: 20, size: 12, font: helveticaFont })
} else if (data.mitgliedschaftsart === 'passiv') {
firstPage.drawText('X', { x: 116, y: -8, size: 12, font: helveticaFont })
}
} catch (error) {
console.error('Positional filling failed:', error.message)
}
}

View File

@@ -0,0 +1,105 @@
/**
* PDF Generator Service - Main service for PDF generation
* Clean Code: Facade Pattern, Single Responsibility
*/
import { PDFDocument } from 'pdf-lib'
import fs from 'fs/promises'
import path from 'path'
import { fillPdfForm } from './pdf-form-filler.js'
/**
* PDF Generation Result
*/
export class PDFGenerationResult {
constructor(success, pdfBuffer, filename, error = null) {
this.success = success
this.pdfBuffer = pdfBuffer
this.filename = filename
this.error = error
}
}
/**
* PDF Generator Service
*/
export class PDFGeneratorService {
constructor() {
this.templatePath = path.join(process.cwd(), 'server', 'templates', 'mitgliedschaft-fillable.pdf')
this.fallbackTemplatePath = path.join(process.cwd(), 'server', 'templates', 'Aufnahmeantrag 2025.pdf')
}
/**
* Generates PDF from template with form data
* @param {Object} data - Form data
* @returns {Promise<PDFGenerationResult>} Generation result
*/
async generateFromTemplate(data) {
try {
const templatePath = await this.getTemplatePath()
const templateBytes = await fs.readFile(templatePath)
const pdfDoc = await PDFDocument.load(templateBytes)
const form = pdfDoc.getForm()
// Fill form fields
await fillPdfForm(pdfDoc, form, data)
// Don't flatten form to keep fields editable
// form.flatten() makes fields non-editable
// Generate filename
const filename = this.generateFilename(data)
// Save PDF
const pdfBytes = await pdfDoc.save()
return new PDFGenerationResult(true, Buffer.from(pdfBytes), filename)
} catch (error) {
console.error('Template PDF generation failed:', error.message)
return new PDFGenerationResult(false, null, null, error.message)
}
}
/**
* Gets the appropriate template path
* @returns {Promise<string>} Template path
*/
async getTemplatePath() {
try {
await fs.access(this.templatePath)
return this.templatePath
} catch (error) {
try {
await fs.access(this.fallbackTemplatePath)
return this.fallbackTemplatePath
} catch (fallbackError) {
throw new Error('No PDF template found')
}
}
}
/**
* Generates filename for PDF
* @param {Object} data - Form data
* @returns {string} Filename
*/
generateFilename(data) {
const timestamp = Date.now()
const name = `${data.nachname || 'Unbekannt'}_${data.vorname || 'Unbekannt'}`
return `beitrittserklärung_${timestamp}.pdf`
}
/**
* Saves PDF to file system
* @param {Buffer} pdfBuffer - PDF buffer
* @param {string} filename - Filename
* @param {string} uploadDir - Upload directory
* @returns {Promise<string>} File path
*/
async savePDF(pdfBuffer, filename, uploadDir) {
const filePath = path.join(uploadDir, filename)
await fs.writeFile(filePath, pdfBuffer)
return filePath
}
}