368 lines
12 KiB
JavaScript
368 lines
12 KiB
JavaScript
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;
|
|
},
|
|
};
|
|
}
|