From daacf4b3938855ab2306231448c51fbb91125a5e Mon Sep 17 00:00:00 2001 From: Marvin Gemlin Date: Wed, 30 Sep 2026 12:41:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Rezensionen=20speichern=20und=20Datensc?= =?UTF-8?q?hutz=20in=20allen=20Formularen=20best=C3=A4tigen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 106 +++++++++++----- src/features/booking/booking-page.js | 13 +- src/features/booking/booking.css | 22 ++++ src/features/contact/contact-dialog.js | 3 + src/features/contact/contact-form.js | 5 +- src/features/contact/contact.css | 5 + src/features/reviews/review-form.js | 48 +++++++ src/server/data/bookings.csv | 20 +-- src/server/data/contact-requests.csv | 6 +- src/server/index.js | 20 +++ src/server/routes/reviews.js | 96 ++++++++++++++ src/server/services/booking-service.js | 5 +- src/server/services/contact-form-service.js | 4 +- src/server/services/contact-form-storage.js | 21 ++- src/server/services/contact-service.js | 25 +++- src/server/services/csv-storage.js | 6 +- src/server/services/review-storage.js | 93 ++++++++++++++ src/server/swagger.json | 14 +- src/tests/bookings.test.js | 5 + src/tests/browser/booking.spec.js | 104 +++++++++------ src/tests/browser/contact.spec.js | 13 +- src/tests/browser/privacy.spec.js | 110 ++++++++++++++++ src/tests/browser/reviews.spec.js | 51 ++++++++ src/tests/contact-form.test.js | 20 +++ src/tests/contact.test.js | 37 +++++- src/tests/helpers/browser-server.js | 20 ++- src/tests/reviews.test.js | 134 ++++++++++++++++++++ 27 files changed, 899 insertions(+), 107 deletions(-) create mode 100644 src/features/reviews/review-form.js create mode 100644 src/server/routes/reviews.js create mode 100644 src/server/services/review-storage.js create mode 100644 src/tests/browser/privacy.spec.js create mode 100644 src/tests/browser/reviews.spec.js create mode 100644 src/tests/reviews.test.js diff --git a/index.html b/index.html index b658775..0df33c9 100644 --- a/index.html +++ b/index.html @@ -547,6 +547,11 @@ font-size: 0.78rem; } + .consent a { + font-weight: 700; + text-decoration: underline; + } + .consent input { margin-top: 4px; accent-color: var(--brand); @@ -994,10 +999,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 +1031,36 @@
- +
- + + +
+ +
+
- + @@ -1100,12 +1125,22 @@ + *Pflichtfelder + @@ -1217,6 +1252,24 @@ *Pflichtfelder
+ +

+ @@ -1250,24 +1303,9 @@ type="module" src="/src/features/contact/contact-form.js" > - + 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 7297d73..e1b9b88 100644 --- a/src/features/contact/contact-dialog.js +++ b/src/features/contact/contact-dialog.js @@ -30,6 +30,7 @@ export function openContactDialog({
+

`; @@ -57,6 +58,7 @@ export function openContactDialog({ 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) { @@ -82,6 +84,7 @@ export function openContactDialog({ email: email.value, advisorId, reason: reason.value, + consent: form.elements.consent.checked, simulationAccepted: form.elements.simulationAccepted.checked, }; busy = true; diff --git a/src/features/contact/contact-form.js b/src/features/contact/contact-form.js index 544d19b..4c1f98e 100644 --- a/src/features/contact/contact-form.js +++ b/src/features/contact/contact-form.js @@ -44,7 +44,10 @@ form?.addEventListener("submit", async (event) => { const response = await fetch("/api/nutrition-contact", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(Object.fromEntries(new FormData(form))), + body: JSON.stringify({ + ...Object.fromEntries(new FormData(form)), + consent: form.elements.consent.checked, + }), }); const result = await response.json(); if (!response.ok) { diff --git a/src/features/contact/contact.css b/src/features/contact/contact.css index fd7a660..0458a73 100644 --- a/src/features/contact/contact.css +++ b/src/features/contact/contact.css @@ -148,3 +148,8 @@ .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 e21556d..5149311 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1,3 +1,5 @@ +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"; @@ -21,6 +23,9 @@ export function createApp({ 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, @@ -28,6 +33,17 @@ export function createApp({ 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) { @@ -56,6 +72,7 @@ export function createApp({ }); const contact = createContactService({ storage, + requestStorage: advisorContactStorage, sendContact: sendContact ?? createContactEmailService(), }); // Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand. @@ -65,6 +82,9 @@ export function createApp({ }); 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); 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 4d04ec0..dc70213 100644 --- a/src/server/services/booking-service.js +++ b/src/server/services/booking-service.js @@ -82,7 +82,9 @@ function validateInput(input, key) { }; } const fields = {}; - const result = {}; + 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."], @@ -315,6 +317,7 @@ export function createBookingService({ bookedPackage, customer: data.customer ?? null, acceptedTerms: data.acceptedTerms ?? null, + consent: data.consent, name: data.name, email: data.email, phone: data.customer?.phone ?? data.phone ?? "", diff --git a/src/server/services/contact-form-service.js b/src/server/services/contact-form-service.js index 3013065..bd614f7 100644 --- a/src/server/services/contact-form-service.js +++ b/src/server/services/contact-form-service.js @@ -13,7 +13,9 @@ function normalizeInput(input) { // 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 = {}; + 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") { diff --git a/src/server/services/contact-form-storage.js b/src/server/services/contact-form-storage.js index 03e19fc..7728462 100644 --- a/src/server/services/contact-form-storage.js +++ b/src/server/services/contact-form-storage.js @@ -5,7 +5,14 @@ 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"]; +const defaultColumns = [ + "createdAt", + "name", + "email", + "subject", + "message", + "consent", +]; // Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird. function protect(value) { @@ -19,7 +26,7 @@ function restore(value) { } // Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange. -export function createContactFormStorage(filePath) { +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. @@ -35,7 +42,13 @@ export function createContactFormStorage(filePath) { return parse(content, { bom: true, columns(header) { - if (header.join(",") !== columns.join(",")) { + if ( + header.join(",") !== columns.join(",") && + header.join(",") !== + columns + .filter((column) => column !== "consent") + .join(",") + ) { throw new Error("Unbekanntes Kontakt-CSV-Format"); } return header; @@ -45,7 +58,7 @@ export function createContactFormStorage(filePath) { Object.fromEntries( Object.entries(row).map(([key, value]) => [ key, - restore(value), + key === "consent" ? value === "true" : restore(value), ]), ), ); diff --git a/src/server/services/contact-service.js b/src/server/services/contact-service.js index 6ba349f..1f4692a 100644 --- a/src/server/services/contact-service.js +++ b/src/server/services/contact-service.js @@ -29,7 +29,7 @@ function advisorDetails(advisorId) { return { ...advisor }; } -export function createContactService({ storage, sendContact }) { +export function createContactService({ storage, requestStorage, sendContact }) { async function validate(input) { if ( !input || @@ -93,6 +93,29 @@ export function createContactService({ storage, sendContact }) { "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, 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 a380052..e1dd349 100644 --- a/src/server/swagger.json +++ b/src/server/swagger.json @@ -359,7 +359,8 @@ "email", "advisorId", "reason", - "simulationAccepted" + "simulationAccepted", + "consent" ], "properties": { "email": { @@ -387,6 +388,10 @@ "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] } } } @@ -809,7 +814,7 @@ }, "BookingCreateRequest": { "type": "object", - "required": ["packageId"], + "required": ["packageId", "consent"], "properties": { "packageId": { "type": "string", @@ -875,6 +880,11 @@ "type": "boolean", "example": true, "enum": [true] + }, + "consent": { + "type": "boolean", + "enum": [true], + "description": "Zustimmung zu den Datenschutzbestimmungen ist erforderlich." } }, "anyOf": [ 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 abc6203..a8fa98d 100644 --- a/src/tests/browser/contact.spec.js +++ b/src/tests/browser/contact.spec.js @@ -16,7 +16,12 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter", const email = `kontakt-${randomUUID()}@example.test`; const booking = await request.post("/api/bookings", { headers: { "Idempotency-Key": randomUUID() }, - data: { packageId: "ernaehrung-starter", name: "Testkunde", email }, + data: { + consent: true, + packageId: "ernaehrung-starter", + name: "Testkunde", + email, + }, }); expect(booking.ok()).toBeTruthy(); await open(page); @@ -30,7 +35,8 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter", .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 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(); @@ -76,7 +82,8 @@ test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => { 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.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", 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 c1dd4a8..56c3126 100644 --- a/src/tests/contact-form.test.js +++ b/src/tests/contact-form.test.js @@ -8,6 +8,7 @@ import { createContactFormEmailService } from "../server/services/contact-form-e import { createContactFormStorage } from "../server/services/contact-form-storage.js"; const validInput = { + consent: true, name: "Test Person", email: "person@example.test", subject: "Eine Frage", @@ -57,6 +58,7 @@ test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", asyn 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, @@ -72,6 +74,10 @@ test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", asyn 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) }, @@ -267,3 +273,17 @@ test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => { 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 ee6bc34..25d5c0a 100644 --- a/src/tests/contact.test.js +++ b/src/tests/contact.test.js @@ -4,6 +4,7 @@ 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"; @@ -18,9 +19,21 @@ async function fixture(t, options = {}) { 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, @@ -40,9 +53,10 @@ async function fixture(t, options = {}) { }); return { status: response.status, body: await response.json() }; }; - return { post, sent, storage, base }; + return { post, sent, storage, requests, base }; } const input = { + consent: true, email: "kunde@example.test", advisorId: "relindis-agethen", reason: "coaching", @@ -74,11 +88,19 @@ test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redir 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], @@ -89,6 +111,7 @@ test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlen ]) 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) => { @@ -187,3 +210,15 @@ test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async () "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 e9805ce..ed320ef 100644 --- a/src/tests/helpers/browser-server.js +++ b/src/tests/helpers/browser-server.js @@ -4,16 +4,34 @@ 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(3000, "127.0.0.1"); +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)); 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, + ); +});