trihub-ernaehrung-backseatDevs/src/server/services/booking-service.js

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