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

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
}
}