import { createHash, randomUUID } from 'crypto' import { promises as fs } from 'fs' import { decryptObject, encryptObject } from './encryption.js' import { readMembers, findDuplicateMember, normalizeDate } from './members.js' import { getServerDataPath } from './paths.js' const APPLICATIONS_DIR = getServerDataPath('membership-applications') const LOCKS_DIR = getServerDataPath('membership-application-locks') function encryptionKey() { return process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production' } function normalizeName(value) { return String(value || '') .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9]+/g, ' ') .trim() .toLowerCase() } function identityHash(data) { const identity = [normalizeName(data.vorname), normalizeName(data.nachname), normalizeDate(data.geburtsdatum)].join('|') return createHash('sha256').update(identity).digest('hex') } async function withIdentityLock(hash, operation) { await fs.mkdir(LOCKS_DIR, { recursive: true }) const lockPath = `${LOCKS_DIR}/${hash}.lock` let handle try { handle = await fs.open(lockPath, 'wx') } catch (error) { if (error?.code === 'EEXIST') { throw createError({ statusCode: 409, statusMessage: 'Für diese Person wird bereits ein Mitgliedschaftsantrag bearbeitet.' }) } throw error } try { return await operation() } finally { await handle.close().catch(() => {}) await fs.unlink(lockPath).catch(() => {}) } } async function findMatchingApplication(data) { try { const files = await fs.readdir(APPLICATIONS_DIR) const target = identityHash(data) for (const file of files.filter(file => file.endsWith('.json'))) { try { const application = JSON.parse(await fs.readFile(`${APPLICATIONS_DIR}/${file}`, 'utf8')) if (application.identityHash === target) return application if (application.encryptedData) { const personalData = decryptObject(application.encryptedData, encryptionKey()) if (identityHash(personalData) === target) return application } } catch (error) { console.warn('Mitgliedschaftsantrag konnte bei der Duplikatprüfung nicht gelesen werden:', { file, message: error.message }) } } return null } catch (error) { if (error?.code !== 'ENOENT') throw error return null } } export async function createMembershipApplication(data) { const hash = identityHash(data) if (!normalizeName(data.vorname) || !normalizeName(data.nachname) || !normalizeDate(data.geburtsdatum)) { throw createError({ statusCode: 400, statusMessage: 'Vorname, Nachname und ein gültiges Geburtsdatum sind erforderlich.' }) } return withIdentityLock(hash, async () => { const members = await readMembers() if (findDuplicateMember(members, data.vorname, data.nachname, data.geburtsdatum)) { throw createError({ statusCode: 409, statusMessage: 'Für diese Person besteht bereits eine Mitgliedschaft.' }) } const existing = await findMatchingApplication(data) if (existing) { throw createError({ statusCode: 409, statusMessage: 'Für diese Person liegt bereits ein Mitgliedschaftsantrag vor.' }) } await fs.mkdir(APPLICATIONS_DIR, { recursive: true }) const application = { id: randomUUID(), timestamp: new Date().toISOString(), status: 'pending', identityHash: hash, metadata: { mitgliedschaftsart: data.mitgliedschaftsart }, encryptedData: encryptObject(data, encryptionKey()) } await fs.writeFile(`${APPLICATIONS_DIR}/${application.id}.json`, `${JSON.stringify(application, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }) return application }) } export async function removeMembershipApplication(applicationId) { if (!applicationId) return await fs.unlink(`${APPLICATIONS_DIR}/${applicationId}.json`).catch(error => { if (error?.code !== 'ENOENT') throw error }) }