import { createHash } from "node:crypto"; import { packagesDetails } from "../../shared/packagesdetails.js"; export class BookingError extends Error { constructor(status, code, message, fields = {}) { super(message); this.status = status; this.code = code; this.fields = fields; } } const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; // Übliche unquotierte E-Mail-Adressen; einzelne Domainlabels maximal 63 Zeichen. const emailPattern = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; function validateInput(input, key) { if (!uuidPattern.test(key ?? "")) { throw new BookingError( 400, "INVALID_KEY", "Ein gültiger Idempotency-Key (UUID v4) ist erforderlich.", ); } if (!input || typeof input !== "object" || Array.isArray(input)) { throw new BookingError( 400, "INVALID_INPUT", "Erwartet wird ein JSON-Objekt.", ); } let customer; if (Object.hasOwn(input, "customer")) { if ( !input.customer || typeof input.customer !== "object" || Array.isArray(input.customer) || input.acceptedTerms !== true ) { throw new BookingError( 400, "INVALID_INPUT", "Kundendaten und Zustimmung zu den Bedingungen sind erforderlich.", ); } customer = {}; for (const [field, max, required] of [ ["firstName", 60, true], ["lastName", 60, true], ["email", 254, true], ["phone", 50, false], ["ageGroup", 40, false], ["notes", 2000, false], ]) { const value = input.customer[field]; if (value === undefined && !required) continue; if ( typeof value !== "string" || value.length > max || (required && !value.trim()) || /[\x00-\x1f\x7f]/.test(value) ) { throw new BookingError( 400, "INVALID_INPUT", "Bitte die Kundendaten prüfen.", { [field]: "Ungültige Angabe." }, ); } customer[field] = value.trim(); } input = { ...input, name: `${customer.firstName} ${customer.lastName}`, email: customer.email, }; } const fields = {}; const result = {}; for (const [field, max, message] of [ ["packageId", 80, "Bitte ein gültiges Paket auswählen."], ["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."], [ "email", 254, "Bitte eine gültige E-Mail-Adresse eingeben (maximal 254 Zeichen).", ], ]) { const value = input[field]; if (typeof value !== "string" || !value.trim() || value.length > max) { fields[field] = message; } else { result[field] = value.trim(); } } if ( result.packageId && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result.packageId) ) { fields.packageId = "Die Paket-ID ist ungültig."; } if (result.name && /[\x00-\x1f\x7f]/.test(result.name)) { fields.name = "Bitte den Namen ohne Zeilenumbrüche oder Steuerzeichen eingeben."; } if ( result.email && (!emailPattern.test(result.email) || result.email.split("@")[0].length > 64) ) { fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; } if (!customer && input.phone !== undefined) { if ( typeof input.phone !== "string" || input.phone.length > 50 || /[\x00-\x1f\x7f]/.test(input.phone) ) { fields.phone = "Bitte höchstens 50 Zeichen ohne Zeilenumbrüche eingeben."; } else if (input.phone.trim()) { result.phone = input.phone.trim(); } } if (Object.keys(fields).length) { throw new BookingError( 400, "INVALID_INPUT", "Bitte die markierten Angaben prüfen.", fields, ); } if (input.variantId != null) { if ( typeof input.variantId !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.variantId) || input.variantId.length > 80 ) { throw new BookingError( 400, "INVALID_VARIANT", "Bitte eine gültige Variante auswählen.", ); } result.variantId = input.variantId; } if (customer) { result.customer = customer; result.acceptedTerms = true; } return result; } // Der höchste gespeicherte Wert ist der persistente Zähler. Die bestehende // Warteschlange verhindert, dass parallele Anfragen dieselbe Nummer erhalten. function nextBookingId(records) { let highest = 0n; for (const record of records) { const match = /^TH-([0-9]{6,})$/.exec(record.bookingId); if (match) { const number = BigInt(match[1]); if (number > highest) highest = number; } } return `TH-${String(highest + 1n).padStart(6, "0")}`; } function responseFor(record, replayed) { // Nach Prozessabbruch während SMTP bleibt die tatsächliche Annahme unklar. const emailStatus = record.emailStatus === "sending" ? "unknown" : record.emailStatus; return { status: emailStatus === "accepted" ? (replayed ? 200 : 201) : 202, body: { bookingId: record.bookingId, createdAt: record.createdAt, packageId: record.packageId, packageName: record.packageName, bookedPackage: record.bookedPackage || null, customerEmail: record.email, status: "CONFIRMED", saved: true, emailStatus, replayed, }, }; } export function createBookingService({ storage, sendConfirmation, packages, details = packagesDetails, }) { function getPackage(id) { const selected = packages.some((entry) => entry.id === id) && details.find((entry) => entry.id === id); if (!selected) throw new BookingError( 404, "UNKNOWN_PACKAGE", "Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.", ); return structuredClone(selected); } function preview(input) { if ( !input || typeof input !== "object" || Array.isArray(input) || typeof input.packageId !== "string" ) { throw new BookingError( 400, "INVALID_INPUT", "Bitte ein gültiges Paket auswählen.", ); } let selected; try { selected = getPackage(input.packageId); } catch (error) { error.status = 400; throw error; } const variants = selected.variants || []; const variantId = input.variantId ?? variants[0]?.variantId ?? null; const variant = variants.find((entry) => entry.variantId === variantId); if ( (variants.length && !variant) || (!variants.length && variantId !== null && variantId !== selected.id) ) { throw new BookingError( 400, "INVALID_VARIANT", "Diese Variante gehört nicht zum ausgewählten Paket.", ); } const grossCents = Math.round((variant?.price ?? selected.price) * 100); const netCents = Math.round(grossCents / (1 + selected.taxRate / 100)); return { packageId: selected.id, variantId: variant?.variantId ?? null, variantLabel: variant?.label ?? null, type: selected.type, title: selected.title, subtitle: selected.subtitle, summaryForBooking: selected.summaryForBooking, durationWeeks: variant?.durationWeeks ?? selected.durationWeeks, durationLabel: variant ? `${variant.durationWeeks} Wochen Begleitung` : selected.durationLabel, quantity: 1, billingInterval: selected.billingInterval, includedFeatures: selected.includedFeatures, processSteps: selected.processSteps, netPrice: netCents / 100, taxRate: selected.taxRate, taxAmount: (grossCents - netCents) / 100, grossPrice: grossCents / 100, currency: selected.currency, }; } // Lesen, Speichern und Versand laufen innerhalb eines Prozesses nacheinander. let queue = Promise.resolve(); async function processBooking(input, key) { const data = validateInput(input, key); const requestHash = createHash("sha256") .update(JSON.stringify(data)) .digest("hex"); let records; try { records = await storage.readAll(); } catch { throw new BookingError( 503, "STORAGE_UNAVAILABLE", "Der Buchungsstatus kann gerade nicht geprüft werden. Bitte mit derselben Anfrage erneut versuchen.", ); } let record = records.find((entry) => entry.idempotencyKey === key); const replayed = Boolean(record); if (record && record.requestHash !== requestHash) { throw new BookingError( 409, "IDEMPOTENCY_CONFLICT", "Dieser Anfrageschlüssel gehört zu anderen Buchungsdaten. Bitte die ursprünglichen Angaben verwenden.", ); } if (record && record.emailStatus !== "pending") return responseFor(record, true); if (!record) { const selected = packages.find( (entry) => entry.id === data.packageId, ); if (!selected) { throw new BookingError( 400, "UNKNOWN_PACKAGE", "Das ausgewählte Paket ist nicht verfügbar.", { packageId: "Bitte ein verfügbares Paket auswählen." }, ); } const bookedPackage = preview(data); record = { bookingId: nextBookingId(records), createdAt: new Date().toISOString(), packageId: selected.id, packageName: selected.name, bookedPackage, customer: data.customer ?? null, acceptedTerms: data.acceptedTerms ?? null, name: data.name, email: data.email, phone: data.customer?.phone ?? data.phone ?? "", emailStatus: "pending", idempotencyKey: key, requestHash, }; records.push(record); try { await storage.writeAll(records); } catch { throw new BookingError( 503, "BOOKING_NOT_SAVED", "Die Buchung konnte nicht gespeichert werden. Bitte erneut versuchen.", ); } } // Versandabsicht zuerst persistieren. Nach einem Absturz niemals blind // noch einmal senden: SMTP und CSV bilden keine gemeinsame Transaktion. record.emailStatus = "sending"; try { await storage.writeAll(records); } catch { record.emailStatus = "pending"; return responseFor(record, replayed); } try { await sendConfirmation(record); record.emailStatus = "accepted"; } catch (error) { record.emailStatus = error.deliveryUnknown ? "unknown" : "failed"; } try { await storage.writeAll(records); } catch { record.emailStatus = "unknown"; } return responseFor(record, replayed); } return { getPackage, preview, book(input, key) { const task = queue.then(() => processBooking(input, key)); queue = task.catch(() => {}); return task; }, }; }