Telefonnummer bei Buchung übertragen und in Bestätigungsmail ergänzt

feature/booking-component
Marvin Gemlin 2026-09-28 16:48:35 +00:00
parent 9bfa8b78b0
commit 6f2b239e57
6 changed files with 32 additions and 11 deletions

View File

@ -68,7 +68,9 @@ Der vollständige Vertrag steht in `src/server/swagger.json`.
POST-Anfragen benötigen `Content-Type: application/json`. Beim Buchen ist POST-Anfragen benötigen `Content-Type: application/json`. Beim Buchen ist
zusätzlich ein `Idempotency-Key` als UUID v4 erforderlich. Das Formular sendet zusätzlich ein `Idempotency-Key` als UUID v4 erforderlich. Das Formular sendet
`packageId`, gegebenenfalls `variantId`, `name` und `email`. `packageId`, gegebenenfalls `variantId`, `name`, `email` und optional `phone`.
Die Telefonnummer darf höchstens 50 Zeichen und keine Steuerzeichen enthalten.
Sie wird in der CSV gespeichert und in der Bestätigungsmail ausgegeben.
Alternativ akzeptiert die API das Swagger-Objekt `customer` mit `firstName`, Alternativ akzeptiert die API das Swagger-Objekt `customer` mit `firstName`,
`lastName`, `email` sowie optional `phone`, `ageGroup` und `notes`. Dabei muss `lastName`, `email` sowie optional `phone`, `ageGroup` und `notes`. Dabei muss
@ -93,6 +95,7 @@ erhalten bleiben; ältere UUID-Buchungsnummern werden weiterhin unterstützt.
`bookedPackage` speichert den Bestellstand als JSON, `customer` die optionalen `bookedPackage` speichert den Bestellstand als JSON, `customer` die optionalen
strukturierten Kundendaten und `acceptedTerms` die zugehörige Zustimmung. strukturierten Kundendaten und `acceptedTerms` die zugehörige Zustimmung.
Die optionale Telefonnummer steht zusätzlich in der Spalte `phone`.
CSV-Dateien mit dem früheren Spaltensatz bleiben lesbar und werden beim nächsten CSV-Dateien mit dem früheren Spaltensatz bleiben lesbar und werden beim nächsten
Speichern erweitert. Für alte Buchungen ohne Bestellstand liefert die API Speichern erweitert. Für alte Buchungen ohne Bestellstand liefert die API
`bookedPackage: null`. `bookedPackage: null`.

View File

@ -102,8 +102,7 @@ export function mountBookingPage(
</div> </div>
<div class="booking__field"> <div class="booking__field">
<label for="${instanceId}-booking-phone">Telefonnummer (optional)</label> <label for="${instanceId}-booking-phone">Telefonnummer (optional)</label>
<input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-hint ${instanceId}-booking-phone-error" /> <input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-error" />
<p id="${instanceId}-booking-phone-hint">Die Telefonnummer wird derzeit noch nicht an die Buchungsstelle übermittelt.</p>
<p class="booking__field-error" id="${instanceId}-booking-phone-error"></p> <p class="booking__field-error" id="${instanceId}-booking-phone-error"></p>
</div> </div>
<div class="booking__actions"> <div class="booking__actions">
@ -252,7 +251,7 @@ export function mountBookingPage(
if (stored.order) renderOrder(stored.order); if (stored.order) renderOrder(stored.order);
name.value = stored.payload.name; name.value = stored.payload.name;
email.value = stored.payload.email; email.value = stored.payload.email;
phone.value = stored.phone || ""; phone.value = stored.payload.phone || "";
lockFields(true); lockFields(true);
button.textContent = "Status prüfen / erneut versuchen"; button.textContent = "Status prüfen / erneut versuchen";
if (stored.result) showResult(stored.result, false); if (stored.result) showResult(stored.result, false);
@ -301,8 +300,6 @@ export function mountBookingPage(
const nextAttempt = { const nextAttempt = {
key: crypto.randomUUID(), key: crypto.randomUUID(),
order: selected, order: selected,
// Bis zur Backend-Erweiterung nur für die Wiederherstellung.
phone: phone.value,
payload: { payload: {
packageId: selected.packageId, packageId: selected.packageId,
...(selected.variantId ...(selected.variantId
@ -310,6 +307,7 @@ export function mountBookingPage(
: {}), : {}),
name: name.value, name: name.value,
email: email.value, email: email.value,
...(phone.value ? { phone: phone.value } : {}),
}, },
}; };
try { try {

View File

@ -112,6 +112,18 @@ function validateInput(input, key) {
) { ) {
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; 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) { if (Object.keys(fields).length) {
throw new BookingError( throw new BookingError(
400, 400,
@ -305,6 +317,7 @@ export function createBookingService({
acceptedTerms: data.acceptedTerms ?? null, acceptedTerms: data.acceptedTerms ?? null,
name: data.name, name: data.name,
email: data.email, email: data.email,
phone: data.customer?.phone ?? data.phone ?? "",
emailStatus: "pending", emailStatus: "pending",
idempotencyKey: key, idempotencyKey: key,
requestHash, requestHash,

View File

@ -17,7 +17,8 @@ const legacyColumns = [
]; ];
const orderColumns = [...legacyColumns, "bookedPackage"]; const orderColumns = [...legacyColumns, "bookedPackage"];
export const columns = [...orderColumns, "customer", "acceptedTerms"]; const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
export const columns = [...customerColumns, "phone"];
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms"]; const jsonColumns = ["bookedPackage", "customer", "acceptedTerms"];
// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird // Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
@ -47,6 +48,7 @@ export function createCsvStorage(filePath) {
columns(header) { columns(header) {
if ( if (
header.join(",") !== columns.join(",") && header.join(",") !== columns.join(",") &&
header.join(",") !== customerColumns.join(",") &&
header.join(",") !== legacyColumns.join(",") && header.join(",") !== legacyColumns.join(",") &&
header.join(",") !== orderColumns.join(",") header.join(",") !== orderColumns.join(",")
) { ) {

View File

@ -36,6 +36,7 @@ export function createEmailService(
const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>"; const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>";
return async function sendConfirmation(booking) { return async function sendConfirmation(booking) {
const order = booking.bookedPackage; const order = booking.bookedPackage;
const phone = booking.phone || booking.customer?.phone;
const lines = [ const lines = [
`Hallo ${booking.name},`, `Hallo ${booking.name},`,
"", "",
@ -48,9 +49,7 @@ export function createEmailService(
: []), : []),
`Kunde: ${booking.name}`, `Kunde: ${booking.name}`,
`E-Mail: ${booking.email}`, `E-Mail: ${booking.email}`,
...(booking.customer?.phone ...(phone ? [`Telefon: ${phone}`] : []),
? [`Telefon: ${booking.customer.phone}`]
: []),
...(booking.customer?.notes ...(booking.customer?.notes
? [`Deine Anmerkungen: ${booking.customer.notes}`] ? [`Deine Anmerkungen: ${booking.customer.notes}`]
: []), : []),

View File

@ -550,6 +550,12 @@
"maxLength": 254, "maxLength": 254,
"example": "thomas.mueller@example.de" "example": "thomas.mueller@example.de"
}, },
"phone": {
"type": "string",
"maxLength": 50,
"description": "Optionale Telefonnummer bei Übergabe von name und email, ohne Steuerzeichen. Bei customer gilt customer.phone.",
"example": "+49 170 1234567"
},
"customer": { "customer": {
"type": "object", "type": "object",
"required": ["firstName", "lastName", "email"], "required": ["firstName", "lastName", "email"],
@ -595,7 +601,7 @@
"required": ["name", "email"] "required": ["name", "email"]
} }
], ],
"description": "Kundendaten gemäß customer mit acceptedTerms=true. Bestehende Clients können alternativ name und email übergeben." "description": "Kundendaten gemäß customer mit acceptedTerms=true. Bestehende Clients können alternativ name und email sowie optional phone übergeben."
}, },
"BookingConfirmation": { "BookingConfirmation": {
"type": "object", "type": "object",