diff --git a/angebotsuebersicht.html b/angebotsuebersicht.html index 06d0290..595ef54 100644 --- a/angebotsuebersicht.html +++ b/angebotsuebersicht.html @@ -6,6 +6,7 @@ Ernährungs-Pakete – Tri-Hub + @@ -30,5 +31,7 @@ type="module" src="/src/features/angebote/angebote-entry.js" > + + diff --git a/berater.html b/berater.html index f0f91c7..f8bc4e3 100644 --- a/berater.html +++ b/berater.html @@ -1,31 +1,55 @@ - - - - - - - Beraterprofil · Tri-Hub - - - - - - -
-
-
-
-

PERSÖNLICH FÜR DICH DA

-

Lerne deine Ernährungsberater kennen.

-

Hinter einer guten Ernährungsstrategie stehen Menschen, die zuhören und ihre Erfahrung in deinen Alltag und dein Training einbringen.

-
-
-

Beraterprofile werden geladen …

-
-
-
-
- - - - + + + + + + + Beraterprofil · Tri-Hub + + + + + + + +
+
+
+
+

PERSÖNLICH FÜR DICH DA

+

+ Lerne deine Ernährungsberater kennen. +

+

+ Hinter einer guten Ernährungsstrategie stehen + Menschen, die zuhören und ihre Erfahrung in deinen + Alltag und dein Training einbringen. +

+
+
+

+ Beraterprofile werden geladen … +

+
+
+
+
+ + + + + + diff --git a/booking-confirmation.html b/booking-confirmation.html index 5ad23bf..025df2e 100644 --- a/booking-confirmation.html +++ b/booking-confirmation.html @@ -6,6 +6,7 @@ Buchungsbestätigung – Tri-Hub + @@ -146,9 +147,6 @@ Buchungsbestätigung anzuzeigen. - + + diff --git a/booking.html b/booking.html index 10fd45f..9773233 100644 --- a/booking.html +++ b/booking.html @@ -5,6 +5,7 @@ Paket buchen – Tri-Hub +
@@ -17,5 +18,7 @@ type="module" src="/src/features/booking/booking-entry.js" > + + diff --git a/contact-test.html b/contact-test.html index cd09112..a49daf9 100644 --- a/contact-test.html +++ b/contact-test.html @@ -1,26 +1,29 @@ - - - - - - Berater kontaktieren – Tri-Hub Testseite - - -
-

TRI-HUB · KONTAKT-DEMO

-

Dein direkter Draht zur Beratung

-

- -

- Testseite für das spätere Beraterprofil. Nutze eine - E-Mail-Adresse aus einer vorhandenen Testbuchung. -

-
- - - + + + + + + Berater kontaktieren – Tri-Hub Testseite + + + +
+

TRI-HUB · KONTAKT-DEMO

+

Dein direkter Draht zur Beratung

+

+ +

+ Testseite für das spätere Beraterprofil. Nutze eine + E-Mail-Adresse aus einer vorhandenen Testbuchung. +

+
+ + + + + diff --git a/index.html b/index.html index b658775..0593746 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + div { + position: relative; + z-index: 1; + } + .section { padding: 96px 0; } @@ -375,6 +399,26 @@ font-size: clamp(2.5rem, 5vw, 4.7rem); } + .knowledge-image-frame { + width: 100%; + transform: translateX(-18px); + margin-top: 28px; + padding: 9px; + overflow: hidden; + border: 1px solid rgba(248, 250, 252, 0.18); + border-radius: 22px; + background: linear-gradient(145deg, #273135, #151d22); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.3); + } + + .knowledge-image-frame img { + display: block; + width: 100%; + aspect-ratio: 2.58 / 1; + border-radius: 14px; + object-fit: cover; + } + .knowledge-copy { color: var(--muted); line-height: 1.85; @@ -547,6 +591,11 @@ font-size: 0.78rem; } + .consent a { + font-weight: 700; + text-decoration: underline; + } + .consent input { margin-top: 4px; accent-color: var(--brand); @@ -692,6 +741,7 @@ .hero-card { min-height: 330px; + transform: none; } } @@ -879,6 +929,13 @@

Wissen,
das dich
weiterbringt.

+
+ Ergänzendes Bild zur Ernährungsberatung +
@@ -994,10 +1051,9 @@

- Hinweis: Die Bewertungen in dieser HTML-Demo werden - nicht dauerhaft gespeichert. Für den Live-Betrieb - sollte das Formular an das bestehende Backend oder - CMS von Tri-hub angebunden werden. + Nur Kunden mit einer vorhandenen Buchung können eine + Bewertung abgeben. Deine E-Mail-Adresse wird nicht + öffentlich angezeigt.

@@ -1027,19 +1083,36 @@
- +
- + + +
+ +
+
- + @@ -1100,12 +1177,22 @@ + *Pflichtfelder + @@ -1217,6 +1304,24 @@ *Pflichtfelder
+ +

+ @@ -1232,42 +1337,20 @@
- + + - + diff --git a/paket-details.html b/paket-details.html index e3fabf7..60eb606 100644 --- a/paket-details.html +++ b/paket-details.html @@ -6,6 +6,7 @@ Paket-Details – Tri-Hub Ernährungsberatung + @@ -23,5 +24,7 @@ type="module" src="/src/features/angebote/paket-details.js" > + + diff --git a/public/images/Starter_bild.webp b/public/images/Starter_bild.webp new file mode 100644 index 0000000..df1856c Binary files /dev/null and b/public/images/Starter_bild.webp differ diff --git a/public/images/exttraBild.jpeg b/public/images/exttraBild.jpeg new file mode 100644 index 0000000..d6cda6c Binary files /dev/null and b/public/images/exttraBild.jpeg differ diff --git a/public/images/premium_bild.webp b/public/images/premium_bild.webp new file mode 100644 index 0000000..66f577a Binary files /dev/null and b/public/images/premium_bild.webp differ diff --git a/public/images/standard_bild.webp b/public/images/standard_bild.webp new file mode 100644 index 0000000..229346a Binary files /dev/null and b/public/images/standard_bild.webp differ diff --git a/public/images/startseiter_hintergrund.jpeg b/public/images/startseiter_hintergrund.jpeg new file mode 100644 index 0000000..140522f Binary files /dev/null and b/public/images/startseiter_hintergrund.jpeg differ diff --git a/src/components/footer/footer.html b/src/components/footer/footer.html new file mode 100644 index 0000000..8873ba3 --- /dev/null +++ b/src/components/footer/footer.html @@ -0,0 +1,69 @@ + diff --git a/src/components/footer/footer.js b/src/components/footer/footer.js new file mode 100644 index 0000000..0a9842d --- /dev/null +++ b/src/components/footer/footer.js @@ -0,0 +1,15 @@ +import footerHtml from "./footer.html?raw"; + +/** Fügt das Footer-Fragment am Platzhalter ein. */ +export function initFooter(placeholderSelector = "#footer-placeholder") { + const placeholder = document.querySelector(placeholderSelector); + if (!placeholder) { + console.warn("Footer-Platzhalter nicht gefunden:", placeholderSelector); + return; + } + + placeholder.outerHTML = footerHtml; + +} + +document.addEventListener("DOMContentLoaded", () => initFooter()); diff --git a/src/features/angebote/angebotsuebersicht.js b/src/features/angebote/angebotsuebersicht.js index 57133ac..d45886f 100644 --- a/src/features/angebote/angebotsuebersicht.js +++ b/src/features/angebote/angebotsuebersicht.js @@ -15,9 +15,24 @@ export function renderAngebotsuebersicht() { ? `${pkg.highlight}` : ""; - const cardHtml = ` -
+ const packageImage = { + "ernaehrung-starter": { + src: "/images/Starter_bild.webp", + alt: "Triathlon-Einsteiger im Gespräch mit ihrem Coach am See", + }, + "ernaehrung-standard": { + src: "/images/standard_bild.webp", + alt: "Triathlet im Radtrikot bespricht sein Training mit dem Coach", + }, + "ernaehrung-premium": { + src: "/images/premium_bild.webp", + alt: "Triathlon-Athleten planen gemeinsam am Seeufer", + }, + }[pkg.id]; + const cardHtml = ` +
+ ${packageImage.alt}
${pkg.type} ${highlightHtml} @@ -43,7 +58,7 @@ export function renderAngebotsuebersicht() { ${pkg.buttonText} -
+
`; container.innerHTML += cardHtml; diff --git a/src/features/booking/booking-page.js b/src/features/booking/booking-page.js index 2366c50..ab3020a 100644 --- a/src/features/booking/booking-page.js +++ b/src/features/booking/booking-page.js @@ -109,6 +109,8 @@ export function mountBookingPage(

+ +
@@ -192,6 +194,7 @@ export function mountBookingPage( const errors = form.querySelector(".booking__errors"); const progress = form.querySelector(".booking__progress"); const result = content.querySelector(".booking__result"); + const consent = form.elements.consent; const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`; let attempt = null; let busy = false; @@ -201,11 +204,12 @@ export function mountBookingPage( name.readOnly = locked; email.readOnly = locked; phone.readOnly = locked; + consent.disabled = locked; } function showError(message, fields = {}) { errors.textContent = message; errors.hidden = false; - for (const field of [name, email, phone]) { + for (const field of [name, email, phone, consent]) { const message = fields[field.name] || ""; content.querySelector( `#${instanceId}-booking-${field.name}-error`, @@ -216,7 +220,7 @@ export function mountBookingPage( } function clearErrors() { errors.hidden = true; - for (const field of [name, email, phone]) { + for (const field of [name, email, phone, consent]) { field.removeAttribute("aria-invalid"); content.querySelector( `#${instanceId}-booking-${field.name}-error`, @@ -256,6 +260,7 @@ export function mountBookingPage( name.value = stored.payload.name; email.value = stored.payload.email; phone.value = stored.payload.phone || ""; + consent.checked = stored.payload.consent === true; lockFields(true); button.textContent = "Status prüfen / erneut versuchen"; if (stored.result) showResult(stored.result, false); @@ -275,6 +280,9 @@ export function mountBookingPage( clearErrors(); if (!attempt) { const fields = {}; + if (!consent.checked) + fields.consent = + "Bitte die Datenschutzbestimmungen akzeptieren."; name.value = name.value.trim(); email.value = email.value.trim(); if ( @@ -305,6 +313,7 @@ export function mountBookingPage( ...(selected.variantId ? { variantId: selected.variantId } : {}), + consent: consent.checked, name: name.value, email: email.value, ...(phone.value ? { phone: phone.value } : {}), diff --git a/src/features/booking/booking.css b/src/features/booking/booking.css index c8f1ca9..6170bd2 100644 --- a/src/features/booking/booking.css +++ b/src/features/booking/booking.css @@ -190,3 +190,25 @@ .booking__dialog .booking__result:focus { outline-color: #53d5cd; } + +.booking .booking__consent { + display: flex; + align-items: flex-start; + gap: 0.6rem; + line-height: 1.5; +} +.booking__consent input[type="checkbox"] { + flex: 0 0 1em; + width: 1em; + height: 1em; + min-height: 0; + margin: 0.25em 0 0; + padding: 0; +} + +.booking__consent a, +.booking__consent a:visited { + color: #fff; + font-weight: 700; + text-decoration: underline; +} diff --git a/src/features/contact/contact-dialog.js b/src/features/contact/contact-dialog.js index d050fde..e1b9b88 100644 --- a/src/features/contact/contact-dialog.js +++ b/src/features/contact/contact-dialog.js @@ -1,130 +1,133 @@ -import { findAdvisor, advisorDisplayName } from "../../shared/berater.js"; -import { - contactReasons, - contactNotice, - contactBookingUrl, -} from "../../shared/contact.js"; -import "./contact.css"; - -// Auf Profilseiten: openContactDialog({ advisorId: profile.id }). -export function openContactDialog({ - advisorId, - navigate = (url) => window.location.assign(url), -} = {}) { - const advisor = findAdvisor(advisorId); - const returnFocus = document.activeElement; - const dialog = document.createElement("dialog"); - dialog.className = "contact-dialog"; - dialog.setAttribute("aria-labelledby", "contact-title"); - dialog.innerHTML = ` - -

DEIN NÄCHSTER SCHRITT

-

-

- -
- -

Verwende die E-Mail-Adresse, mit der du dein Paket gebucht hast.

-
-
- -
- -

- - `; - const advisorName = advisorDisplayName(advisor); - dialog.querySelector("#contact-title").textContent = advisorName - ? `${advisorName} kontaktieren` - : "Berater nicht gefunden"; - dialog.querySelector(".contact-advisor").textContent = advisor - ? "Sende deine Kontaktanfrage." - : "Die Berater-ID fehlt oder ist unbekannt. Bitte öffne das Fenster über ein Beraterprofil."; - dialog.querySelector(".contact-consent span").textContent = contactNotice; - const form = dialog.querySelector("form"); - const email = form.elements.email; - const reason = form.elements.reason; - for (const [value, label] of Object.entries(contactReasons)) { - reason.add(new Option(label, value)); - } - const status = dialog.querySelector(".contact-status"); - const submit = dialog.querySelector(".contact-submit"); - const close = dialog.querySelector(".contact-close"); - let busy = false; - let sent = false; - function update() { - submit.disabled = busy || sent || !advisor; - close.disabled = busy; - email.disabled = busy || sent; - reason.disabled = busy || sent; - form.elements.simulationAccepted.disabled = busy || sent; - } - async function post(path, input) { - const response = await fetch(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - const result = await response.json(); - if (!response.ok) - throw new Error( - result.error?.message || "Die Anfrage ist fehlgeschlagen.", - ); - return result; - } - email.addEventListener("input", () => { - status.textContent = ""; - }); - form.addEventListener("submit", async (event) => { - event.preventDefault(); - if (submit.disabled || !form.reportValidity()) return; - const input = { - email: email.value, - advisorId, - reason: reason.value, - simulationAccepted: form.elements.simulationAccepted.checked, - }; - busy = true; - update(); - status.textContent = - "E-Mail wird geprüft und simulierte Anfrage gesendet …"; - try { - const result = await post("/api/contact", input); - if (result.redirectUrl !== contactBookingUrl) - throw new Error("Die Weiterleitungsadresse ist ungültig."); - sent = true; - status.textContent = - "Beide Bestätigungen wurden in Mailpit angenommen. Microsoft Bookings wird geöffnet. "; - const link = document.createElement("a"); - link.href = contactBookingUrl; - link.textContent = "Weiter zur Terminwahl"; - status.append(link); - navigate(contactBookingUrl); - } catch (error) { - status.textContent = `${error.message} Bei einem Verbindungsfehler bitte vor erneutem Senden Mailpit prüfen.`; - } finally { - busy = false; - update(); - } - }); - close.addEventListener("click", () => dialog.close()); - dialog.addEventListener("cancel", (event) => { - if (busy) event.preventDefault(); - }); - const previousOverflow = document.body.style.overflow; - dialog.addEventListener( - "close", - () => { - document.body.style.overflow = previousOverflow; - dialog.remove(); - returnFocus?.focus(); - }, - { once: true }, - ); - document.body.append(dialog); - document.body.style.overflow = "hidden"; - update(); - dialog.showModal(); - email.focus(); - return dialog; -} +import { findAdvisor, advisorDisplayName } from "../../shared/berater.js"; +import { + contactReasons, + contactNotice, + contactBookingUrl, +} from "../../shared/contact.js"; +import "./contact.css"; + +// Auf Profilseiten: openContactDialog({ advisorId: profile.id }). +export function openContactDialog({ + advisorId, + navigate = (url) => window.location.assign(url), +} = {}) { + const advisor = findAdvisor(advisorId); + const returnFocus = document.activeElement; + const dialog = document.createElement("dialog"); + dialog.className = "contact-dialog"; + dialog.setAttribute("aria-labelledby", "contact-title"); + dialog.innerHTML = ` + +

DEIN NÄCHSTER SCHRITT

+

+

+
+
+ +

Verwende die E-Mail-Adresse, mit der du dein Paket gebucht hast.

+
+
+ +
+ + +

+ +
`; + const advisorName = advisorDisplayName(advisor); + dialog.querySelector("#contact-title").textContent = advisorName + ? `${advisorName} kontaktieren` + : "Berater nicht gefunden"; + dialog.querySelector(".contact-advisor").textContent = advisor + ? "Sende deine Kontaktanfrage." + : "Die Berater-ID fehlt oder ist unbekannt. Bitte öffne das Fenster über ein Beraterprofil."; + dialog.querySelector(".contact-consent span").textContent = contactNotice; + const form = dialog.querySelector("form"); + const email = form.elements.email; + const reason = form.elements.reason; + for (const [value, label] of Object.entries(contactReasons)) { + reason.add(new Option(label, value)); + } + const status = dialog.querySelector(".contact-status"); + const submit = dialog.querySelector(".contact-submit"); + const close = dialog.querySelector(".contact-close"); + let busy = false; + let sent = false; + function update() { + submit.disabled = busy || sent || !advisor; + close.disabled = busy; + email.disabled = busy || sent; + reason.disabled = busy || sent; + form.elements.consent.disabled = busy || sent; + form.elements.simulationAccepted.disabled = busy || sent; + } + async function post(path, input) { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const result = await response.json(); + if (!response.ok) + throw new Error( + result.error?.message || "Die Anfrage ist fehlgeschlagen.", + ); + return result; + } + email.addEventListener("input", () => { + status.textContent = ""; + }); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submit.disabled || !form.reportValidity()) return; + const input = { + email: email.value, + advisorId, + reason: reason.value, + consent: form.elements.consent.checked, + simulationAccepted: form.elements.simulationAccepted.checked, + }; + busy = true; + update(); + status.textContent = + "E-Mail wird geprüft und simulierte Anfrage gesendet …"; + try { + const result = await post("/api/contact", input); + if (result.redirectUrl !== contactBookingUrl) + throw new Error("Die Weiterleitungsadresse ist ungültig."); + sent = true; + status.textContent = + "Beide Bestätigungen wurden in Mailpit angenommen. Microsoft Bookings wird geöffnet. "; + const link = document.createElement("a"); + link.href = contactBookingUrl; + link.textContent = "Weiter zur Terminwahl"; + status.append(link); + navigate(contactBookingUrl); + } catch (error) { + status.textContent = `${error.message} Bei einem Verbindungsfehler bitte vor erneutem Senden Mailpit prüfen.`; + } finally { + busy = false; + update(); + } + }); + close.addEventListener("click", () => dialog.close()); + dialog.addEventListener("cancel", (event) => { + if (busy) event.preventDefault(); + }); + const previousOverflow = document.body.style.overflow; + dialog.addEventListener( + "close", + () => { + document.body.style.overflow = previousOverflow; + dialog.remove(); + returnFocus?.focus(); + }, + { once: true }, + ); + document.body.append(dialog); + document.body.style.overflow = "hidden"; + update(); + dialog.showModal(); + email.focus(); + return dialog; +} diff --git a/src/features/contact/contact-form.js b/src/features/contact/contact-form.js index 428c548..4c1f98e 100644 --- a/src/features/contact/contact-form.js +++ b/src/features/contact/contact-form.js @@ -1,70 +1,73 @@ -const form = document.querySelector("#contactForm"); -const status = document.querySelector("#contactFormStatus"); -const message = form?.elements.message; -const messageHint = document.querySelector("#contactMessageHint"); - -function updateMessageHint() { - if (!message || !messageHint) return; - const valid = [...message.value.trim()].length >= 10; - messageHint.classList.toggle("is-valid", valid); - messageHint.textContent = valid - ? "Mindestens 10 Zeichen erreicht." - : "Mindestens 10 Zeichen erforderlich."; -} - -function showErrors(fields = {}) { - for (const input of form.elements) { - if (!input.name) continue; - input.removeAttribute("aria-invalid"); - const error = form.querySelector(`[data-error-for="${input.name}"]`); - if (error) error.textContent = fields[input.name] ?? ""; - if (fields[input.name]) input.setAttribute("aria-invalid", "true"); - } -} - -message?.addEventListener("input", updateMessageHint); -updateMessageHint(); - -form?.addEventListener("submit", async (event) => { - event.preventDefault(); - status.textContent = ""; - status.classList.remove("is-error"); - showErrors(); - - if (!form.reportValidity()) { - status.textContent = "Bitte prüfe die markierten Eingaben."; - status.classList.add("is-error"); - return; - } - - const submit = form.querySelector('[type="submit"]'); - submit.disabled = true; - submit.textContent = "Wird gesendet …"; - try { - const response = await fetch("/api/nutrition-contact", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(Object.fromEntries(new FormData(form))), - }); - const result = await response.json(); - if (!response.ok) { - showErrors(result.error?.fields); - throw new Error( - result.error?.message ?? - "Die Nachricht konnte nicht gesendet werden.", - ); - } - form.reset(); - updateMessageHint(); - status.textContent = - "Danke für deine Nachricht. Wir melden uns schnellstmöglich bei dir."; - } catch (error) { - status.textContent = - error.message || - "Der Server ist gerade nicht erreichbar. Bitte versuche es erneut."; - status.classList.add("is-error"); - } finally { - submit.disabled = false; - submit.textContent = "Nachricht senden"; - } -}); +const form = document.querySelector("#contactForm"); +const status = document.querySelector("#contactFormStatus"); +const message = form?.elements.message; +const messageHint = document.querySelector("#contactMessageHint"); + +function updateMessageHint() { + if (!message || !messageHint) return; + const valid = [...message.value.trim()].length >= 10; + messageHint.classList.toggle("is-valid", valid); + messageHint.textContent = valid + ? "Mindestens 10 Zeichen erreicht." + : "Mindestens 10 Zeichen erforderlich."; +} + +function showErrors(fields = {}) { + for (const input of form.elements) { + if (!input.name) continue; + input.removeAttribute("aria-invalid"); + const error = form.querySelector(`[data-error-for="${input.name}"]`); + if (error) error.textContent = fields[input.name] ?? ""; + if (fields[input.name]) input.setAttribute("aria-invalid", "true"); + } +} + +message?.addEventListener("input", updateMessageHint); +updateMessageHint(); + +form?.addEventListener("submit", async (event) => { + event.preventDefault(); + status.textContent = ""; + status.classList.remove("is-error"); + showErrors(); + + if (!form.reportValidity()) { + status.textContent = "Bitte prüfe die markierten Eingaben."; + status.classList.add("is-error"); + return; + } + + const submit = form.querySelector('[type="submit"]'); + submit.disabled = true; + submit.textContent = "Wird gesendet …"; + try { + const response = await fetch("/api/nutrition-contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...Object.fromEntries(new FormData(form)), + consent: form.elements.consent.checked, + }), + }); + const result = await response.json(); + if (!response.ok) { + showErrors(result.error?.fields); + throw new Error( + result.error?.message ?? + "Die Nachricht konnte nicht gesendet werden.", + ); + } + form.reset(); + updateMessageHint(); + status.textContent = + "Danke für deine Nachricht. Wir melden uns schnellstmöglich bei dir."; + } catch (error) { + status.textContent = + error.message || + "Der Server ist gerade nicht erreichbar. Bitte versuche es erneut."; + status.classList.add("is-error"); + } finally { + submit.disabled = false; + submit.textContent = "Nachricht senden"; + } +}); diff --git a/src/features/contact/contact.css b/src/features/contact/contact.css index 74df7e0..0458a73 100644 --- a/src/features/contact/contact.css +++ b/src/features/contact/contact.css @@ -1,150 +1,155 @@ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url("/fonts/dm-sans-latin-400-normal.woff2") format("woff2"); -} -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url("/fonts/dm-sans-latin-500-normal.woff2") format("woff2"); -} -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url("/fonts/dm-sans-latin-700-normal.woff2") format("woff2"); -} -.contact-dialog { - --contact-foreground: oklch(96% 0.008 210); - --contact-accent: oklch(70% 0.12 205); - box-sizing: border-box; - width: min(32rem, calc(100vw - 2rem)); - max-height: calc(100dvh - 2rem); - margin: auto; - padding: 1.75rem; - overflow-y: auto; - border: 1px solid #324449; - border-radius: 1.25rem; - background: #071316; - color: var(--contact-foreground); - font: - 400 16px/1.5 "DM Sans", - sans-serif; - -webkit-font-smoothing: antialiased; - box-shadow: 0 1.5rem 4rem #0006; -} -.contact-dialog::backdrop { - background: rgb(0 0 0 / 65%); -} -.contact-dialog * { - box-sizing: border-box; -} -.contact-dialog h2 { - margin: 8px 32px 8px 0; - font-size: 1.35rem; - line-height: 1.25; -} -.contact-dialog p { - margin: 0; -} -.contact-dialog .contact-eyebrow { - color: var(--contact-accent); - font-size: 12px; - letter-spacing: 0.12em; -} -.contact-advisor, -.contact-help, -.contact-consent { - color: oklch(72% 0.015 214); -} -.contact-form { - margin-top: 32px; - display: grid; - gap: 24px; -} -.contact-form label { - display: block; - margin-bottom: 0.35rem; - font-weight: 600; -} -.contact-form input:not([type="checkbox"]), -.contact-form select { - width: 100%; - min-height: 2.75rem; - padding: 0.65rem 0.8rem; - border: 1px solid #61777d; - border-radius: 0.75rem; - background: #101e22; - color: inherit; - font: inherit; - color-scheme: dark; -} -.contact-dialog button { - min-height: 2.75rem; - padding: 0.65rem 0.8rem; - border: 1px solid transparent; - border-radius: 0.75rem; - font: inherit; - cursor: pointer; -} -.contact-dialog .contact-close { - position: absolute; - top: 0.5rem; - right: 0.5rem; - padding: 0; - width: 2.75rem; - height: 2.75rem; - border: 0; - background: transparent; - color: inherit; - font-size: 28px; -} -.contact-submit { - width: 100%; - background: #53d5cd; - color: oklch(20% 0.03 220); -} -.contact-submit:hover:not(:disabled) { - background: #6de0d9; -} - -.contact-dialog :disabled { - opacity: 0.7; - cursor: not-allowed; -} -.contact-dialog :focus-visible { - outline: 3px solid #53d5cd; - outline-offset: 4px; -} -.contact-dialog .contact-help { - margin-top: 8px; - font-size: 14px; -} -.contact-form .contact-consent { - display: flex; - align-items: flex-start; - gap: 12px; - margin: 0; - font-size: 14px; -} -.contact-consent input { - flex-shrink: 0; - width: 20px; - height: 20px; - margin: 2px 0 0; - accent-color: var(--contact-accent); -} -.contact-status { - overflow-wrap: anywhere; -} -.contact-status:empty { - display: none; -} -.contact-status a { - color: var(--contact-accent); -} +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/fonts/dm-sans-latin-400-normal.woff2") format("woff2"); +} +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("/fonts/dm-sans-latin-500-normal.woff2") format("woff2"); +} +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/fonts/dm-sans-latin-700-normal.woff2") format("woff2"); +} +.contact-dialog { + --contact-foreground: oklch(96% 0.008 210); + --contact-accent: oklch(70% 0.12 205); + box-sizing: border-box; + width: min(32rem, calc(100vw - 2rem)); + max-height: calc(100dvh - 2rem); + margin: auto; + padding: 1.75rem; + overflow-y: auto; + border: 1px solid #324449; + border-radius: 1.25rem; + background: #071316; + color: var(--contact-foreground); + font: + 400 16px/1.5 "DM Sans", + sans-serif; + -webkit-font-smoothing: antialiased; + box-shadow: 0 1.5rem 4rem #0006; +} +.contact-dialog::backdrop { + background: rgb(0 0 0 / 65%); +} +.contact-dialog * { + box-sizing: border-box; +} +.contact-dialog h2 { + margin: 8px 32px 8px 0; + font-size: 1.35rem; + line-height: 1.25; +} +.contact-dialog p { + margin: 0; +} +.contact-dialog .contact-eyebrow { + color: var(--contact-accent); + font-size: 12px; + letter-spacing: 0.12em; +} +.contact-advisor, +.contact-help, +.contact-consent { + color: oklch(72% 0.015 214); +} +.contact-form { + margin-top: 32px; + display: grid; + gap: 24px; +} +.contact-form label { + display: block; + margin-bottom: 0.35rem; + font-weight: 600; +} +.contact-form input:not([type="checkbox"]), +.contact-form select { + width: 100%; + min-height: 2.75rem; + padding: 0.65rem 0.8rem; + border: 1px solid #61777d; + border-radius: 0.75rem; + background: #101e22; + color: inherit; + font: inherit; + color-scheme: dark; +} +.contact-dialog button { + min-height: 2.75rem; + padding: 0.65rem 0.8rem; + border: 1px solid transparent; + border-radius: 0.75rem; + font: inherit; + cursor: pointer; +} +.contact-dialog .contact-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0; + width: 2.75rem; + height: 2.75rem; + border: 0; + background: transparent; + color: inherit; + font-size: 28px; +} +.contact-submit { + width: 100%; + background: #53d5cd; + color: oklch(20% 0.03 220); +} +.contact-submit:hover:not(:disabled) { + background: #6de0d9; +} + +.contact-dialog :disabled { + opacity: 0.7; + cursor: not-allowed; +} +.contact-dialog :focus-visible { + outline: 3px solid #53d5cd; + outline-offset: 4px; +} +.contact-dialog .contact-help { + margin-top: 8px; + font-size: 14px; +} +.contact-form .contact-consent { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0; + font-size: 14px; +} +.contact-consent input { + flex-shrink: 0; + width: 20px; + height: 20px; + margin: 2px 0 0; + accent-color: var(--contact-accent); +} +.contact-status { + overflow-wrap: anywhere; +} +.contact-status:empty { + display: none; +} +.contact-status a { + color: var(--contact-accent); +} + +.contact-consent a { + font-weight: 700; + text-decoration: underline; +} diff --git a/src/features/reviews/review-form.js b/src/features/reviews/review-form.js new file mode 100644 index 0000000..f327948 --- /dev/null +++ b/src/features/reviews/review-form.js @@ -0,0 +1,48 @@ +const form = document.getElementById("reviewForm"); +const status = document.getElementById("formStatus"); +const button = form.querySelector('button[type="submit"]'); + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (button.disabled) return; + const input = { + email: form.elements.email.value.trim(), + title: form.elements.title.value.trim(), + review: form.elements.review.value.trim(), + rating: Number(form.elements.rating.value), + consent: form.elements.consent.checked, + }; + for (const [field, max, label] of [ + ["title", 50, "Titel"], + ["review", 500, "Bewertungstext"], + ]) { + if (input[field].length < 10 || input[field].length > max) { + status.textContent = `${label}: Bitte 10 bis ${max} Zeichen eingeben.`; + form.elements[field].focus(); + return; + } + } + button.disabled = true; + status.textContent = "Buchung wird geprüft und Bewertung gespeichert …"; + try { + const response = await fetch("/api/reviews", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const result = await response.json(); + if (!response.ok) { + status.textContent = + result.error?.message || + "Die Bewertung konnte nicht gespeichert werden."; + return; + } + status.textContent = "Danke! Deine Bewertung wurde gespeichert."; + form.reset(); + } catch { + status.textContent = + "Keine Bestätigung vom Server erhalten. Bitte versuche es später erneut."; + } finally { + button.disabled = false; + } +}); diff --git a/src/server/data/bookings.csv b/src/server/data/bookings.csv index ba00ff8..d285d68 100644 --- a/src/server/data/bookings.csv +++ b/src/server/data/bookings.csv @@ -1,10 +1,10 @@ -bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms -TH-000001,2026-09-25T11:43:43.074Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,kk,kk@web.de,accepted,497662ce-278c-466f-9505-c2728f8973ca,d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9,,, -TH-000002,2026-09-25T19:22:37.801Z,ernaehrung-starter,Für den sicheren Einstieg in die Sporternährung,Marvin,marvin@web.de,accepted,06b9ad79-05c3-4e37-9135-0c3cf1da3f36,7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c,,, -TH-000003,2026-09-25T19:52:53.927Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,M,test@web.de,accepted,9446e525-a714-4912-bb10-1906a75cc9cd,a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",, -TH-000004,2026-09-25T19:54:52.744Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,m,test@web.de,accepted,1817271c-343e-4e27-ac60-9d225897ba28,4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",, -TH-000005,2026-09-25T20:14:51.874Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,Marvin,m@web.de,accepted,678654e5-7db9-49af-97b0-64fe02257e77,89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",, -TH-000006,2026-09-26T08:44:42.313Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,Marvin,m@web.de,accepted,a0a8c426-fc56-4887-a0e5-42172ff9cfb2,b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",, -TH-000007,2026-09-26T08:50:50.112Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,k@web.de,accepted,92e12eff-321d-49e5-a9ad-674a7d61f9a7,14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",, -TH-000008,2026-09-26T09:18:17.493Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,marvin@test.de,accepted,c74460a8-0315-4e0b-9d6b-48c854b0f71b,19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",, -TH-000009,2026-09-26T09:43:16.704Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,marvin,m@email.de,accepted,9c18134f-3a71-428b-8769-fda7d06790c7,eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-24"",""variantLabel"":""24-Wochen-Variante"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":671.43,""taxRate"":19,""taxAmount"":127.57,""grossPrice"":799,""currency"":""EUR""}",, +bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms,consent +TH-000001,2026-09-25T11:43:43.074Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,kk,kk@web.de,accepted,497662ce-278c-466f-9505-c2728f8973ca,d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9,,,,true +TH-000002,2026-09-25T19:22:37.801Z,ernaehrung-starter,Für den sicheren Einstieg in die Sporternährung,Marvin,marvin@web.de,accepted,06b9ad79-05c3-4e37-9135-0c3cf1da3f36,7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c,,,,true +TH-000003,2026-09-25T19:52:53.927Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,M,test@web.de,accepted,9446e525-a714-4912-bb10-1906a75cc9cd,a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true +TH-000004,2026-09-25T19:54:52.744Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,m,test@web.de,accepted,1817271c-343e-4e27-ac60-9d225897ba28,4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true +TH-000005,2026-09-25T20:14:51.874Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,Marvin,m@web.de,accepted,678654e5-7db9-49af-97b0-64fe02257e77,89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true +TH-000006,2026-09-26T08:44:42.313Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,Marvin,m@web.de,accepted,a0a8c426-fc56-4887-a0e5-42172ff9cfb2,b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true +TH-000007,2026-09-26T08:50:50.112Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,k@web.de,accepted,92e12eff-321d-49e5-a9ad-674a7d61f9a7,14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true +TH-000008,2026-09-26T09:18:17.493Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,marvin@test.de,accepted,c74460a8-0315-4e0b-9d6b-48c854b0f71b,19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true +TH-000009,2026-09-26T09:43:16.704Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,marvin,m@email.de,accepted,9c18134f-3a71-428b-8769-fda7d06790c7,eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-24"",""variantLabel"":""24-Wochen-Variante"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":671.43,""taxRate"":19,""taxAmount"":127.57,""grossPrice"":799,""currency"":""EUR""}",,,true diff --git a/src/server/data/contact-requests.csv b/src/server/data/contact-requests.csv index fca8c2e..e18ccbb 100644 --- a/src/server/data/contact-requests.csv +++ b/src/server/data/contact-requests.csv @@ -1,13 +1,13 @@ -createdAt,name,email,subject,message +createdAt,name,email,subject,message,consent 2026-09-29T18:51:35.694Z,Max Muster,test10@web.de,Erstgespräch für eine Ernährungsberatung,"Guten Tag, ich möchte einen termin vereinbaren, können sie sich bei mir melden. Mfg -Max Muster" +Max Muster",true 2026-09-29T19:27:34.639Z,Test Test,test11@web.de,Erstgespräch Ernährung,"Guten Tag, können sie sich bei mir melden. Mfg -Test" +Test",true diff --git a/src/server/index.js b/src/server/index.js index 90348e1..5149311 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1,148 +1,168 @@ -import { createContactService } from "./services/contact-service.js"; -import { createContactEmailService } from "./services/contact-email.js"; -import { handleContact } from "./routes/contact.js"; -import { createServer } from "node:http"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { packages } from "../shared/packages.js"; -import { - BookingError, - createBookingService, -} from "./services/booking-service.js"; -import { createCsvStorage } from "./services/csv-storage.js"; -import { createEmailService } from "./services/email-service.js"; -import { handleBooking, json } from "./routes/bookings.js"; -import { handleContactForm } from "./routes/contact-form.js"; -import { createContactFormService } from "./services/contact-form-service.js"; -import { createContactFormStorage } from "./services/contact-form-storage.js"; -import { createContactFormEmailService } from "./services/contact-form-email.js"; - -export function createApp({ - catalog = packages, - storage = createCsvStorage( - fileURLToPath(new URL("./data/bookings.csv", import.meta.url)), - ), - details, - sendConfirmation, - sendContact, - contactFormStorage = createContactFormStorage( - fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)), - ), - sendContactForm, -} = {}) { - const ids = new Set(); - for (const entry of catalog) { - if ( - !entry || - typeof entry.id !== "string" || - !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) || - entry.id.length > 80 || - ids.has(entry.id) || - typeof entry.name !== "string" || - !entry.name.trim() || - typeof entry.summary !== "string" || - !entry.summary.trim() - ) { - throw new Error( - "Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.", - ); - } - ids.add(entry.id); - } - const service = createBookingService({ - storage, - details, - packages: catalog, - sendConfirmation: sendConfirmation ?? createEmailService(), - }); - const contact = createContactService({ - storage, - sendContact: sendContact ?? createContactEmailService(), - }); - // Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand. - const contactForm = createContactFormService({ - storage: contactFormStorage, - sendContactForm: sendContactForm ?? createContactFormEmailService(), - }); - return createServer(async (request, response) => { - const path = new URL(request.url, "http://localhost").pathname; - // Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt. - if (path === "/api/nutrition-contact") { - return handleContactForm(request, response, contactForm); - } - if (path === "/api/contact" || path === "/api/contact/validate") { - return handleContact( - request, - response, - contact, - path.endsWith("/validate"), - ); - } - if (path === "/api/angebote" && request.method === "GET") { - return json(response, 200, { - packages: catalog.map(({ id, name, summary }) => ({ - id, - name, - summary, - })), - }); - } - if (path === "/api/packages" && request.method === "GET") { - return json( - response, - 200, - catalog.map(({ name, summary, ...entry }) => ({ - ...entry, - title: name, - description: summary, - })), - ); - } - if (path.startsWith("/api/packages/") && request.method === "GET") { - try { - return json( - response, - 200, - service.getPackage(path.slice("/api/packages/".length)), - ); - } catch (error) { - if (!(error instanceof BookingError)) throw error; - return json(response, error.status, { - error: { - code: error.code, - message: error.message, - fields: error.fields, - }, - }); - } - } - if (path === "/api/bookings" || path === "/api/bookings/preview") { - if (request.method !== "POST") { - response.setHeader("Allow", "POST"); - return json(response, 405, { - error: { - code: "METHOD_NOT_ALLOWED", - message: "Bitte POST verwenden.", - }, - }); - } - return handleBooking(request, response, service, { - preview: path.endsWith("/preview"), - }); - } - // Ausschließlich API; keine Auslieferung von CSV oder Serverdateien. - return json(response, 404, { - error: { code: "NOT_FOUND", message: "Adresse nicht gefunden." }, - }); - }); -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - const port = Number(process.env.PORT || 3000); - const server = createApp(); - server.listen(port, process.env.HOST || "127.0.0.1", () => { - console.log(`Buchungs-API auf Port ${port}`); - }); -} +import { createReviewStorage } from "./services/review-storage.js"; +import { handleReview } from "./routes/reviews.js"; +import { createContactService } from "./services/contact-service.js"; +import { createContactEmailService } from "./services/contact-email.js"; +import { handleContact } from "./routes/contact.js"; +import { createServer } from "node:http"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { packages } from "../shared/packages.js"; +import { + BookingError, + createBookingService, +} from "./services/booking-service.js"; +import { createCsvStorage } from "./services/csv-storage.js"; +import { createEmailService } from "./services/email-service.js"; +import { handleBooking, json } from "./routes/bookings.js"; +import { handleContactForm } from "./routes/contact-form.js"; +import { createContactFormService } from "./services/contact-form-service.js"; +import { createContactFormStorage } from "./services/contact-form-storage.js"; +import { createContactFormEmailService } from "./services/contact-form-email.js"; + +export function createApp({ + catalog = packages, + storage = createCsvStorage( + fileURLToPath(new URL("./data/bookings.csv", import.meta.url)), + ), + reviewStorage = createReviewStorage( + fileURLToPath(new URL("./data/reviews.csv", import.meta.url)), + ), + details, + sendConfirmation, + sendContact, + contactFormStorage = createContactFormStorage( + fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)), + ), + sendContactForm, + advisorContactStorage = createContactFormStorage( + fileURLToPath(new URL("./data/advisor-contacts.csv", import.meta.url)), + [ + "createdAt", + "email", + "advisorId", + "reason", + "simulationAccepted", + "consent", + ], + ), +} = {}) { + const ids = new Set(); + for (const entry of catalog) { + if ( + !entry || + typeof entry.id !== "string" || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) || + entry.id.length > 80 || + ids.has(entry.id) || + typeof entry.name !== "string" || + !entry.name.trim() || + typeof entry.summary !== "string" || + !entry.summary.trim() + ) { + throw new Error( + "Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.", + ); + } + ids.add(entry.id); + } + const service = createBookingService({ + storage, + details, + packages: catalog, + sendConfirmation: sendConfirmation ?? createEmailService(), + }); + const contact = createContactService({ + storage, + requestStorage: advisorContactStorage, + sendContact: sendContact ?? createContactEmailService(), + }); + // Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand. + const contactForm = createContactFormService({ + storage: contactFormStorage, + sendContactForm: sendContactForm ?? createContactFormEmailService(), + }); + return createServer(async (request, response) => { + const path = new URL(request.url, "http://localhost").pathname; + if (path === "/api/reviews") { + return handleReview(request, response, storage, reviewStorage); + } + // Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt. + if (path === "/api/nutrition-contact") { + return handleContactForm(request, response, contactForm); + } + if (path === "/api/contact" || path === "/api/contact/validate") { + return handleContact( + request, + response, + contact, + path.endsWith("/validate"), + ); + } + if (path === "/api/angebote" && request.method === "GET") { + return json(response, 200, { + packages: catalog.map(({ id, name, summary }) => ({ + id, + name, + summary, + })), + }); + } + if (path === "/api/packages" && request.method === "GET") { + return json( + response, + 200, + catalog.map(({ name, summary, ...entry }) => ({ + ...entry, + title: name, + description: summary, + })), + ); + } + if (path.startsWith("/api/packages/") && request.method === "GET") { + try { + return json( + response, + 200, + service.getPackage(path.slice("/api/packages/".length)), + ); + } catch (error) { + if (!(error instanceof BookingError)) throw error; + return json(response, error.status, { + error: { + code: error.code, + message: error.message, + fields: error.fields, + }, + }); + } + } + if (path === "/api/bookings" || path === "/api/bookings/preview") { + if (request.method !== "POST") { + response.setHeader("Allow", "POST"); + return json(response, 405, { + error: { + code: "METHOD_NOT_ALLOWED", + message: "Bitte POST verwenden.", + }, + }); + } + return handleBooking(request, response, service, { + preview: path.endsWith("/preview"), + }); + } + // Ausschließlich API; keine Auslieferung von CSV oder Serverdateien. + return json(response, 404, { + error: { code: "NOT_FOUND", message: "Adresse nicht gefunden." }, + }); + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const port = Number(process.env.PORT || 3000); + const server = createApp(); + server.listen(port, process.env.HOST || "127.0.0.1", () => { + console.log(`Buchungs-API auf Port ${port}`); + }); +} diff --git a/src/server/routes/reviews.js b/src/server/routes/reviews.js new file mode 100644 index 0000000..bfd22bc --- /dev/null +++ b/src/server/routes/reviews.js @@ -0,0 +1,96 @@ +import { BookingError, emailPattern } from "../services/booking-service.js"; +import { json, readJson } from "./bookings.js"; + +export async function handleReview(request, response, bookings, reviews) { + if (request.method !== "POST") { + response.setHeader("Allow", "POST"); + return json(response, 405, { + error: { message: "Bitte POST verwenden." }, + }); + } + try { + const input = await readJson(request); + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte das Formular ausfüllen.", + ); + } + const fields = {}; + const email = + typeof input.email === "string" + ? input.email.trim().toLowerCase() + : ""; + if (email.length > 254 || !emailPattern.test(email)) { + fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; + } + const record = { email }; + for (const [field, max, label] of [ + ["title", 50, "Titel"], + ["review", 500, "Bewertungstext"], + ]) { + const value = + typeof input[field] === "string" ? input[field].trim() : ""; + if (value.length < 10 || value.length > max) { + fields[field] = + `${label}: Bitte 10 bis ${max} Zeichen eingeben.`; + } + record[field] = value; + } + if ( + !Number.isInteger(input.rating) || + input.rating < 1 || + input.rating > 5 + ) { + fields.rating = "Bitte 1 bis 5 Sterne auswählen."; + } + if (input.consent !== true) { + fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren."; + } + if (Object.keys(fields).length) { + throw new BookingError( + 400, + "INVALID_INPUT", + Object.values(fields).join(" "), + fields, + ); + } + const customers = await bookings.readAll(); + if ( + !customers.some( + (booking) => booking.email.trim().toLowerCase() === email, + ) + ) { + throw new BookingError( + 403, + "BOOKING_REQUIRED", + "Unter dieser E-Mail-Adresse wurde keine Buchung gefunden. Bitte verwende die E-Mail-Adresse deiner Buchung.", + ); + } + await reviews.append({ + ...record, + rating: input.rating, + consent: true, + createdAt: new Date().toISOString(), + }); + return json(response, 201, { saved: true }); + } catch (error) { + if (error instanceof BookingError) { + return json(response, error.status, { + error: { + code: error.code, + message: error.message, + fields: error.fields, + }, + }); + } + return json(response, 503, { + error: { + code: "REVIEW_NOT_SAVED", + message: + "Die Bewertung konnte nicht gespeichert werden. Bitte versuche es später erneut.", + }, + }); + } +} diff --git a/src/server/services/booking-service.js b/src/server/services/booking-service.js index f8a8ab3..dc70213 100644 --- a/src/server/services/booking-service.js +++ b/src/server/services/booking-service.js @@ -1,367 +1,370 @@ -import { - isValidBookingPhone, - phoneError, -} from "../../shared/phone-validation.js"; -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. -export 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."; - } - const phone = customer ? customer.phone : input.phone; - if (phone !== undefined) { - if (!isValidBookingPhone(phone)) { - fields.phone = phoneError; - } else if (!customer && phone.trim()) { - result.phone = 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; - }, - }; -} +import { + isValidBookingPhone, + phoneError, +} from "../../shared/phone-validation.js"; +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. +export 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 = {}; + if (input.consent !== true) + fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren."; + const result = { consent: true }; + 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."; + } + const phone = customer ? customer.phone : input.phone; + if (phone !== undefined) { + if (!isValidBookingPhone(phone)) { + fields.phone = phoneError; + } else if (!customer && phone.trim()) { + result.phone = 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, + consent: data.consent, + 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; + }, + }; +} diff --git a/src/server/services/contact-form-service.js b/src/server/services/contact-form-service.js index 30f1000..bd614f7 100644 --- a/src/server/services/contact-form-service.js +++ b/src/server/services/contact-form-service.js @@ -1,90 +1,92 @@ -import { BookingError, emailPattern } from "./booking-service.js"; - -// Prüft und normalisiert die vom Browser übermittelten Kontaktangaben. -function normalizeInput(input) { - if (!input || typeof input !== "object" || Array.isArray(input)) { - throw new BookingError( - 400, - "INVALID_INPUT", - "Bitte alle Pflichtfelder prüfen.", - ); - } - - // Legt die zulässige Höchstlänge für jedes Formularfeld fest. - const limits = { name: 120, email: 254, subject: 160, message: 5000 }; - const fields = {}; - const data = {}; - for (const [field, max] of Object.entries(limits)) { - const value = input[field]; - if (typeof value !== "string") { - fields[field] = "Dieses Feld ist erforderlich."; - continue; - } - const trimmed = value.trim(); - if (!trimmed) fields[field] = "Dieses Feld ist erforderlich."; - else if (trimmed.length > max) - fields[field] = `Maximal ${max} Zeichen eingeben.`; - else if ( - (field === "message" - ? /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/ - : /[\x00-\x1f\x7f]/ - ).test(trimmed) - ) - fields[field] = "Bitte entferne ungültige Steuerzeichen."; - else data[field] = trimmed; - } - // Prüft zusätzlich das E-Mail-Format und die Mindestlänge der Nachricht. - if ( - data.email && - (!emailPattern.test(data.email) || data.email.split("@")[0].length > 64) - ) { - fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; - } - if (data.message && [...data.message].length < 10) { - fields.message = - "Deine Nachricht muss mindestens 10 Zeichen enthalten."; - } - // Gibt alle gefundenen Feldfehler gesammelt an die Route zurück. - if (Object.keys(fields).length) { - throw new BookingError( - 400, - "INVALID_INPUT", - "Bitte prüfe deine Eingaben.", - fields, - ); - } - return data; -} - -// Verbindet Validierung, CSV-Speicherung und anschließenden E-Mail-Versand. -export function createContactFormService({ storage, sendContactForm }) { - return { - async submit(input) { - const data = normalizeInput(input); - // Ergänzt einen Zeitstempel, damit die Anfrage zeitlich nachvollziehbar bleibt. - const record = { createdAt: new Date().toISOString(), ...data }; - // Speichert vor dem Versand, damit die Anfrage bei SMTP-Problemen erhalten bleibt. - try { - await storage.append(record); - } catch { - throw new BookingError( - 503, - "CONTACT_NOT_SAVED", - "Deine Nachricht konnte gerade nicht gespeichert werden. Bitte versuche es erneut.", - ); - } - // Sendet die beiden Mails erst nach erfolgreichem Speichern der Anfrage. - try { - await sendContactForm(record); - } catch (error) { - if (error instanceof BookingError) throw error; - throw new BookingError( - 502, - "CONTACT_EMAIL_FAILED", - "Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.", - ); - } - return { saved: true, emailStatus: "accepted" }; - }, - }; -} +import { BookingError, emailPattern } from "./booking-service.js"; + +// Prüft und normalisiert die vom Browser übermittelten Kontaktangaben. +function normalizeInput(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte alle Pflichtfelder prüfen.", + ); + } + + // Legt die zulässige Höchstlänge für jedes Formularfeld fest. + const limits = { name: 120, email: 254, subject: 160, message: 5000 }; + const fields = {}; + const data = { consent: true }; + if (input.consent !== true) + fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren."; + for (const [field, max] of Object.entries(limits)) { + const value = input[field]; + if (typeof value !== "string") { + fields[field] = "Dieses Feld ist erforderlich."; + continue; + } + const trimmed = value.trim(); + if (!trimmed) fields[field] = "Dieses Feld ist erforderlich."; + else if (trimmed.length > max) + fields[field] = `Maximal ${max} Zeichen eingeben.`; + else if ( + (field === "message" + ? /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/ + : /[\x00-\x1f\x7f]/ + ).test(trimmed) + ) + fields[field] = "Bitte entferne ungültige Steuerzeichen."; + else data[field] = trimmed; + } + // Prüft zusätzlich das E-Mail-Format und die Mindestlänge der Nachricht. + if ( + data.email && + (!emailPattern.test(data.email) || data.email.split("@")[0].length > 64) + ) { + fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; + } + if (data.message && [...data.message].length < 10) { + fields.message = + "Deine Nachricht muss mindestens 10 Zeichen enthalten."; + } + // Gibt alle gefundenen Feldfehler gesammelt an die Route zurück. + if (Object.keys(fields).length) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte prüfe deine Eingaben.", + fields, + ); + } + return data; +} + +// Verbindet Validierung, CSV-Speicherung und anschließenden E-Mail-Versand. +export function createContactFormService({ storage, sendContactForm }) { + return { + async submit(input) { + const data = normalizeInput(input); + // Ergänzt einen Zeitstempel, damit die Anfrage zeitlich nachvollziehbar bleibt. + const record = { createdAt: new Date().toISOString(), ...data }; + // Speichert vor dem Versand, damit die Anfrage bei SMTP-Problemen erhalten bleibt. + try { + await storage.append(record); + } catch { + throw new BookingError( + 503, + "CONTACT_NOT_SAVED", + "Deine Nachricht konnte gerade nicht gespeichert werden. Bitte versuche es erneut.", + ); + } + // Sendet die beiden Mails erst nach erfolgreichem Speichern der Anfrage. + try { + await sendContactForm(record); + } catch (error) { + if (error instanceof BookingError) throw error; + throw new BookingError( + 502, + "CONTACT_EMAIL_FAILED", + "Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.", + ); + } + return { saved: true, emailStatus: "accepted" }; + }, + }; +} diff --git a/src/server/services/contact-form-storage.js b/src/server/services/contact-form-storage.js index 6ad09e1..7728462 100644 --- a/src/server/services/contact-form-storage.js +++ b/src/server/services/contact-form-storage.js @@ -1,93 +1,106 @@ -import { mkdir, open, readFile, rename, rm } from "node:fs/promises"; -import { dirname } from "node:path"; -import { randomUUID } from "node:crypto"; -import { parse } from "csv-parse/sync"; -import { stringify } from "csv-stringify/sync"; - -// Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV. -const columns = ["createdAt", "name", "email", "subject", "message"]; - -// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird. -function protect(value) { - const text = String(value ?? ""); - return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text; -} - -// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her. -function restore(value) { - return value.startsWith("'") ? value.slice(1) : value; -} - -// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange. -export function createContactFormStorage(filePath) { - let queue = Promise.resolve(); - - // Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt. - async function readAll() { - let content; - try { - content = await readFile(filePath, "utf8"); - } catch (error) { - if (error.code === "ENOENT") return []; - throw error; - } - if (!content.trim()) throw new Error("Leere Kontaktdatei"); - return parse(content, { - bom: true, - columns(header) { - if (header.join(",") !== columns.join(",")) { - throw new Error("Unbekanntes Kontakt-CSV-Format"); - } - return header; - }, - skip_empty_lines: true, - }).map((row) => - Object.fromEntries( - Object.entries(row).map(([key, value]) => [ - key, - restore(value), - ]), - ), - ); - } - - // Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original. - async function writeAll(records) { - await mkdir(dirname(filePath), { recursive: true }); - const temporary = `${filePath}.${randomUUID()}.tmp`; - const content = stringify( - records.map((row) => - Object.fromEntries( - columns.map((column) => [column, protect(row[column])]), - ), - ), - { header: true, columns, record_delimiter: "\r\n" }, - ); - try { - const handle = await open(temporary, "wx", 0o600); - try { - await handle.writeFile(content, "utf8"); - await handle.sync(); - } finally { - await handle.close(); - } - await rename(temporary, filePath); - } finally { - await rm(temporary, { force: true }); - } - } - - return { - readAll, - // Hängt eine Anfrage nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren. - append(record) { - const operation = queue.then(async () => { - const records = await readAll(); - records.push(record); - await writeAll(records); - }); - queue = operation.catch(() => {}); - return operation; - }, - }; -} +import { mkdir, open, readFile, rename, rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import { parse } from "csv-parse/sync"; +import { stringify } from "csv-stringify/sync"; + +// Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV. +const defaultColumns = [ + "createdAt", + "name", + "email", + "subject", + "message", + "consent", +]; + +// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird. +function protect(value) { + const text = String(value ?? ""); + return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text; +} + +// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her. +function restore(value) { + return value.startsWith("'") ? value.slice(1) : value; +} + +// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange. +export function createContactFormStorage(filePath, columns = defaultColumns) { + let queue = Promise.resolve(); + + // Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt. + async function readAll() { + let content; + try { + content = await readFile(filePath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + if (!content.trim()) throw new Error("Leere Kontaktdatei"); + return parse(content, { + bom: true, + columns(header) { + if ( + header.join(",") !== columns.join(",") && + header.join(",") !== + columns + .filter((column) => column !== "consent") + .join(",") + ) { + throw new Error("Unbekanntes Kontakt-CSV-Format"); + } + return header; + }, + skip_empty_lines: true, + }).map((row) => + Object.fromEntries( + Object.entries(row).map(([key, value]) => [ + key, + key === "consent" ? value === "true" : restore(value), + ]), + ), + ); + } + + // Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original. + async function writeAll(records) { + await mkdir(dirname(filePath), { recursive: true }); + const temporary = `${filePath}.${randomUUID()}.tmp`; + const content = stringify( + records.map((row) => + Object.fromEntries( + columns.map((column) => [column, protect(row[column])]), + ), + ), + { header: true, columns, record_delimiter: "\r\n" }, + ); + try { + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(content, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, filePath); + } finally { + await rm(temporary, { force: true }); + } + } + + return { + readAll, + // Hängt eine Anfrage nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren. + append(record) { + const operation = queue.then(async () => { + const records = await readAll(); + records.push(record); + await writeAll(records); + }); + queue = operation.catch(() => {}); + return operation; + }, + }; +} diff --git a/src/server/services/contact-service.js b/src/server/services/contact-service.js index 6146748..1f4692a 100644 --- a/src/server/services/contact-service.js +++ b/src/server/services/contact-service.js @@ -1,108 +1,131 @@ -import { findAdvisor } from "../../shared/berater.js"; -import { BookingError, emailPattern } from "./booking-service.js"; -import { contactReasons, contactBookingUrl } from "../../shared/contact.js"; - -function advisorDetails(advisorId) { - const advisor = findAdvisor(advisorId); - if (!advisor) { - throw new BookingError( - 400, - "INVALID_ADVISOR", - "Bitte eine gültige Berater-ID übergeben.", - ); - } - if ( - typeof advisor.vorname !== "string" || - !advisor.vorname.trim() || - typeof advisor.email !== "string" || - advisor.email.length > 254 || - !emailPattern.test(advisor.email) || - advisor.email.split("@")[0].length > 64 || - !advisor.email.toLowerCase().endsWith("@tri-hub.de") - ) { - throw new BookingError( - 503, - "ADVISOR_UNAVAILABLE", - "Die Kontaktdaten dieses Beraters sind nicht verfügbar.", - ); - } - return { ...advisor }; -} - -export function createContactService({ storage, sendContact }) { - async function validate(input) { - if ( - !input || - typeof input !== "object" || - Array.isArray(input) || - typeof input.email !== "string" || - input.email.length > 254 - ) { - throw new BookingError( - 400, - "INVALID_EMAIL", - "Bitte eine gültige E-Mail-Adresse eingeben.", - ); - } - const email = input.email.trim().toLowerCase(); - if (!emailPattern.test(email) || email.split("@")[0].length > 64) { - throw new BookingError( - 400, - "INVALID_EMAIL", - "Bitte eine gültige E-Mail-Adresse eingeben.", - ); - } - let records; - try { - records = await storage.readAll(); - } catch { - throw new BookingError( - 503, - "STORAGE_UNAVAILABLE", - "Die Kundendaten können gerade nicht geprüft werden.", - ); - } - if (!records.some((row) => row.email?.trim().toLowerCase() === email)) { - throw new BookingError( - 403, - "CUSTOMER_NOT_FOUND", - "Zu dieser E-Mail-Adresse liegt keine Buchung vor. Bitte verwende die Adresse deiner Buchung.", - ); - } - return { email, valid: true }; - } - return { - validate, - async submit(input) { - const { email } = await validate(input); - const advisor = advisorDetails(input.advisorId); - if ( - typeof input.reason !== "string" || - !Object.hasOwn(contactReasons, input.reason) - ) { - throw new BookingError( - 400, - "INVALID_REASON", - "Bitte einen Kontaktgrund auswählen.", - ); - } - if (input.simulationAccepted !== true) { - throw new BookingError( - 400, - "SIMULATION_REQUIRED", - "Bitte den Simulationshinweis bestätigen.", - ); - } - await sendContact({ - email, - advisor, - reason: contactReasons[input.reason], - }); - return { - simulated: true, - emailStatus: "accepted", - redirectUrl: contactBookingUrl, - }; - }, - }; -} +import { findAdvisor } from "../../shared/berater.js"; +import { BookingError, emailPattern } from "./booking-service.js"; +import { contactReasons, contactBookingUrl } from "../../shared/contact.js"; + +function advisorDetails(advisorId) { + const advisor = findAdvisor(advisorId); + if (!advisor) { + throw new BookingError( + 400, + "INVALID_ADVISOR", + "Bitte eine gültige Berater-ID übergeben.", + ); + } + if ( + typeof advisor.vorname !== "string" || + !advisor.vorname.trim() || + typeof advisor.email !== "string" || + advisor.email.length > 254 || + !emailPattern.test(advisor.email) || + advisor.email.split("@")[0].length > 64 || + !advisor.email.toLowerCase().endsWith("@tri-hub.de") + ) { + throw new BookingError( + 503, + "ADVISOR_UNAVAILABLE", + "Die Kontaktdaten dieses Beraters sind nicht verfügbar.", + ); + } + return { ...advisor }; +} + +export function createContactService({ storage, requestStorage, sendContact }) { + async function validate(input) { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) || + typeof input.email !== "string" || + input.email.length > 254 + ) { + throw new BookingError( + 400, + "INVALID_EMAIL", + "Bitte eine gültige E-Mail-Adresse eingeben.", + ); + } + const email = input.email.trim().toLowerCase(); + if (!emailPattern.test(email) || email.split("@")[0].length > 64) { + throw new BookingError( + 400, + "INVALID_EMAIL", + "Bitte eine gültige E-Mail-Adresse eingeben.", + ); + } + let records; + try { + records = await storage.readAll(); + } catch { + throw new BookingError( + 503, + "STORAGE_UNAVAILABLE", + "Die Kundendaten können gerade nicht geprüft werden.", + ); + } + if (!records.some((row) => row.email?.trim().toLowerCase() === email)) { + throw new BookingError( + 403, + "CUSTOMER_NOT_FOUND", + "Zu dieser E-Mail-Adresse liegt keine Buchung vor. Bitte verwende die Adresse deiner Buchung.", + ); + } + return { email, valid: true }; + } + return { + validate, + async submit(input) { + const { email } = await validate(input); + const advisor = advisorDetails(input.advisorId); + if ( + typeof input.reason !== "string" || + !Object.hasOwn(contactReasons, input.reason) + ) { + throw new BookingError( + 400, + "INVALID_REASON", + "Bitte einen Kontaktgrund auswählen.", + ); + } + if (input.simulationAccepted !== true) { + throw new BookingError( + 400, + "SIMULATION_REQUIRED", + "Bitte den Simulationshinweis bestätigen.", + ); + } + if (input.consent !== true) { + throw new BookingError( + 400, + "CONSENT_REQUIRED", + "Bitte die Datenschutzbestimmungen akzeptieren.", + ); + } + try { + await requestStorage.append({ + createdAt: new Date().toISOString(), + email, + advisorId: advisor.id, + reason: input.reason, + simulationAccepted: true, + consent: true, + }); + } catch { + throw new BookingError( + 503, + "CONTACT_NOT_SAVED", + "Die Kontaktanfrage konnte nicht gespeichert werden.", + ); + } + await sendContact({ + email, + advisor, + reason: contactReasons[input.reason], + }); + return { + simulated: true, + emailStatus: "accepted", + redirectUrl: contactBookingUrl, + }; + }, + }; +} diff --git a/src/server/services/csv-storage.js b/src/server/services/csv-storage.js index 2236e99..2656660 100644 --- a/src/server/services/csv-storage.js +++ b/src/server/services/csv-storage.js @@ -18,8 +18,9 @@ const legacyColumns = [ const orderColumns = [...legacyColumns, "bookedPackage"]; const customerColumns = [...orderColumns, "customer", "acceptedTerms"]; -export const columns = [...customerColumns, "phone"]; -const jsonColumns = ["bookedPackage", "customer", "acceptedTerms"]; +const phoneColumns = [...customerColumns, "phone"]; +export const columns = [...phoneColumns, "consent"]; +const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"]; // Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird // ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält. @@ -49,6 +50,7 @@ export function createCsvStorage(filePath) { if ( header.join(",") !== columns.join(",") && header.join(",") !== customerColumns.join(",") && + header.join(",") !== phoneColumns.join(",") && header.join(",") !== legacyColumns.join(",") && header.join(",") !== orderColumns.join(",") ) { diff --git a/src/server/services/review-storage.js b/src/server/services/review-storage.js new file mode 100644 index 0000000..289de55 --- /dev/null +++ b/src/server/services/review-storage.js @@ -0,0 +1,93 @@ +import { mkdir, open, readFile, rename, rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import { parse } from "csv-parse/sync"; +import { stringify } from "csv-stringify/sync"; + +// Diese Spalten bilden den festen Aufbau der Bewertungen-CSV. +const columns = ["createdAt", "email", "title", "review", "rating", "consent"]; + +// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird. +function protect(value) { + const text = String(value ?? ""); + return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text; +} + +// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her. +function restore(value) { + return value.startsWith("'") ? value.slice(1) : value; +} + +// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange. +export function createReviewStorage(filePath) { + let queue = Promise.resolve(); + + // Liest alle Bewertungen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt. + async function readAll() { + let content; + try { + content = await readFile(filePath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + if (!content.trim()) throw new Error("Leere Bewertungsdatei"); + return parse(content, { + bom: true, + columns(header) { + if (header.join(",") !== columns.join(",")) { + throw new Error("Unbekanntes Bewertungs-CSV-Format"); + } + return header; + }, + skip_empty_lines: true, + }).map((row) => + Object.fromEntries( + Object.entries(row).map(([key, value]) => [ + key, + restore(value), + ]), + ), + ); + } + + // Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original. + async function writeAll(records) { + await mkdir(dirname(filePath), { recursive: true }); + const temporary = `${filePath}.${randomUUID()}.tmp`; + const content = stringify( + records.map((row) => + Object.fromEntries( + columns.map((column) => [column, protect(row[column])]), + ), + ), + { header: true, columns, record_delimiter: "\r\n" }, + ); + try { + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(content, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, filePath); + } finally { + await rm(temporary, { force: true }); + } + } + + return { + readAll, + // Hängt eine Bewertung nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren. + append(record) { + const operation = queue.then(async () => { + const records = await readAll(); + records.push(record); + await writeAll(records); + }); + queue = operation.catch(() => {}); + return operation; + }, + }; +} diff --git a/src/server/swagger.json b/src/server/swagger.json index 8a43d82..e1dd349 100644 --- a/src/server/swagger.json +++ b/src/server/swagger.json @@ -1,982 +1,992 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "Tri-Hub Ernährungsberatung & Booking API", - "description": "API-Spezifikation für die Angebotsübersicht (US2.1), die Paket-Detailansicht und den Buchungsservice (US2.4 Paket buchen). Kontakt-Simulation mit CSV-Kundenvalidierung und Mailpit.", - "version": "1.2.0" - }, - "servers": [ - { - "url": "http://localhost:3000/api", - "description": "Lokaler Node.js Entwicklungsserver" - } - ], - "paths": { - "/packages": { - "get": { - "summary": "Alle Ernährungs-Pakete abrufen (Kurzübersicht)", - "description": "Liefert die Basisdaten aus packages.js für die Angebotsübersicht (US2.1).", - "tags": ["Packages"], - "responses": { - "200": { - "description": "Erfolgreiche Rückgabe der Paketliste", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackageOverview" - } - } - } - } - } - } - } - }, - "/packages/{id}": { - "get": { - "summary": "Detaillierte Paketinformationen für Detailseite & Bestellprozess abrufen", - "description": "Liefert die ausführlichen Paketdaten aus packagesdetails.js (inkl. Preis, Laufzeit, Leistungen und Varianten), um sie auf der Detailseite und in der Bestellzusammenfassung des Booking-Service anzuzeigen.", - "tags": ["Packages", "Booking"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "description": "Eindeutige ID des Pakets (z. B. ernaehrung-starter)", - "schema": { - "type": "string", - "example": "ernaehrung-standard" - } - } - ], - "responses": { - "200": { - "description": "Detaillierte Paketdaten erfolgreich geladen", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackageDetail" - } - } - } - }, - "404": { - "description": "Paket mit dieser ID wurde nicht gefunden", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/bookings/preview": { - "post": { - "summary": "Preis- und Leistungsübersicht für den Bestellprozess berechnen", - "description": "Berechnet die Bestellübersicht aus dem serverseitigen Paketkatalog. Preisangaben aus der Anfrage werden ignoriert. Ohne Varianten-ID wird bei Paketen mit Varianten die erste Variante gewählt. Es wird keine Buchung angelegt.", - "tags": ["Booking"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingPreviewRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Berechnete Bestellübersicht für das Checkout-Formular", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingPreviewResponse" - } - } - } - }, - "400": { - "description": "Ungültige Paket- oder Varianten-ID", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/bookings": { - "post": { - "summary": "Paket verbindlich buchen (US2.4)", - "description": "Speichert Paket, gewählte Variante, Leistungen und Preise zum Buchungszeitpunkt und startet den Bestätigungsversand. Der aktuelle Ablauf simuliert die Bestellung ohne Zahlung.", - "tags": ["Booking"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingCreateRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Buchung erfolgreich angelegt (Bestellbestätigung)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingConfirmation" - } - } - } - }, - "400": { - "description": "Fehlende oder ungültige Eingabedaten", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "200": { - "description": "Bereits gespeicherte Buchung; keine zweite Buchung oder Mail", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingConfirmation" - } - } - } - }, - "202": { - "description": "Buchung gespeichert; Versand ausstehend, fehlgeschlagen oder unklar", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingConfirmation" - } - } - } - }, - "409": { - "description": "Anfrageschlüssel wurde mit anderen Buchungsdaten verwendet", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "413": { - "description": "Anfrage größer als 8 KiB", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "415": { - "description": "JSON-Content-Type erforderlich", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "503": { - "description": "Speicherung oder Statusprüfung nicht verfügbar", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "parameters": [ - { - "name": "Idempotency-Key", - "in": "header", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - }, - "description": "UUID v4; bei Wiederholung denselben Schlüssel und dieselben Buchungsdaten verwenden." - } - ] - } - }, - "/contact/validate": { - "post": { - "tags": ["Contact"], - "summary": "Kunden-E-Mail prüfen", - "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email"], - "properties": { - "email": { - "type": "string", - "format": "email", - "maxLength": 254 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Kunde in CSV gefunden", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["valid", "email"], - "properties": { - "valid": { - "type": "boolean", - "enum": [true] - }, - "email": { - "type": "string", - "format": "email", - "maxLength": 254 - } - } - } - } - } - }, - "400": { - "description": "Ungültige Eingabe oder JSON", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "403": { - "description": "Keine Buchung zur E-Mail", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "405": { - "description": "Nur POST erlaubt", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "413": { - "description": "Mehr als 8 KiB", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "415": { - "description": "Content-Type muss application/json sein", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "503": { - "description": "CSV nicht lesbar", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "500": { - "description": "Unerwarteter Fehler", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - } - } - } - }, - "/contact": { - "post": { - "tags": ["Contact"], - "summary": "Beraterkontakt simulieren", - "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes. Prüft CSV bei jedem Absenden erneut. Versand ausschließlich an lokales Mailpit (127.0.0.1:1025), zwei getrennte Bestätigungen. Kein automatischer Neuversand, keine persistente Kontaktablage.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "email", - "advisorId", - "reason", - "simulationAccepted" - ], - "properties": { - "email": { - "type": "string", - "format": "email", - "maxLength": 254 - }, - "reason": { - "type": "string", - "enum": [ - "coaching", - "nutrition", - "competition", - "recovery", - "package", - "appointment", - "other" - ] - }, - "simulationAccepted": { - "type": "boolean", - "enum": [true] - }, - "advisorId": { - "type": "string", - "example": "relindis-agethen", - "description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen." - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Beide simulierten E-Mails von Mailpit angenommen; Weiterleitung möglich", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "simulated", - "emailStatus", - "redirectUrl" - ], - "properties": { - "simulated": { - "type": "boolean", - "enum": [true] - }, - "emailStatus": { - "type": "string", - "enum": ["accepted"] - }, - "redirectUrl": { - "type": "string", - "format": "uri", - "description": "Feste Microsoft-Bookings-Adresse; keine benutzerdefinierten Redirects." - } - } - } - } - } - }, - "400": { - "description": "Ungültige Eingabe oder JSON", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "403": { - "description": "Keine Buchung zur E-Mail", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "405": { - "description": "Nur POST erlaubt", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "413": { - "description": "Mehr als 8 KiB", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "415": { - "description": "Content-Type muss application/json sein", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "503": { - "description": "CSV nicht lesbar oder Beraterkontaktdaten ungültig", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "500": { - "description": "Unerwarteter Fehler", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "502": { - "description": "Mindestens eine Mailannahme fehlgeschlagen oder unklar; vor Wiederholung Mailpit prüfen", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "PackageOverview": { - "type": "object", - "required": [ - "id", - "type", - "title", - "description", - "fokus", - "anamnese", - "begleitung", - "buttonText" - ], - "properties": { - "id": { - "type": "string", - "example": "ernaehrung-standard" - }, - "type": { - "type": "string", - "example": "STANDARD" - }, - "title": { - "type": "string", - "example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen" - }, - "description": { - "type": "string" - }, - "fokus": { - "type": "string" - }, - "anamnese": { - "type": "string" - }, - "begleitung": { - "type": "string" - }, - "buttonText": { - "type": "string", - "example": "Standard-Paket ansehen" - }, - "highlight": { - "type": "string", - "nullable": true, - "example": "BESONDERS PASSEND" - } - } - }, - "PackageVariant": { - "type": "object", - "properties": { - "variantId": { - "type": "string", - "example": "ernaehrung-premium-24" - }, - "label": { - "type": "string", - "example": "24-Wochen-Variante" - }, - "durationWeeks": { - "type": "integer", - "example": 24 - }, - "price": { - "type": "number", - "format": "float", - "example": 799.0, - "description": "Bruttopreis der Variante inklusive MwSt." - } - } - }, - "PackageDetail": { - "type": "object", - "required": [ - "id", - "type", - "title", - "subtitle", - "durationWeeks", - "price", - "currency", - "taxRate", - "includedFeatures" - ], - "properties": { - "id": { - "type": "string", - "example": "ernaehrung-standard" - }, - "type": { - "type": "string", - "example": "STANDARD" - }, - "badge": { - "type": "string", - "example": "BESONDERS PASSEND" - }, - "title": { - "type": "string", - "example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen" - }, - "subtitle": { - "type": "string" - }, - "durationWeeks": { - "type": "integer", - "example": 24 - }, - "durationLabel": { - "type": "string", - "example": "24 Wochen Begleitung" - }, - "price": { - "type": "number", - "format": "float", - "example": 449.0, - "description": "Bruttopreis inklusive der angegebenen MwSt." - }, - "currency": { - "type": "string", - "example": "EUR" - }, - "billingInterval": { - "type": "string", - "example": "einmalig" - }, - "taxRate": { - "type": "integer", - "example": 19 - }, - "summaryForBooking": { - "type": "string", - "example": "Standard-Paket Sporternährung (24 Wochen Coaching)" - }, - "targetGroup": { - "type": "array", - "items": { - "type": "string" - } - }, - "includedFeatures": { - "type": "array", - "items": { - "type": "string" - } - }, - "processSteps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "text": { - "type": "string" - } - } - } - }, - "variants": { - "type": "array", - "nullable": true, - "items": { - "$ref": "#/components/schemas/PackageVariant" - } - }, - "ctaButtonText": { - "type": "string", - "example": "Standard-Paket jetzt buchen" - } - } - }, - "BookingPreviewRequest": { - "type": "object", - "required": ["packageId"], - "properties": { - "packageId": { - "type": "string", - "example": "ernaehrung-premium" - }, - "variantId": { - "type": "string", - "nullable": true, - "example": "ernaehrung-premium-52" - } - } - }, - "BookingPreviewResponse": { - "type": "object", - "properties": { - "packageId": { - "type": "string", - "example": "ernaehrung-premium" - }, - "variantId": { - "type": "string", - "example": "ernaehrung-premium-52", - "nullable": true - }, - "title": { - "type": "string", - "example": "Für maximale Individualität und intensive 1:1-Begleitung" - }, - "summaryForBooking": { - "type": "string" - }, - "durationWeeks": { - "type": "integer", - "example": 52 - }, - "netPrice": { - "type": "number", - "format": "float", - "example": 1175.63 - }, - "taxAmount": { - "type": "number", - "format": "float", - "example": 223.37 - }, - "grossPrice": { - "type": "number", - "format": "float", - "example": 1399.0 - }, - "currency": { - "type": "string", - "example": "EUR" - }, - "variantLabel": { - "type": "string", - "nullable": true - }, - "type": { - "type": "string" - }, - "subtitle": { - "type": "string" - }, - "durationLabel": { - "type": "string" - }, - "quantity": { - "type": "integer", - "enum": [1] - }, - "billingInterval": { - "type": "string", - "example": "einmalig" - }, - "taxRate": { - "type": "number", - "example": 19 - }, - "includedFeatures": { - "type": "array", - "items": { - "type": "string" - } - }, - "processSteps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "text": { - "type": "string" - } - } - } - } - }, - "required": [ - "packageId", - "variantId", - "title", - "summaryForBooking", - "durationWeeks", - "netPrice", - "taxAmount", - "grossPrice", - "currency", - "variantLabel", - "type", - "subtitle", - "durationLabel", - "quantity", - "billingInterval", - "taxRate", - "includedFeatures", - "processSteps" - ] - }, - "BookingCreateRequest": { - "type": "object", - "required": ["packageId"], - "properties": { - "packageId": { - "type": "string", - "example": "ernaehrung-standard" - }, - "variantId": { - "type": "string", - "nullable": true, - "example": "ernaehrung-premium-52" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 120, - "example": "Thomas Müller" - }, - "email": { - "type": "string", - "format": "email", - "maxLength": 254, - "example": "thomas.mueller@example.de" - }, - "phone": { - "type": "string", - "maxLength": 50, - "description": "Optionale Telefonnummer: deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl. Prüfung des Nummernformats, keine Bestätigung der Erreichbarkeit. Bei customer gilt customer.phone.", - "example": "+49 170 1234567" - }, - "customer": { - "type": "object", - "required": ["firstName", "lastName", "email"], - "properties": { - "firstName": { - "type": "string", - "example": "Thomas" - }, - "lastName": { - "type": "string", - "example": "Müller" - }, - "email": { - "type": "string", - "format": "email", - "example": "thomas.mueller@example.de" - }, - "phone": { - "type": "string", - "maxLength": 50, - "description": "Optional. Deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl; keine Bestätigung der Erreichbarkeit.", - "example": "+49 170 1234567" - }, - "ageGroup": { - "type": "string", - "example": "50-59" - }, - "notes": { - "type": "string", - "example": "Vorbereitung auf meine erste Mitteldistanz im August." - } - } - }, - "acceptedTerms": { - "type": "boolean", - "example": true, - "enum": [true] - } - }, - "anyOf": [ - { - "required": ["customer", "acceptedTerms"] - }, - { - "required": ["name", "email"] - } - ], - "description": "Kundendaten gemäß customer mit acceptedTerms=true. Bestehende Clients können alternativ name und email sowie optional phone übergeben." - }, - "BookingConfirmation": { - "type": "object", - "properties": { - "bookingId": { - "type": "string", - "example": "TH-000001" - }, - "status": { - "type": "string", - "example": "CONFIRMED" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "bookedPackage": { - "allOf": [ - { - "$ref": "#/components/schemas/BookingPreviewResponse" - } - ], - "nullable": true, - "description": "Gespeicherter Bestellstand; bei älteren Buchungen ohne diese Daten null." - }, - "customerEmail": { - "type": "string", - "example": "thomas.mueller@example.de" - }, - "packageId": { - "type": "string" - }, - "packageName": { - "type": "string" - }, - "saved": { - "type": "boolean" - }, - "emailStatus": { - "type": "string", - "enum": ["pending", "accepted", "failed", "unknown"], - "description": "accepted bedeutet SMTP-Annahme, keine bestätigte Zustellung." - }, - "replayed": { - "type": "boolean" - } - } - }, - "ErrorResponse": { - "type": "object", - "required": ["error"], - "properties": { - "error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "example": "INVALID_VARIANT" - }, - "message": { - "type": "string" - }, - "fields": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "ContactError": { - "type": "object", - "required": ["error"], - "properties": { - "error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } -} +{ + "openapi": "3.0.3", + "info": { + "title": "Tri-Hub Ernährungsberatung & Booking API", + "description": "API-Spezifikation für die Angebotsübersicht (US2.1), die Paket-Detailansicht und den Buchungsservice (US2.4 Paket buchen). Kontakt-Simulation mit CSV-Kundenvalidierung und Mailpit.", + "version": "1.2.0" + }, + "servers": [ + { + "url": "http://localhost:3000/api", + "description": "Lokaler Node.js Entwicklungsserver" + } + ], + "paths": { + "/packages": { + "get": { + "summary": "Alle Ernährungs-Pakete abrufen (Kurzübersicht)", + "description": "Liefert die Basisdaten aus packages.js für die Angebotsübersicht (US2.1).", + "tags": ["Packages"], + "responses": { + "200": { + "description": "Erfolgreiche Rückgabe der Paketliste", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackageOverview" + } + } + } + } + } + } + } + }, + "/packages/{id}": { + "get": { + "summary": "Detaillierte Paketinformationen für Detailseite & Bestellprozess abrufen", + "description": "Liefert die ausführlichen Paketdaten aus packagesdetails.js (inkl. Preis, Laufzeit, Leistungen und Varianten), um sie auf der Detailseite und in der Bestellzusammenfassung des Booking-Service anzuzeigen.", + "tags": ["Packages", "Booking"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Eindeutige ID des Pakets (z. B. ernaehrung-starter)", + "schema": { + "type": "string", + "example": "ernaehrung-standard" + } + } + ], + "responses": { + "200": { + "description": "Detaillierte Paketdaten erfolgreich geladen", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackageDetail" + } + } + } + }, + "404": { + "description": "Paket mit dieser ID wurde nicht gefunden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/bookings/preview": { + "post": { + "summary": "Preis- und Leistungsübersicht für den Bestellprozess berechnen", + "description": "Berechnet die Bestellübersicht aus dem serverseitigen Paketkatalog. Preisangaben aus der Anfrage werden ignoriert. Ohne Varianten-ID wird bei Paketen mit Varianten die erste Variante gewählt. Es wird keine Buchung angelegt.", + "tags": ["Booking"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingPreviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Berechnete Bestellübersicht für das Checkout-Formular", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingPreviewResponse" + } + } + } + }, + "400": { + "description": "Ungültige Paket- oder Varianten-ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/bookings": { + "post": { + "summary": "Paket verbindlich buchen (US2.4)", + "description": "Speichert Paket, gewählte Variante, Leistungen und Preise zum Buchungszeitpunkt und startet den Bestätigungsversand. Der aktuelle Ablauf simuliert die Bestellung ohne Zahlung.", + "tags": ["Booking"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Buchung erfolgreich angelegt (Bestellbestätigung)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingConfirmation" + } + } + } + }, + "400": { + "description": "Fehlende oder ungültige Eingabedaten", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "Bereits gespeicherte Buchung; keine zweite Buchung oder Mail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingConfirmation" + } + } + } + }, + "202": { + "description": "Buchung gespeichert; Versand ausstehend, fehlgeschlagen oder unklar", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingConfirmation" + } + } + } + }, + "409": { + "description": "Anfrageschlüssel wurde mit anderen Buchungsdaten verwendet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "413": { + "description": "Anfrage größer als 8 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "415": { + "description": "JSON-Content-Type erforderlich", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Speicherung oder Statusprüfung nicht verfügbar", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "UUID v4; bei Wiederholung denselben Schlüssel und dieselben Buchungsdaten verwenden." + } + ] + } + }, + "/contact/validate": { + "post": { + "tags": ["Contact"], + "summary": "Kunden-E-Mail prüfen", + "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Kunde in CSV gefunden", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["valid", "email"], + "properties": { + "valid": { + "type": "boolean", + "enum": [true] + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + } + } + } + } + } + }, + "400": { + "description": "Ungültige Eingabe oder JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "403": { + "description": "Keine Buchung zur E-Mail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "405": { + "description": "Nur POST erlaubt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "413": { + "description": "Mehr als 8 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "415": { + "description": "Content-Type muss application/json sein", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "503": { + "description": "CSV nicht lesbar", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "500": { + "description": "Unerwarteter Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + } + } + } + }, + "/contact": { + "post": { + "tags": ["Contact"], + "summary": "Beraterkontakt simulieren", + "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes. Prüft CSV bei jedem Absenden erneut. Versand ausschließlich an lokales Mailpit (127.0.0.1:1025), zwei getrennte Bestätigungen. Kein automatischer Neuversand, keine persistente Kontaktablage.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "advisorId", + "reason", + "simulationAccepted", + "consent" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + }, + "reason": { + "type": "string", + "enum": [ + "coaching", + "nutrition", + "competition", + "recovery", + "package", + "appointment", + "other" + ] + }, + "simulationAccepted": { + "type": "boolean", + "enum": [true] + }, + "advisorId": { + "type": "string", + "example": "relindis-agethen", + "description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen." + }, + "consent": { + "type": "boolean", + "enum": [true] + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Beide simulierten E-Mails von Mailpit angenommen; Weiterleitung möglich", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "simulated", + "emailStatus", + "redirectUrl" + ], + "properties": { + "simulated": { + "type": "boolean", + "enum": [true] + }, + "emailStatus": { + "type": "string", + "enum": ["accepted"] + }, + "redirectUrl": { + "type": "string", + "format": "uri", + "description": "Feste Microsoft-Bookings-Adresse; keine benutzerdefinierten Redirects." + } + } + } + } + } + }, + "400": { + "description": "Ungültige Eingabe oder JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "403": { + "description": "Keine Buchung zur E-Mail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "405": { + "description": "Nur POST erlaubt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "413": { + "description": "Mehr als 8 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "415": { + "description": "Content-Type muss application/json sein", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "503": { + "description": "CSV nicht lesbar oder Beraterkontaktdaten ungültig", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "500": { + "description": "Unerwarteter Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "502": { + "description": "Mindestens eine Mailannahme fehlgeschlagen oder unklar; vor Wiederholung Mailpit prüfen", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "PackageOverview": { + "type": "object", + "required": [ + "id", + "type", + "title", + "description", + "fokus", + "anamnese", + "begleitung", + "buttonText" + ], + "properties": { + "id": { + "type": "string", + "example": "ernaehrung-standard" + }, + "type": { + "type": "string", + "example": "STANDARD" + }, + "title": { + "type": "string", + "example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen" + }, + "description": { + "type": "string" + }, + "fokus": { + "type": "string" + }, + "anamnese": { + "type": "string" + }, + "begleitung": { + "type": "string" + }, + "buttonText": { + "type": "string", + "example": "Standard-Paket ansehen" + }, + "highlight": { + "type": "string", + "nullable": true, + "example": "BESONDERS PASSEND" + } + } + }, + "PackageVariant": { + "type": "object", + "properties": { + "variantId": { + "type": "string", + "example": "ernaehrung-premium-24" + }, + "label": { + "type": "string", + "example": "24-Wochen-Variante" + }, + "durationWeeks": { + "type": "integer", + "example": 24 + }, + "price": { + "type": "number", + "format": "float", + "example": 799.0, + "description": "Bruttopreis der Variante inklusive MwSt." + } + } + }, + "PackageDetail": { + "type": "object", + "required": [ + "id", + "type", + "title", + "subtitle", + "durationWeeks", + "price", + "currency", + "taxRate", + "includedFeatures" + ], + "properties": { + "id": { + "type": "string", + "example": "ernaehrung-standard" + }, + "type": { + "type": "string", + "example": "STANDARD" + }, + "badge": { + "type": "string", + "example": "BESONDERS PASSEND" + }, + "title": { + "type": "string", + "example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen" + }, + "subtitle": { + "type": "string" + }, + "durationWeeks": { + "type": "integer", + "example": 24 + }, + "durationLabel": { + "type": "string", + "example": "24 Wochen Begleitung" + }, + "price": { + "type": "number", + "format": "float", + "example": 449.0, + "description": "Bruttopreis inklusive der angegebenen MwSt." + }, + "currency": { + "type": "string", + "example": "EUR" + }, + "billingInterval": { + "type": "string", + "example": "einmalig" + }, + "taxRate": { + "type": "integer", + "example": 19 + }, + "summaryForBooking": { + "type": "string", + "example": "Standard-Paket Sporternährung (24 Wochen Coaching)" + }, + "targetGroup": { + "type": "array", + "items": { + "type": "string" + } + }, + "includedFeatures": { + "type": "array", + "items": { + "type": "string" + } + }, + "processSteps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + }, + "variants": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/PackageVariant" + } + }, + "ctaButtonText": { + "type": "string", + "example": "Standard-Paket jetzt buchen" + } + } + }, + "BookingPreviewRequest": { + "type": "object", + "required": ["packageId"], + "properties": { + "packageId": { + "type": "string", + "example": "ernaehrung-premium" + }, + "variantId": { + "type": "string", + "nullable": true, + "example": "ernaehrung-premium-52" + } + } + }, + "BookingPreviewResponse": { + "type": "object", + "properties": { + "packageId": { + "type": "string", + "example": "ernaehrung-premium" + }, + "variantId": { + "type": "string", + "example": "ernaehrung-premium-52", + "nullable": true + }, + "title": { + "type": "string", + "example": "Für maximale Individualität und intensive 1:1-Begleitung" + }, + "summaryForBooking": { + "type": "string" + }, + "durationWeeks": { + "type": "integer", + "example": 52 + }, + "netPrice": { + "type": "number", + "format": "float", + "example": 1175.63 + }, + "taxAmount": { + "type": "number", + "format": "float", + "example": 223.37 + }, + "grossPrice": { + "type": "number", + "format": "float", + "example": 1399.0 + }, + "currency": { + "type": "string", + "example": "EUR" + }, + "variantLabel": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string" + }, + "subtitle": { + "type": "string" + }, + "durationLabel": { + "type": "string" + }, + "quantity": { + "type": "integer", + "enum": [1] + }, + "billingInterval": { + "type": "string", + "example": "einmalig" + }, + "taxRate": { + "type": "number", + "example": 19 + }, + "includedFeatures": { + "type": "array", + "items": { + "type": "string" + } + }, + "processSteps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + } + }, + "required": [ + "packageId", + "variantId", + "title", + "summaryForBooking", + "durationWeeks", + "netPrice", + "taxAmount", + "grossPrice", + "currency", + "variantLabel", + "type", + "subtitle", + "durationLabel", + "quantity", + "billingInterval", + "taxRate", + "includedFeatures", + "processSteps" + ] + }, + "BookingCreateRequest": { + "type": "object", + "required": ["packageId", "consent"], + "properties": { + "packageId": { + "type": "string", + "example": "ernaehrung-standard" + }, + "variantId": { + "type": "string", + "nullable": true, + "example": "ernaehrung-premium-52" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "example": "Thomas Müller" + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 254, + "example": "thomas.mueller@example.de" + }, + "phone": { + "type": "string", + "maxLength": 50, + "description": "Optionale Telefonnummer: deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl. Prüfung des Nummernformats, keine Bestätigung der Erreichbarkeit. Bei customer gilt customer.phone.", + "example": "+49 170 1234567" + }, + "customer": { + "type": "object", + "required": ["firstName", "lastName", "email"], + "properties": { + "firstName": { + "type": "string", + "example": "Thomas" + }, + "lastName": { + "type": "string", + "example": "Müller" + }, + "email": { + "type": "string", + "format": "email", + "example": "thomas.mueller@example.de" + }, + "phone": { + "type": "string", + "maxLength": 50, + "description": "Optional. Deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl; keine Bestätigung der Erreichbarkeit.", + "example": "+49 170 1234567" + }, + "ageGroup": { + "type": "string", + "example": "50-59" + }, + "notes": { + "type": "string", + "example": "Vorbereitung auf meine erste Mitteldistanz im August." + } + } + }, + "acceptedTerms": { + "type": "boolean", + "example": true, + "enum": [true] + }, + "consent": { + "type": "boolean", + "enum": [true], + "description": "Zustimmung zu den Datenschutzbestimmungen ist erforderlich." + } + }, + "anyOf": [ + { + "required": ["customer", "acceptedTerms"] + }, + { + "required": ["name", "email"] + } + ], + "description": "Kundendaten gemäß customer mit acceptedTerms=true. Bestehende Clients können alternativ name und email sowie optional phone übergeben." + }, + "BookingConfirmation": { + "type": "object", + "properties": { + "bookingId": { + "type": "string", + "example": "TH-000001" + }, + "status": { + "type": "string", + "example": "CONFIRMED" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "bookedPackage": { + "allOf": [ + { + "$ref": "#/components/schemas/BookingPreviewResponse" + } + ], + "nullable": true, + "description": "Gespeicherter Bestellstand; bei älteren Buchungen ohne diese Daten null." + }, + "customerEmail": { + "type": "string", + "example": "thomas.mueller@example.de" + }, + "packageId": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "saved": { + "type": "boolean" + }, + "emailStatus": { + "type": "string", + "enum": ["pending", "accepted", "failed", "unknown"], + "description": "accepted bedeutet SMTP-Annahme, keine bestätigte Zustellung." + }, + "replayed": { + "type": "boolean" + } + } + }, + "ErrorResponse": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "example": "INVALID_VARIANT" + }, + "message": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "ContactError": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/src/styles/footer.css b/src/styles/footer.css new file mode 100644 index 0000000..c779dbc --- /dev/null +++ b/src/styles/footer.css @@ -0,0 +1,229 @@ +.trihub-footer, +.trihub-footer * { + box-sizing: border-box; +} + +.trihub-footer { + padding: 4.75rem 0; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0 0 1.8rem 1.8rem; + background: #06171b; + color: #fff; + font-family: "DM Sans", sans-serif; +} + +.trihub-footer-inner { + width: min(100% - 4rem, 1648px); + margin: 0 auto; + display: grid; + grid-template-columns: 1.35fr 1fr 1fr; + gap: 3.5rem; + align-items: stretch; +} + +.trihub-footer-card { + min-width: 0; + min-height: 34.75rem; + padding: 2rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 1.8rem; + background: rgba(255, 255, 255, 0.05); +} + +.trihub-brand { + display: flex; + align-items: center; + gap: 1.4rem; + color: inherit; + text-decoration: none; +} + +.trihub-logo-box { + width: 100px; + height: 90px; + padding: 0.5rem 0.75rem; + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 1.1rem; + background: #fff; + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.18); +} + +.trihub-logo-box img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.trihub-brand-label, +.trihub-brand-title { + display: block; +} + +.trihub-brand-label { + margin: 0 0 0.3rem; + color: rgba(255, 255, 255, 0.46); + font-size: 0.68rem; + letter-spacing: 0.28em; + text-transform: uppercase; +} + +.trihub-brand-title { + color: #fff; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; +} + +.trihub-description { + max-width: 36rem; + margin: 2.3rem 0 0; + color: rgba(255, 255, 255, 0.64); + font-size: 0.875rem; + line-height: 1.75rem; +} + +.trihub-contact { + margin-top: 2.1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + color: rgba(255, 255, 255, 0.68); + font-size: 0.875rem; +} + +.trihub-contact-row { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.trihub-contact-row p { + margin: 0; +} + +.trihub-contact-row a { + color: inherit; + text-decoration: none; +} + +.trihub-contact-row svg { + width: 1rem; + height: 1rem; + margin-top: 0.25rem; + flex-shrink: 0; + color: #ffc21f; +} + +.trihub-socials { + margin-top: 2.1rem; + display: flex; + gap: 0.75rem; +} + +.trihub-social-button { + width: 3.25rem; + height: 3.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 9999px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); + transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.trihub-social-button svg { + width: 1.25rem; + height: 1.25rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.trihub-social-button:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.trihub-footer-heading { + margin: 0 0 1.7rem; + display: flex; + font-family: "DM Sans", sans-serif; + align-items: center; + gap: 0.75rem; + color: rgba(255, 255, 255, 0.46); + font-size: 0.8rem; + font-weight: 400; + letter-spacing: 0.28em; + text-transform: uppercase; +} + +.trihub-footer-heading svg { + width: 1rem; + height: 1rem; + color: #00c7d5; +} + +.trihub-footer-links { + display: flex; + flex-direction: column; + gap: 1rem; + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; +} + +.trihub-footer-links a { + color: inherit; + text-decoration: none; + transition: color 0.15s ease; +} + +.trihub-footer-links a:hover { + color: #fff; +} + +.trihub-footer-links .trihub-cookie-link { + margin: 0.7rem 0 0; + color: #00bfd2; + text-decoration: none; +} + +.trihub-footer-links .trihub-cookie-link:hover { + color: #fff; +} + +@media (max-width: 1000px) { + .trihub-footer-inner { + grid-template-columns: 1fr; + } + + .trihub-footer-card { + min-height: auto; + } +} + +@media (max-width: 640px) { + .trihub-footer { + padding: 2rem 0; + } + + .trihub-footer-inner { + width: min(100% - 2rem, 1648px); + gap: 1rem; + } + + .trihub-footer-card { + padding: 1.5rem; + } + + .trihub-brand { + align-items: flex-start; + } +} diff --git a/src/styles/packages.css b/src/styles/packages.css index c058b4b..d989cbb 100644 --- a/src/styles/packages.css +++ b/src/styles/packages.css @@ -72,6 +72,15 @@ display: flex; flex-direction: column; } +.package-image { + display: block; + width: 100%; + aspect-ratio: 1.42 / 1; + margin: 0 0 1.5rem; + border: 1px solid #f8fafc1f; + border-radius: 1.1rem; + object-fit: cover; +} .package-type-container { display: flex; flex-wrap: wrap; diff --git a/src/tests/bookings.test.js b/src/tests/bookings.test.js index 2be862c..b076817 100644 --- a/src/tests/bookings.test.js +++ b/src/tests/bookings.test.js @@ -11,6 +11,7 @@ import { createEmailService } from "../server/services/email-service.js"; import { packages } from "../shared/packages.js"; const input = { + consent: true, packageId: packages[0].id, name: 'Test, "Person"', email: "person@example.test", @@ -68,6 +69,7 @@ test("Buchung steht vor Versand in CSV; Paketname kommt vom Server", async (t) = assert.ok(!Number.isNaN(Date.parse(result.body.createdAt))); const records = await fixture.storage.readAll(); assert.equal(records.length, 1); + assert.equal(records[0].consent, true); assert.equal(records[0].name, input.name); assert.equal(records[0].emailStatus, "accepted"); assert.equal(fixture.sent.length, 1); @@ -81,6 +83,7 @@ test("Pflichtfelder, Typen, Längen, E-Mail und unbekannte IDs werden abgelehnt" null, [], {}, + ...[false, "true", undefined].map((consent) => ({ ...input, consent })), { ...input, name: " " }, { ...input, name: 42 }, { ...input, name: "a".repeat(121) }, @@ -583,6 +586,7 @@ test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeich notes: "Vorbereitung auf die Mitteldistanz.", }; const request = { + consent: true, packageId: "ernaehrung-standard", customer, acceptedTerms: true, @@ -633,6 +637,7 @@ test("Telefonnummern werden in beiden Anfrageformaten geprüft", async (t) => { { ...input, phone }, { packageId: input.packageId, + consent: true, acceptedTerms: true, customer: { firstName: "Test", diff --git a/src/tests/browser/booking.spec.js b/src/tests/browser/booking.spec.js index 41b86ae..90eecb3 100644 --- a/src/tests/browser/booking.spec.js +++ b/src/tests/browser/booking.spec.js @@ -5,12 +5,18 @@ import { packagesDetails } from "../../shared/packagesdetails.js"; const pageUrl = "/booking.html#/buchen/ernaehrung-starter"; async function fill(page) { + if (!(await page.locator("dialog[open]").count())) { + await page + .getByRole("button", { name: "Jetzt buchen", exact: true }) + .click(); + } await page .getByLabel("Name (Pflichtfeld)", { exact: true }) .fill("Test Person"); await page .getByLabel("E-Mail-Adresse (Pflichtfeld)") .fill("person@example.test"); + await page.locator('[name="consent"]').check(); } test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy", async ({ @@ -26,41 +32,47 @@ test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy" await expect(page.locator(".booking__payment-notice")).toContainText( "keine Abbuchung", ); - await page.keyboard.press("Tab"); - await expect( - page.getByRole("link", { name: "Zur Angebotsseite" }), - ).toBeFocused(); - await page.keyboard.press("Tab"); + const opener = page.getByRole("button", { + name: "Jetzt buchen", + exact: true, + }); + await opener.focus(); + await page.keyboard.press("Enter"); await expect( page.getByLabel("Name (Pflichtfeld)", { exact: true }), ).toBeFocused(); - await page.keyboard.press("Tab"); - await page.keyboard.press("Tab"); await page.keyboard.press("Enter"); await expect(page.getByRole("alert")).toBeFocused(); - await expect(page.locator('[aria-invalid="true"]')).toHaveCount(2); + await expect(page.locator('[aria-invalid="true"]')).toHaveCount(3); await page.keyboard.press("Tab"); await page.keyboard.type("Test Person"); await page.keyboard.press("Tab"); await page.keyboard.type("person@example.test"); await page.keyboard.press("Tab"); - const outline = await page - .getByRole("button", { name: "Paket buchen", exact: true }) - .evaluate((element) => getComputedStyle(element).outlineStyle); - expect(outline).toBe("solid"); + await page.keyboard.press("Tab"); + await expect(page.locator('[name="consent"]')).toBeFocused(); + await page.keyboard.press("Space"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + const submit = page.getByRole("button", { + name: "Paket buchen", + exact: true, + }); + await expect(submit).toBeFocused(); + expect( + await submit.evaluate( + (element) => getComputedStyle(element).outlineStyle, + ), + ).toBe("solid"); await page.keyboard.press("Enter"); - await expect(page.locator(".booking__result")).toContainText( - /Buchungsnummer: TH-\d{6,}/, + await expect(page).toHaveURL(/booking-confirmation\.html$/); + await expect(page.locator("#confirmation-receipt")).toContainText( + /TH-\d{6,}/, ); - await expect(page.locator(".booking__result")).toContainText( - "Zustellung ist noch nicht bestätigt", - ); - await expect( - page.getByRole("button", { name: "Buchung gespeichert" }), - ).toBeDisabled(); await page.reload(); - await expect(page.locator(".booking__result")).toContainText( - /Buchungsnummer: TH-\d{6,}/, + await expect(page.locator("#confirmation-receipt")).toContainText( + /TH-\d{6,}/, ); }); @@ -105,6 +117,9 @@ test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchu .click(); await expect(page.getByRole("alert")).toBeVisible(); await page.reload(); + await page + .getByRole("button", { name: "Jetzt buchen", exact: true }) + .click(); await expect( page.getByLabel("Name (Pflichtfeld)", { exact: true }), ).toHaveValue("Test Person"); @@ -114,7 +129,9 @@ test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchu await page .getByRole("button", { name: "Status prüfen / erneut versuchen" }) .click(); - await expect(page.locator(".booking__result")).toContainText(firstBooking); + await expect(page.locator("#confirmation-receipt")).toContainText( + firstBooking, + ); expect(requests).toBe(2); }); @@ -186,7 +203,9 @@ test("Vite liefert Serverdateien und Umgebungsdateien nicht aus", async ({ for (const pkg of packages) { test(`Von der Startseite zur Buchung: ${pkg.type}`, async ({ page }) => { await page.goto("/"); - await page.getByRole("link", { name: "Beratung kennenlernen" }).click(); + await page + .getByRole("link", { name: "Ernährungswissen entdecken" }) + .click(); await expect(page.locator(".package-card")).toHaveCount( packages.length, ); @@ -195,13 +214,11 @@ for (const pkg of packages) { new RegExp(`paket-details\\.html\\?id=${pkg.id}$`), ); await page.locator("#proceed-to-booking-btn").click(); - await expect(page).toHaveURL( - new RegExp(`booking\\.html\\?id=${pkg.id}`), - ); + await expect(page.locator("dialog[open]")).toBeVisible(); const details = packagesDetails.find((entry) => entry.id === pkg.id); - await expect( - page.getByRole("heading", { name: details.title, exact: true }), - ).toBeVisible(); + await expect(page.locator(".booking__package-name")).toHaveText( + details.title, + ); await expect(page.locator(".booking__summary")).toHaveText( details.summaryForBooking, ); @@ -219,13 +236,16 @@ for (const pkg of packages) { .click(); const response = await responsePromise; expect(response.status()).toBe(201); - const booking = await response.json(); + await expect(page).toHaveURL(/booking-confirmation\.html$/); + const booking = await page.evaluate(() => + JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")), + ); expect(booking.packageId).toBe(pkg.id); expect(booking.packageName).toBe(pkg.name); - await expect(page.locator(".booking__result")).toContainText( + await expect(page.locator("#confirmation-receipt")).toContainText( booking.bookingId, ); - await page.getByRole("link", { name: "Zur Angebotsseite" }).click(); + await page.goto("/angebotsuebersicht.html"); await expect(page.locator(".package-card")).toHaveCount( packages.length, ); @@ -249,14 +269,11 @@ test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung über await page.goto("/paket-details.html?id=ernaehrung-premium"); await page.locator("#variant-select").selectOption("ernaehrung-premium-52"); await page.locator("#proceed-to-booking-btn").click(); - await expect(page).toHaveURL( - /booking\.html\?id=ernaehrung-premium&variant=ernaehrung-premium-52$/, - ); + await expect(page.locator("dialog[open]")).toBeVisible(); await expect(page.locator(".booking__totals")).toContainText( "52 Wochen Begleitung", ); await expect(page.locator(".booking__totals")).toContainText("1.399,00"); - await page.reload(); await fill(page); const responsePromise = page.waitForResponse( (response) => @@ -270,15 +287,20 @@ test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung über expect(response.request().postDataJSON().variantId).toBe( "ernaehrung-premium-52", ); - const booking = await response.json(); + await expect(page).toHaveURL(/booking-confirmation\.html$/); + const booking = await page.evaluate(() => + JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")), + ); expect(booking.bookedPackage.grossPrice).toBe(1399); expect(booking.bookedPackage.durationWeeks).toBe(52); - await expect(page.locator(".booking__result")).toContainText( + await expect(page.locator("#confirmation-receipt")).toContainText( booking.bookingId, ); await page.reload(); - await expect(page.locator(".booking__totals")).toContainText("1.399,00"); - await expect(page.locator(".booking__result")).toContainText( + await expect(page.locator("#confirmation-totals")).toContainText( + "1.399,00", + ); + await expect(page.locator("#confirmation-receipt")).toContainText( booking.bookingId, ); }); diff --git a/src/tests/browser/contact.spec.js b/src/tests/browser/contact.spec.js index 0f7df1b..a8fa98d 100644 --- a/src/tests/browser/contact.spec.js +++ b/src/tests/browser/contact.spec.js @@ -1,98 +1,105 @@ -import { test, expect } from "@playwright/test"; -import { randomUUID } from "node:crypto"; -import { contactBookingUrl } from "../../shared/contact.js"; - -async function open(page) { - await page.goto("/contact-test.html?berater-id=maren-hoffmann"); - await page - .getByRole("button", { name: "Berater kontaktieren", exact: true }) - .click(); -} - -test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter", async ({ - page, - request, -}) => { - const email = `kontakt-${randomUUID()}@example.test`; - const booking = await request.post("/api/bookings", { - headers: { "Idempotency-Key": randomUUID() }, - data: { packageId: "ernaehrung-starter", name: "Testkunde", email }, - }); - expect(booking.ok()).toBeTruthy(); - await open(page); - await expect(page.getByRole("dialog")).toContainText("Maren"); - const submit = page.getByRole("button", { name: "Anfrage senden" }); - await expect( - page.getByRole("button", { name: "E-Mail prüfen" }), - ).toHaveCount(0); - await expect(submit).toBeEnabled(); - await page - .getByLabel("Deine Buchungs-E-Mail") - .fill("unbekannt@example.test"); - await page.getByLabel("Grund für den Kontakt").selectOption("nutrition"); - await page.getByRole("checkbox").check(); - await submit.click(); - await expect(page.getByRole("status")).toContainText("keine Buchung"); - await expect(page.getByRole("dialog")).toBeVisible(); - await page.getByLabel("Deine Buchungs-E-Mail").fill(email); - await page.route("https://bookings.cloud.microsoft/**", (route) => - route.fulfill({ body: "Microsoft Bookings (Test)" }), - ); - const sentRequest = page.waitForRequest((request) => - request.url().endsWith("/api/contact"), - ); - await submit.click(); - expect((await sentRequest).postDataJSON().advisorId).toBe("maren-hoffmann"); - await expect(page).toHaveURL(contactBookingUrl); -}); - -test("Mobiles Popup: Fokus, Escape, Rückkehr zum Auslöser und keine Überbreite", async ({ - page, -}) => { - await page.setViewportSize({ width: 375, height: 667 }); - await open(page); - await expect(page.getByLabel("Deine Buchungs-E-Mail")).toBeFocused(); - const box = await page.getByRole("dialog").boundingBox(); - expect(box.x).toBeGreaterThanOrEqual(0); - expect(box.x + box.width).toBeLessThanOrEqual(375); - await page.keyboard.press("Shift+Tab"); - await expect( - page.getByRole("button", { name: "Kontaktfenster schließen" }), - ).toBeFocused(); - await page.keyboard.press("Escape"); - await expect(page.getByRole("dialog")).toHaveCount(0); - await expect( - page.getByRole("button", { name: "Berater kontaktieren", exact: true }), - ).toBeFocused(); -}); - -test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => { - await page.route("**/api/contact", (route) => - route.fulfill({ - status: 502, - json: { error: { message: "Mailpit nicht erreichbar." } }, - }), - ); - await open(page); - await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test"); - await page.getByLabel("Grund für den Kontakt").selectOption("package"); - await page.getByRole("checkbox").check(); - await page.getByRole("button", { name: "Anfrage senden" }).click(); - await expect(page.getByRole("status")).toContainText( - "Mailpit nicht erreichbar", - ); - await expect(page.getByRole("dialog")).toBeVisible(); -}); - -test("Unbekannte Berater-ID sperrt das Absenden", async ({ page }) => { - await page.goto("/contact-test.html?berater-id=unbekannt"); - await page - .getByRole("button", { name: "Berater kontaktieren", exact: true }) - .click(); - await expect(page.getByRole("dialog")).toContainText( - "Berater-ID fehlt oder ist unbekannt", - ); - await expect( - page.getByRole("button", { name: "Anfrage senden" }), - ).toBeDisabled(); -}); +import { test, expect } from "@playwright/test"; +import { randomUUID } from "node:crypto"; +import { contactBookingUrl } from "../../shared/contact.js"; + +async function open(page) { + await page.goto("/contact-test.html?berater-id=maren-hoffmann"); + await page + .getByRole("button", { name: "Berater kontaktieren", exact: true }) + .click(); +} + +test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter", async ({ + page, + request, +}) => { + const email = `kontakt-${randomUUID()}@example.test`; + const booking = await request.post("/api/bookings", { + headers: { "Idempotency-Key": randomUUID() }, + data: { + consent: true, + packageId: "ernaehrung-starter", + name: "Testkunde", + email, + }, + }); + expect(booking.ok()).toBeTruthy(); + await open(page); + await expect(page.getByRole("dialog")).toContainText("Maren"); + const submit = page.getByRole("button", { name: "Anfrage senden" }); + await expect( + page.getByRole("button", { name: "E-Mail prüfen" }), + ).toHaveCount(0); + await expect(submit).toBeEnabled(); + await page + .getByLabel("Deine Buchungs-E-Mail") + .fill("unbekannt@example.test"); + await page.getByLabel("Grund für den Kontakt").selectOption("nutrition"); + await page.locator('[name="simulationAccepted"]').check(); + await page.locator('[name="consent"]').check(); + await submit.click(); + await expect(page.getByRole("status")).toContainText("keine Buchung"); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.getByLabel("Deine Buchungs-E-Mail").fill(email); + await page.route("https://bookings.cloud.microsoft/**", (route) => + route.fulfill({ body: "Microsoft Bookings (Test)" }), + ); + const sentRequest = page.waitForRequest((request) => + request.url().endsWith("/api/contact"), + ); + await submit.click(); + expect((await sentRequest).postDataJSON().advisorId).toBe("maren-hoffmann"); + await expect(page).toHaveURL(contactBookingUrl); +}); + +test("Mobiles Popup: Fokus, Escape, Rückkehr zum Auslöser und keine Überbreite", async ({ + page, +}) => { + await page.setViewportSize({ width: 375, height: 667 }); + await open(page); + await expect(page.getByLabel("Deine Buchungs-E-Mail")).toBeFocused(); + const box = await page.getByRole("dialog").boundingBox(); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(375); + await page.keyboard.press("Shift+Tab"); + await expect( + page.getByRole("button", { name: "Kontaktfenster schließen" }), + ).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Berater kontaktieren", exact: true }), + ).toBeFocused(); +}); + +test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => { + await page.route("**/api/contact", (route) => + route.fulfill({ + status: 502, + json: { error: { message: "Mailpit nicht erreichbar." } }, + }), + ); + await open(page); + await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test"); + await page.getByLabel("Grund für den Kontakt").selectOption("package"); + await page.locator('[name="simulationAccepted"]').check(); + await page.locator('[name="consent"]').check(); + await page.getByRole("button", { name: "Anfrage senden" }).click(); + await expect(page.getByRole("status")).toContainText( + "Mailpit nicht erreichbar", + ); + await expect(page.getByRole("dialog")).toBeVisible(); +}); + +test("Unbekannte Berater-ID sperrt das Absenden", async ({ page }) => { + await page.goto("/contact-test.html?berater-id=unbekannt"); + await page + .getByRole("button", { name: "Berater kontaktieren", exact: true }) + .click(); + await expect(page.getByRole("dialog")).toContainText( + "Berater-ID fehlt oder ist unbekannt", + ); + await expect( + page.getByRole("button", { name: "Anfrage senden" }), + ).toBeDisabled(); +}); diff --git a/src/tests/browser/privacy.spec.js b/src/tests/browser/privacy.spec.js new file mode 100644 index 0000000..5bdf1dc --- /dev/null +++ b/src/tests/browser/privacy.spec.js @@ -0,0 +1,110 @@ +import { test, expect } from "@playwright/test"; + +const privacyUrl = "https://www.tri-hub.de/datenschutz"; + +async function expectConsent(form) { + const checkbox = form.locator('[name="consent"]'); + await expect(checkbox).not.toBeChecked(); + await expect(checkbox).toHaveAttribute("required", ""); + const link = form.getByRole("link", { + name: "Datenschutzbestimmungen", + exact: true, + }); + await expect(link).toHaveAttribute("href", privacyUrl); + await expect(link).toHaveCSS("font-weight", "700"); + await expect(link).toHaveCSS("text-decoration-line", "underline"); + return checkbox; +} + +test("Landingpage: beide Formulare benötigen Datenschutz; Kontakt speichert Zustimmung", async ({ + page, +}) => { + await page.goto("/index.html"); + await expectConsent(page.locator("#reviewForm")); + const form = page.locator("#contactForm"); + const consent = await expectConsent(form); + await form.locator('[name="name"]').fill("Datenschutz Test"); + await form.locator('[name="email"]').fill("privacy@example.test"); + await form.locator('[name="subject"]').fill("Eine Testanfrage"); + await form + .locator('[name="message"]') + .fill("Eine ausreichend lange Testnachricht."); + await form.locator('[type="submit"]').click(); + expect( + await consent.evaluate((element) => element.validity.valueMissing), + ).toBe(true); + await consent.check(); + const response = page.waitForResponse((response) => + response.url().endsWith("/api/nutrition-contact"), + ); + await form.locator('[type="submit"]').click(); + const saved = await response; + expect(saved.status()).toBe(201); + expect(saved.request().postDataJSON().consent).toBe(true); + await expect(consent).not.toBeChecked(); +}); + +test("Buchungsdialog verlangt Datenschutz und übermittelt Zustimmung", async ({ + page, +}) => { + await page.goto("/booking.html?id=ernaehrung-starter"); + await page + .getByRole("button", { name: "Jetzt buchen", exact: true }) + .click(); + const form = page.locator("dialog form"); + const consent = await expectConsent(form); + await form.locator('[name="name"]').fill("Datenschutz Test"); + await form.locator('[name="email"]').fill("privacy@example.test"); + await form.locator('[type="submit"]').click(); + await expect(page.locator('[id$="booking-consent-error"]')).toContainText( + "Datenschutzbestimmungen akzeptieren", + ); + await consent.check(); + const response = page.waitForResponse((response) => + response.url().endsWith("/api/bookings"), + ); + await form.locator('[type="submit"]').click(); + const saved = await response; + expect(saved.status()).toBe(201); + expect(saved.request().postDataJSON().consent).toBe(true); +}); + +test("Beraterkontakt hat eine separate verpflichtende Datenschutz-Zustimmung", async ({ + page, +}) => { + await page.goto("/contact-test.html?berater-id=maren-hoffmann"); + await page + .getByRole("button", { name: "Berater kontaktieren", exact: true }) + .click(); + await expectConsent(page.locator("dialog form")); +}); + +for (const width of [375, 1280]) { + test(`Buchungsfenster: Checkbox und erste Textzeile auf gleicher Höhe (${width}px)`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto("/booking.html?id=ernaehrung-starter"); + await page + .getByRole("button", { name: "Jetzt buchen", exact: true }) + .click(); + const label = page.locator(".booking__consent"); + const difference = await label.evaluate((element) => { + const checkbox = element + .querySelector("input") + .getBoundingClientRect(); + const text = element.querySelector("span"); + const firstLineCenter = + text.getBoundingClientRect().top + + parseFloat(getComputedStyle(text).lineHeight) / 2; + return Math.abs( + checkbox.top + checkbox.height / 2 - firstLineCenter, + ); + }); + expect(difference).toBeLessThanOrEqual(1); + await expect(label.locator("a")).toHaveCSS( + "color", + "rgb(255, 255, 255)", + ); + }); +} diff --git a/src/tests/browser/reviews.spec.js b/src/tests/browser/reviews.spec.js new file mode 100644 index 0000000..9c4c12a --- /dev/null +++ b/src/tests/browser/reviews.spec.js @@ -0,0 +1,51 @@ +import { test, expect } from "@playwright/test"; +import { randomUUID } from "node:crypto"; + +test("Rezension prüft Buchung, behält Fehler-Eingaben und bestätigt Speicherung", async ({ + page, + request, +}) => { + const email = `review-${randomUUID()}@example.test`; + const booking = await request.post("/api/bookings", { + headers: { "Idempotency-Key": randomUUID() }, + data: { + consent: true, + packageId: "ernaehrung-starter", + name: "Testkunde", + email, + }, + }); + expect(booking.status()).toBe(201); + await page.goto("/index.html#bewertung"); + const form = page.locator("#reviewForm"); + await form.locator('[name="email"]').fill("unbekannt@example.test"); + await form.locator('[name="title"]').fill("Sehr hilfreiche Beratung"); + await form + .locator('[name="review"]') + .fill("Die Beratung hilft mir im Alltag und beim Training."); + await form.locator('label[for="star4"]').click(); + await form.locator('[name="consent"]').check(); + await form + .getByRole("button", { name: "Bewertung veröffentlichen" }) + .click(); + await expect(page.locator("#formStatus")).toContainText( + "keine Buchung gefunden", + ); + await expect(form.locator('[name="title"]')).toHaveValue( + "Sehr hilfreiche Beratung", + ); + await form.locator('[name="email"]').fill(email); + const saved = page.waitForResponse( + (response) => + response.url().endsWith("/api/reviews") && + response.status() === 201, + ); + await form + .getByRole("button", { name: "Bewertung veröffentlichen" }) + .click(); + expect(await (await saved).json()).toEqual({ saved: true }); + await expect(page.locator("#formStatus")).toHaveText( + "Danke! Deine Bewertung wurde gespeichert.", + ); + await expect(form.locator('[name="email"]')).toHaveValue(""); +}); diff --git a/src/tests/contact-form.test.js b/src/tests/contact-form.test.js index 4dfb8a4..56c3126 100644 --- a/src/tests/contact-form.test.js +++ b/src/tests/contact-form.test.js @@ -1,269 +1,289 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createApp } from "../server/index.js"; -import { createContactFormEmailService } from "../server/services/contact-form-email.js"; -import { createContactFormStorage } from "../server/services/contact-form-storage.js"; - -const validInput = { - name: "Test Person", - email: "person@example.test", - subject: "Eine Frage", - message: "Das ist eine ausreichend lange Nachricht.", -}; - -// Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand. -async function fixture(t, options = {}) { - const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-")); - const storage = - options.storage ?? - createContactFormStorage(join(directory, "contact-requests.csv")); - const sent = []; - const app = createApp({ - sendConfirmation: async () => {}, - sendContact: async () => {}, - contactFormStorage: storage, - sendContactForm: async (data) => sent.push(data), - ...options, - }); - await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); - t.after(async () => { - app.closeAllConnections(); - await new Promise((resolve) => app.close(resolve)); - await rm(directory, { recursive: true, force: true }); - }); - - const base = `http://127.0.0.1:${app.address().port}`; - async function post(input = validInput, headers = {}) { - const response = await fetch(`${base}/api/nutrition-contact`, { - method: "POST", - headers: { "Content-Type": "application/json", ...headers }, - body: JSON.stringify(input), - }); - return { status: response.status, body: await response.json() }; - } - return { app, base, post, sent, storage, directory }; -} - -// Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit. -test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => { - const f = await fixture(t); - const result = await f.post(); - - assert.equal(result.status, 201); - assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" }); - assert.equal(f.sent.length, 1); - assert.deepEqual( - { - name: f.sent[0].name, - email: f.sent[0].email, - subject: f.sent[0].subject, - message: f.sent[0].message, - }, - validInput, - ); - assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt))); - assert.deepEqual(await f.storage.readAll(), f.sent); -}); - -// Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden. -test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => { - const f = await fixture(t); - const invalidInputs = [ - { ...validInput, name: " " }, - { ...validInput, name: "Test\nBcc: fremd" }, - { ...validInput, name: "x".repeat(121) }, - { ...validInput, email: "keine-adresse" }, - { ...validInput, email: "x".repeat(255) }, - { ...validInput, subject: "x".repeat(161) }, - { ...validInput, message: "zu kurz" }, - { ...validInput, message: "x".repeat(5001) }, - { ...validInput, message: "Nachricht\u0000ungültig" }, - { ...validInput, message: undefined }, - null, - [], - ]; - - for (const input of invalidInputs) { - const result = await f.post(input); - assert.equal(result.status, 400, JSON.stringify(input)); - } - assert.deepEqual(await f.storage.readAll(), []); - assert.equal(f.sent.length, 0); -}); - -// Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts. -test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => { - const f = await fixture(t); - assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405); - - const wrongType = await fetch(`${f.base}/api/nutrition-contact`, { - method: "POST", - body: "{}", - }); - assert.equal(wrongType.status, 415); - - const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{", - }); - assert.equal(invalidJson.status, 400); - - const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "x".repeat(9000) }), - }); - assert.equal(tooLarge.status, 413); - assert.deepEqual(await f.storage.readAll(), []); -}); - -// Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden. -test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => { - let sendCount = 0; - const f = await fixture(t, { - storage: { - async append() { - throw new Error("Datenträger nicht verfügbar"); - }, - }, - sendContactForm: async () => sendCount++, - }); - const result = await f.post(); - - assert.equal(result.status, 503); - assert.equal(result.body.error.code, "CONTACT_NOT_SAVED"); - assert.equal(sendCount, 0); -}); - -// Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten. -test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => { - const f = await fixture(t, { - sendContactForm: async () => { - throw new Error("Mailpit nicht erreichbar"); - }, - }); - const result = await f.post(); - - assert.equal(result.status, 502); - assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED"); - assert.equal(result.body.error.message.includes("gespeichert"), true); - assert.equal((await f.storage.readAll()).length, 1); -}); - -// Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen. -test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => { - const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-")); - const file = join(directory, "contact-requests.csv"); - const storage = createContactFormStorage(file); - t.after(() => rm(directory, { recursive: true, force: true })); - const names = [ - '=HYPERLINK("evil")', - 'Test, "Person"', - "Zeile 1\nZeile 2", - "'Original", - ...Array.from({ length: 8 }, (_, index) => `Person ${index}`), - ]; - - await Promise.all( - names.map((name, index) => - storage.append({ - createdAt: `2026-01-01T00:00:0${index}.000Z`, - name, - email: `person${index}@example.test`, - subject: "CSV Test", - message: "Eine Nachricht mit mehr als zehn Zeichen.", - }), - ), - ); - - const records = await storage.readAll(); - assert.equal(records.length, names.length); - assert.deepEqual( - records.map((record) => record.name).sort(), - [...names].sort(), - ); - const csv = await readFile(file, "utf8"); - assert.match(csv, /'=HYPERLINK/); - assert.match(csv, /"Test, ""Person"""/); -}); - -// Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben. -test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => { - const directory = await mkdtemp( - join(tmpdir(), "trihub-contact-csv-invalid-"), - ); - const file = join(directory, "contact-requests.csv"); - const storage = createContactFormStorage(file); - const corrupt = "wrong,header\n1,2\n"; - await writeFile(file, corrupt); - t.after(() => rm(directory, { recursive: true, force: true })); - - await assert.rejects( - storage.append({ ...validInput, createdAt: "2026-01-01" }), - ); - assert.equal(await readFile(file, "utf8"), corrupt); -}); - -// Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt. -test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => { - const config = []; - const messages = []; - const sendContactForm = createContactFormEmailService( - {}, - (transportConfig) => { - config.push(transportConfig); - return { - async sendMail(message) { - messages.push(message); - return { accepted: [message.to] }; - }, - }; - }, - ); - - await sendContactForm({ - ...validInput, - createdAt: "2026-01-01T00:00:00.000Z", - }); - - assert.equal(config[0].host, "127.0.0.1"); - assert.equal(config[0].port, 1025); - assert.deepEqual( - messages.map((message) => message.to).sort(), - [validInput.email, "ernaehrung@tri-hub.de"].sort(), - ); - assert.ok( - messages.every( - (message) => - message.from === "Tri-Hub Ernährung ", - ), - ); - assert.ok( - messages.every((message) => message.text.includes(validInput.message)), - ); - const request = messages.find( - (message) => message.to === "ernaehrung@tri-hub.de", - ); - assert.equal(request.replyTo, validInput.email); -}); - -// SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden. -test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => { - const sendContactForm = createContactFormEmailService({}, () => ({ - async sendMail(message) { - return { - accepted: message.to === validInput.email ? [message.to] : [], - }; - }, - })); - - await assert.rejects( - sendContactForm(validInput), - (error) => - error.status === 502 && error.code === "CONTACT_EMAIL_FAILED", - ); -}); +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../server/index.js"; +import { createContactFormEmailService } from "../server/services/contact-form-email.js"; +import { createContactFormStorage } from "../server/services/contact-form-storage.js"; + +const validInput = { + consent: true, + name: "Test Person", + email: "person@example.test", + subject: "Eine Frage", + message: "Das ist eine ausreichend lange Nachricht.", +}; + +// Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand. +async function fixture(t, options = {}) { + const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-")); + const storage = + options.storage ?? + createContactFormStorage(join(directory, "contact-requests.csv")); + const sent = []; + const app = createApp({ + sendConfirmation: async () => {}, + sendContact: async () => {}, + contactFormStorage: storage, + sendContactForm: async (data) => sent.push(data), + ...options, + }); + await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + app.closeAllConnections(); + await new Promise((resolve) => app.close(resolve)); + await rm(directory, { recursive: true, force: true }); + }); + + const base = `http://127.0.0.1:${app.address().port}`; + async function post(input = validInput, headers = {}) { + const response = await fetch(`${base}/api/nutrition-contact`, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(input), + }); + return { status: response.status, body: await response.json() }; + } + return { app, base, post, sent, storage, directory }; +} + +// Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit. +test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => { + const f = await fixture(t); + const result = await f.post(); + + assert.equal(result.status, 201); + assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" }); + assert.equal(f.sent.length, 1); + assert.deepEqual( + { + consent: f.sent[0].consent, + name: f.sent[0].name, + email: f.sent[0].email, + subject: f.sent[0].subject, + message: f.sent[0].message, + }, + validInput, + ); + assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt))); + assert.deepEqual(await f.storage.readAll(), f.sent); +}); + +// Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden. +test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => { + const f = await fixture(t); + const invalidInputs = [ + ...[false, "true", undefined].map((consent) => ({ + ...validInput, + consent, + })), + { ...validInput, name: " " }, + { ...validInput, name: "Test\nBcc: fremd" }, + { ...validInput, name: "x".repeat(121) }, + { ...validInput, email: "keine-adresse" }, + { ...validInput, email: "x".repeat(255) }, + { ...validInput, subject: "x".repeat(161) }, + { ...validInput, message: "zu kurz" }, + { ...validInput, message: "x".repeat(5001) }, + { ...validInput, message: "Nachricht\u0000ungültig" }, + { ...validInput, message: undefined }, + null, + [], + ]; + + for (const input of invalidInputs) { + const result = await f.post(input); + assert.equal(result.status, 400, JSON.stringify(input)); + } + assert.deepEqual(await f.storage.readAll(), []); + assert.equal(f.sent.length, 0); +}); + +// Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts. +test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => { + const f = await fixture(t); + assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405); + + const wrongType = await fetch(`${f.base}/api/nutrition-contact`, { + method: "POST", + body: "{}", + }); + assert.equal(wrongType.status, 415); + + const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }); + assert.equal(invalidJson.status, 400); + + const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "x".repeat(9000) }), + }); + assert.equal(tooLarge.status, 413); + assert.deepEqual(await f.storage.readAll(), []); +}); + +// Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden. +test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => { + let sendCount = 0; + const f = await fixture(t, { + storage: { + async append() { + throw new Error("Datenträger nicht verfügbar"); + }, + }, + sendContactForm: async () => sendCount++, + }); + const result = await f.post(); + + assert.equal(result.status, 503); + assert.equal(result.body.error.code, "CONTACT_NOT_SAVED"); + assert.equal(sendCount, 0); +}); + +// Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten. +test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => { + const f = await fixture(t, { + sendContactForm: async () => { + throw new Error("Mailpit nicht erreichbar"); + }, + }); + const result = await f.post(); + + assert.equal(result.status, 502); + assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED"); + assert.equal(result.body.error.message.includes("gespeichert"), true); + assert.equal((await f.storage.readAll()).length, 1); +}); + +// Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen. +test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-")); + const file = join(directory, "contact-requests.csv"); + const storage = createContactFormStorage(file); + t.after(() => rm(directory, { recursive: true, force: true })); + const names = [ + '=HYPERLINK("evil")', + 'Test, "Person"', + "Zeile 1\nZeile 2", + "'Original", + ...Array.from({ length: 8 }, (_, index) => `Person ${index}`), + ]; + + await Promise.all( + names.map((name, index) => + storage.append({ + createdAt: `2026-01-01T00:00:0${index}.000Z`, + name, + email: `person${index}@example.test`, + subject: "CSV Test", + message: "Eine Nachricht mit mehr als zehn Zeichen.", + }), + ), + ); + + const records = await storage.readAll(); + assert.equal(records.length, names.length); + assert.deepEqual( + records.map((record) => record.name).sort(), + [...names].sort(), + ); + const csv = await readFile(file, "utf8"); + assert.match(csv, /'=HYPERLINK/); + assert.match(csv, /"Test, ""Person"""/); +}); + +// Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben. +test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => { + const directory = await mkdtemp( + join(tmpdir(), "trihub-contact-csv-invalid-"), + ); + const file = join(directory, "contact-requests.csv"); + const storage = createContactFormStorage(file); + const corrupt = "wrong,header\n1,2\n"; + await writeFile(file, corrupt); + t.after(() => rm(directory, { recursive: true, force: true })); + + await assert.rejects( + storage.append({ ...validInput, createdAt: "2026-01-01" }), + ); + assert.equal(await readFile(file, "utf8"), corrupt); +}); + +// Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt. +test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => { + const config = []; + const messages = []; + const sendContactForm = createContactFormEmailService( + {}, + (transportConfig) => { + config.push(transportConfig); + return { + async sendMail(message) { + messages.push(message); + return { accepted: [message.to] }; + }, + }; + }, + ); + + await sendContactForm({ + ...validInput, + createdAt: "2026-01-01T00:00:00.000Z", + }); + + assert.equal(config[0].host, "127.0.0.1"); + assert.equal(config[0].port, 1025); + assert.deepEqual( + messages.map((message) => message.to).sort(), + [validInput.email, "ernaehrung@tri-hub.de"].sort(), + ); + assert.ok( + messages.every( + (message) => + message.from === "Tri-Hub Ernährung ", + ), + ); + assert.ok( + messages.every((message) => message.text.includes(validInput.message)), + ); + const request = messages.find( + (message) => message.to === "ernaehrung@tri-hub.de", + ); + assert.equal(request.replyTo, validInput.email); +}); + +// SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden. +test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => { + const sendContactForm = createContactFormEmailService({}, () => ({ + async sendMail(message) { + return { + accepted: message.to === validInput.email ? [message.to] : [], + }; + }, + })); + + await assert.rejects( + sendContactForm(validInput), + (error) => + error.status === 502 && error.code === "CONTACT_EMAIL_FAILED", + ); +}); + +test("Alte Kontakt-CSV bleibt lesbar; neue Zustimmung wird separat gespeichert", async (t) => { + const f = await fixture(t); + await writeFile( + join(f.directory, "contact-requests.csv"), + "createdAt,name,email,subject,message\n2026-01-01,Alt,alt@example.test,Frage,Alte Nachricht\n", + ); + assert.equal((await f.post()).status, 201); + const records = await f.storage.readAll(); + assert.equal(records.length, 2); + assert.notEqual(records[0].consent, true); + assert.equal(records[0].message, "Alte Nachricht"); + assert.equal(records[1].consent, true); +}); diff --git a/src/tests/contact.test.js b/src/tests/contact.test.js index 2ae25f4..25d5c0a 100644 --- a/src/tests/contact.test.js +++ b/src/tests/contact.test.js @@ -1,189 +1,224 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createApp } from "../server/index.js"; -import { createCsvStorage } from "../server/services/csv-storage.js"; -import { createContactEmailService } from "../server/services/contact-email.js"; -import { contactBookingUrl } from "../shared/contact.js"; - -async function fixture(t, options = {}) { - const dir = await mkdtemp(join(tmpdir(), "trihub-contact-")); - const storage = createCsvStorage(join(dir, "bookings.csv")); - await storage.writeAll([ - { - bookingId: "TH-000001", - email: "kunde@example.test", - name: "Testkunde", - }, - ]); - const sent = []; - const app = createApp({ - storage, - sendConfirmation: async () => {}, - sendContact: async (data) => sent.push(data), - ...options, - }); - await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); - t.after(async () => { - app.closeAllConnections(); - await new Promise((resolve) => app.close(resolve)); - await rm(dir, { recursive: true, force: true }); - }); - const base = `http://127.0.0.1:${app.address().port}`; - const post = async (path, input) => { - const response = await fetch(base + path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - return { status: response.status, body: await response.json() }; - }; - return { post, sent, storage, base }; -} -const input = { - email: "kunde@example.test", - advisorId: "relindis-agethen", - reason: "coaching", - simulationAccepted: true, -}; - -test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redirect", async (t) => { - const f = await fixture(t); - const before = await f.storage.readAll(); - const validation = await f.post("/api/contact/validate", { - email: " KUNDE@example.test ", - }); - assert.equal(validation.status, 200); - assert.equal(validation.body.email, input.email); - const result = await f.post("/api/contact", { - ...input, - advisor: "Manipuliert", - advisorEmail: "falsch@example.test", - }); - assert.equal(result.status, 201); - assert.equal(result.body.redirectUrl, contactBookingUrl); - assert.equal(result.body.simulated, true); - assert.equal(f.sent[0].advisor.email, "relindis.agethen@tri-hub.de"); - assert.equal(f.sent[0].advisor.vorname, "Relindis"); - const second = await f.post("/api/contact", { - ...input, - advisorId: "maren-hoffmann", - }); - assert.equal(second.status, 201); - assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de"); - assert.deepEqual(await f.storage.readAll(), before); -}); - -test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => { - const f = await fixture(t); - for (const [data, status] of [ - [null, 400], - [{ ...input, email: "bad" }, 400], - [{ ...input, email: "unknown@example.test" }, 403], - [{ ...input, advisorId: "unbekannt" }, 400], - [{ ...input, advisorId: null }, 400], - [{ ...input, reason: "__proto__" }, 400], - [{ ...input, simulationAccepted: false }, 400], - ]) - assert.equal((await f.post("/api/contact", data)).status, status); - assert.equal(f.sent.length, 0); -}); - -test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => { - const f = await fixture(t); - assert.equal((await f.post("/api/contact/validate", input)).status, 200); - await f.storage.writeAll([]); - assert.equal((await f.post("/api/contact", input)).status, 403); - const broken = await fixture(t, { - storage: { - readAll() { - throw new Error("unavailable"); - }, - }, - }); - assert.equal((await broken.post("/api/contact", input)).status, 503); - assert.equal(broken.sent.length, 0); -}); - -test("HTTP-Vertrag weist falsche Methode, ungültiges JSON und große Requests zurück", async (t) => { - const f = await fixture(t); - assert.equal((await fetch(f.base + "/api/contact")).status, 405); - assert.equal( - (await fetch(f.base + "/api/contact", { method: "POST", body: "x" })) - .status, - 415, - ); - assert.equal( - ( - await fetch(f.base + "/api/contact", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{", - }) - ).status, - 400, - ); - assert.equal( - (await f.post("/api/contact", { email: "x".repeat(9000) })).status, - 413, - ); -}); - -test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", async (t) => { - const messages = []; - const sender = createContactEmailService((config) => { - assert.equal(config.host, "127.0.0.1"); - assert.equal(config.port, 1025); - return { - async sendMail(message) { - messages.push(message); - return { accepted: [message.to] }; - }, - }; - }); - const f = await fixture(t, { sendContact: sender }); - assert.equal((await f.post("/api/contact", input)).status, 201); - assert.deepEqual( - messages.map((m) => m.to), - [input.email, "relindis.agethen@tri-hub.de"], - ); - for (const message of messages) { - assert.match(message.text, /Simulation/); - assert.match(message.text, /Persönliches Coaching/); - } - const failing = createContactEmailService(() => ({ - async sendMail(message) { - return { accepted: message.to === input.email ? [message.to] : [] }; - }, - })); - const failure = await fixture(t, { sendContact: failing }); - const result = await failure.post("/api/contact", input); - assert.equal(result.status, 502); - assert.equal(result.body.redirectUrl, undefined); -}); - -test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async () => { - const { advisorDisplayName } = await import("../shared/berater.js"); - assert.equal( - advisorDisplayName({ - spitzname: " Lilli ", - vorname: "Relindis", - nachname: "Agethen", - }), - "Lilli", - ); - assert.equal( - advisorDisplayName({ - spitzname: " ", - vorname: "Maren", - nachname: "Hoffmann", - }), - "Maren", - ); - assert.equal( - advisorDisplayName({ vorname: "Maren", nachname: "Hoffmann" }), - "Maren", - ); -}); +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../server/index.js"; +import { createContactFormStorage } from "../server/services/contact-form-storage.js"; +import { createCsvStorage } from "../server/services/csv-storage.js"; +import { createContactEmailService } from "../server/services/contact-email.js"; +import { contactBookingUrl } from "../shared/contact.js"; + +async function fixture(t, options = {}) { + const dir = await mkdtemp(join(tmpdir(), "trihub-contact-")); + const storage = createCsvStorage(join(dir, "bookings.csv")); + await storage.writeAll([ + { + bookingId: "TH-000001", + email: "kunde@example.test", + name: "Testkunde", + }, + ]); + const requests = createContactFormStorage( + join(dir, "advisor-contacts.csv"), + [ + "createdAt", + "email", + "advisorId", + "reason", + "simulationAccepted", + "consent", + ], + ); + const sent = []; + const app = createApp({ + storage, + advisorContactStorage: requests, + sendConfirmation: async () => {}, + sendContact: async (data) => sent.push(data), + ...options, + }); + await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + app.closeAllConnections(); + await new Promise((resolve) => app.close(resolve)); + await rm(dir, { recursive: true, force: true }); + }); + const base = `http://127.0.0.1:${app.address().port}`; + const post = async (path, input) => { + const response = await fetch(base + path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + return { status: response.status, body: await response.json() }; + }; + return { post, sent, storage, requests, base }; +} +const input = { + consent: true, + email: "kunde@example.test", + advisorId: "relindis-agethen", + reason: "coaching", + simulationAccepted: true, +}; + +test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redirect", async (t) => { + const f = await fixture(t); + const before = await f.storage.readAll(); + const validation = await f.post("/api/contact/validate", { + email: " KUNDE@example.test ", + }); + assert.equal(validation.status, 200); + assert.equal(validation.body.email, input.email); + const result = await f.post("/api/contact", { + ...input, + advisor: "Manipuliert", + advisorEmail: "falsch@example.test", + }); + assert.equal(result.status, 201); + assert.equal(result.body.redirectUrl, contactBookingUrl); + assert.equal(result.body.simulated, true); + assert.equal(f.sent[0].advisor.email, "relindis.agethen@tri-hub.de"); + assert.equal(f.sent[0].advisor.vorname, "Relindis"); + const second = await f.post("/api/contact", { + ...input, + advisorId: "maren-hoffmann", + }); + assert.equal(second.status, 201); + assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de"); + assert.deepEqual(await f.storage.readAll(), before); + const records = await f.requests.readAll(); + assert.equal(records.length, 2); + assert.equal(records[0].consent, true); + assert.equal(records[0].advisorId, input.advisorId); +}); + +test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => { + const f = await fixture(t); + for (const [data, status] of [ + ...[false, "true", undefined].map((consent) => [ + { ...input, consent }, + 400, + ]), + [null, 400], + [{ ...input, email: "bad" }, 400], + [{ ...input, email: "unknown@example.test" }, 403], + [{ ...input, advisorId: "unbekannt" }, 400], + [{ ...input, advisorId: null }, 400], + [{ ...input, reason: "__proto__" }, 400], + [{ ...input, simulationAccepted: false }, 400], + ]) + assert.equal((await f.post("/api/contact", data)).status, status); + assert.equal(f.sent.length, 0); + assert.deepEqual(await f.requests.readAll(), []); +}); + +test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => { + const f = await fixture(t); + assert.equal((await f.post("/api/contact/validate", input)).status, 200); + await f.storage.writeAll([]); + assert.equal((await f.post("/api/contact", input)).status, 403); + const broken = await fixture(t, { + storage: { + readAll() { + throw new Error("unavailable"); + }, + }, + }); + assert.equal((await broken.post("/api/contact", input)).status, 503); + assert.equal(broken.sent.length, 0); +}); + +test("HTTP-Vertrag weist falsche Methode, ungültiges JSON und große Requests zurück", async (t) => { + const f = await fixture(t); + assert.equal((await fetch(f.base + "/api/contact")).status, 405); + assert.equal( + (await fetch(f.base + "/api/contact", { method: "POST", body: "x" })) + .status, + 415, + ); + assert.equal( + ( + await fetch(f.base + "/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }) + ).status, + 400, + ); + assert.equal( + (await f.post("/api/contact", { email: "x".repeat(9000) })).status, + 413, + ); +}); + +test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", async (t) => { + const messages = []; + const sender = createContactEmailService((config) => { + assert.equal(config.host, "127.0.0.1"); + assert.equal(config.port, 1025); + return { + async sendMail(message) { + messages.push(message); + return { accepted: [message.to] }; + }, + }; + }); + const f = await fixture(t, { sendContact: sender }); + assert.equal((await f.post("/api/contact", input)).status, 201); + assert.deepEqual( + messages.map((m) => m.to), + [input.email, "relindis.agethen@tri-hub.de"], + ); + for (const message of messages) { + assert.match(message.text, /Simulation/); + assert.match(message.text, /Persönliches Coaching/); + } + const failing = createContactEmailService(() => ({ + async sendMail(message) { + return { accepted: message.to === input.email ? [message.to] : [] }; + }, + })); + const failure = await fixture(t, { sendContact: failing }); + const result = await failure.post("/api/contact", input); + assert.equal(result.status, 502); + assert.equal(result.body.redirectUrl, undefined); +}); + +test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async () => { + const { advisorDisplayName } = await import("../shared/berater.js"); + assert.equal( + advisorDisplayName({ + spitzname: " Lilli ", + vorname: "Relindis", + nachname: "Agethen", + }), + "Lilli", + ); + assert.equal( + advisorDisplayName({ + spitzname: " ", + vorname: "Maren", + nachname: "Hoffmann", + }), + "Maren", + ); + assert.equal( + advisorDisplayName({ vorname: "Maren", nachname: "Hoffmann" }), + "Maren", + ); +}); + +test("Berateranfrage wird bei CSV-Schreibfehler nicht versendet", async (t) => { + const f = await fixture(t, { + advisorContactStorage: { + append: async () => { + throw new Error("Disk full"); + }, + }, + }); + assert.equal((await f.post("/api/contact", input)).status, 503); + assert.equal(f.sent.length, 0); +}); diff --git a/src/tests/helpers/browser-server.js b/src/tests/helpers/browser-server.js index 0961e60..ed320ef 100644 --- a/src/tests/helpers/browser-server.js +++ b/src/tests/helpers/browser-server.js @@ -1,24 +1,42 @@ -// Ausschließlich Testserver: temporäre CSV und simulierter Versand. -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createApp } from "../../server/index.js"; -import { createCsvStorage } from "../../server/services/csv-storage.js"; -import { packages } from "../../shared/packages.js"; - -const directory = await mkdtemp(join(tmpdir(), "trihub-browser-")); -const server = createApp({ - catalog: packages, - storage: createCsvStorage(join(directory, "bookings.csv")), - sendConfirmation: async () => {}, - sendContact: async () => {}, -}); -server.listen(3000, "127.0.0.1"); -async function stop() { - server.closeAllConnections(); - await new Promise((resolve) => server.close(resolve)); - await rm(directory, { recursive: true, force: true }); - process.exit(0); -} -process.on("SIGTERM", stop); -process.on("SIGINT", stop); +// Ausschließlich Testserver: temporäre CSV und simulierter Versand. +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../../server/index.js"; +import { createCsvStorage } from "../../server/services/csv-storage.js"; +import { createReviewStorage } from "../../server/services/review-storage.js"; +import { createContactFormStorage } from "../../server/services/contact-form-storage.js"; +import { packages } from "../../shared/packages.js"; + +const directory = await mkdtemp(join(tmpdir(), "trihub-browser-")); +const server = createApp({ + catalog: packages, + storage: createCsvStorage(join(directory, "bookings.csv")), + reviewStorage: createReviewStorage(join(directory, "reviews.csv")), + advisorContactStorage: createContactFormStorage( + join(directory, "advisor-contacts.csv"), + [ + "createdAt", + "email", + "advisorId", + "reason", + "simulationAccepted", + "consent", + ], + ), + contactFormStorage: createContactFormStorage( + join(directory, "contact-requests.csv"), + ), + sendContactForm: async () => {}, + sendConfirmation: async () => {}, + sendContact: async () => {}, +}); +server.listen(Number(process.env.TEST_API_PORT || 3000), "127.0.0.1"); +async function stop() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await rm(directory, { recursive: true, force: true }); + process.exit(0); +} +process.on("SIGTERM", stop); +process.on("SIGINT", stop); diff --git a/src/tests/reviews.test.js b/src/tests/reviews.test.js new file mode 100644 index 0000000..03b9af0 --- /dev/null +++ b/src/tests/reviews.test.js @@ -0,0 +1,134 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../server/index.js"; +import { createCsvStorage } from "../server/services/csv-storage.js"; +import { createReviewStorage } from "../server/services/review-storage.js"; + +const input = { + email: "kunde@example.test", + title: 'Sehr gut, "hilfreich"', + review: 'Gute Beratung, viele Tipps.\nAuch für den "Alltag".', + rating: 5, + consent: true, +}; + +async function setup(t, options = {}) { + const directory = await mkdtemp(join(tmpdir(), "trihub-reviews-")); + const bookingFile = join(directory, "bookings.csv"); + const file = join(directory, "reviews.csv"); + const storage = createCsvStorage(bookingFile); + const reviews = createReviewStorage(file); + await storage.writeAll([{ email: input.email }]); + const app = createApp({ storage, reviewStorage: reviews, ...options }); + await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + app.closeAllConnections(); + await new Promise((resolve) => app.close(resolve)); + await rm(directory, { recursive: true, force: true }); + }); + const url = `http://127.0.0.1:${app.address().port}`; + const post = (body = input) => + fetch(`${url}/api/reviews`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return { post, reviews, file, bookingFile, url }; +} + +test("Kundenbewertung wird dauerhaft als CSV gespeichert; Buchungen bleiben unverändert", async (t) => { + const { post, file, bookingFile } = await setup(t); + const before = await readFile(bookingFile, "utf8"); + const response = await post({ ...input, email: " KUNDE@EXAMPLE.TEST " }); + assert.equal(response.status, 201); + assert.deepEqual(await response.json(), { saved: true }); + const rows = await createReviewStorage(file).readAll(); + assert.equal(rows.length, 1); + assert.deepEqual(rows[0], { + createdAt: rows[0].createdAt, + email: input.email, + title: input.title, + review: input.review, + rating: "5", + consent: "true", + }); + assert.ok(!Number.isNaN(Date.parse(rows[0].createdAt))); + assert.equal(await readFile(bookingFile, "utf8"), before); +}); + +test("Unbekannte E-Mail, ungültige Felder und fehlende Zustimmung speichern nichts", async (t) => { + const { post, reviews } = await setup(t); + assert.equal( + (await post({ ...input, email: "fremd@example.test" })).status, + 403, + ); + for (const body of [ + null, + [], + {}, + ...["", "ungueltig", 123].map((email) => ({ ...input, email })), + ...["", "a".repeat(9), "a".repeat(51), " ".repeat(10), 123].map( + (title) => ({ ...input, title }), + ), + ...["", "a".repeat(9), "a".repeat(501), " ".repeat(10), 123].map( + (review) => ({ ...input, review }), + ), + ...[0, 6, 1.5, "5", null].map((rating) => ({ ...input, rating })), + ...[false, "true", undefined].map((consent) => ({ ...input, consent })), + ]) { + assert.equal((await post(body)).status, 400, JSON.stringify(body)); + } + assert.deepEqual(await reviews.readAll(), []); +}); + +test("Grenzwerte und alle Sterne sind erlaubt; paralleles Speichern verliert keine Bewertung", async (t) => { + const { post, reviews } = await setup(t); + const responses = await Promise.all( + [1, 2, 3, 4, 5].map((rating) => + post({ + ...input, + rating, + title: "t".repeat(rating === 1 ? 10 : 50), + review: "r".repeat(rating === 1 ? 10 : 500), + }), + ), + ); + assert.ok(responses.every((response) => response.status === 201)); + const rows = await reviews.readAll(); + assert.equal(rows.length, 5); + assert.deepEqual( + rows.map((row) => Number(row.rating)).sort(), + [1, 2, 3, 4, 5], + ); +}); + +test("Speicherfehler bestätigt keinen Erfolg; beschädigte CSV bleibt erhalten", async (t) => { + const { post, file } = await setup(t); + await writeFile(file, "falscher,header\n1,2\n"); + assert.equal((await post()).status, 503); + assert.equal(await readFile(file, "utf8"), "falscher,header\n1,2\n"); + const failing = await setup(t, { + reviewStorage: { + append: async () => { + throw new Error("Disk full"); + }, + }, + }); + assert.equal((await failing.post()).status, 503); +}); + +test("CSV maskiert Tabellenformeln und bleibt über HTTP privat", async (t) => { + const { post, reviews, file, url } = await setup(t); + const title = "=SUM(1,2,3)"; + assert.equal((await post({ ...input, title })).status, 201); + assert.equal((await reviews.readAll())[0].title, title); + assert.ok((await readFile(file, "utf8")).includes("'=SUM")); + assert.equal((await fetch(`${url}/api/reviews`)).status, 405); + assert.equal( + (await fetch(`${url}/src/server/data/reviews.csv`)).status, + 404, + ); +});