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:
@@ -1,20 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-full py-16 bg-gray-50">
|
<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">
|
<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
|
Mitgliedschaft
|
||||||
</h1>
|
</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) -->
|
<!-- Mitgliedschaftspläne (ohne "Noch Fragen" Box) -->
|
||||||
<div class="mb-12">
|
<div class="mb-4">
|
||||||
<section id="membership" class="py-16 sm:py-20 bg-gradient-to-b from-gray-50 to-white">
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="text-center mb-16">
|
<div class="text-center mb-8">
|
||||||
<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>
|
|
||||||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
<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
|
Werden Sie Teil unserer Tischtennis-Familie - Wählen Sie die passende Mitgliedschaft für sich
|
||||||
</p>
|
</p>
|
||||||
@@ -25,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Satzung Download -->
|
<!-- 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">
|
<div class="text-center mb-8">
|
||||||
<h3 class="text-3xl font-display font-bold text-gray-900 mb-4">
|
<h3 class="text-3xl font-display font-bold text-gray-900 mb-4">
|
||||||
Vereinsatzung
|
Vereinsatzung
|
||||||
@@ -388,19 +384,8 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Buttons -->
|
<!-- Submit Button -->
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center pt-6">
|
<div class="flex 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>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
id="submitBtn"
|
id="submitBtn"
|
||||||
@@ -439,29 +424,8 @@
|
|||||||
// Einfaches JavaScript ohne Vue.js-Reaktivität
|
// Einfaches JavaScript ohne Vue.js-Reaktivität
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const form = document.getElementById('membershipForm')
|
const form = document.getElementById('membershipForm')
|
||||||
const fillDummyBtn = document.getElementById('fillDummyData')
|
|
||||||
const submitBtn = document.getElementById('submitBtn')
|
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
|
// Formular absenden
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
Binary file not shown.
@@ -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==
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
150
server/utils/email-service.js
Normal file
150
server/utils/email-service.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
server/utils/pdf-field-mapper.js
Normal file
135
server/utils/pdf-field-mapper.js
Normal 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'
|
||||||
|
}
|
||||||
228
server/utils/pdf-form-filler.js
Normal file
228
server/utils/pdf-form-filler.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
105
server/utils/pdf-generator-service.js
Normal file
105
server/utils/pdf-generator-service.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user