feat: Rezensionen speichern und Datenschutz in allen Formularen bestätigen
parent
92a9ee7c6c
commit
daacf4b393
106
index.html
106
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 @@
|
|||
</article>
|
||||
|
||||
<p class="review-note">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1027,19 +1031,36 @@
|
|||
|
||||
<form class="review-form" id="reviewForm">
|
||||
<div class="field" style="margin-top: 0">
|
||||
<label for="name">Dein Name</label>
|
||||
<label for="reviewEmail"
|
||||
>E-Mail deiner Buchung*</label
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
placeholder="z. B. Thomas M."
|
||||
id="reviewEmail"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
maxlength="254"
|
||||
placeholder="z. B. thomas@example.de"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Deine Bewertung</label>
|
||||
<label for="reviewTitle"
|
||||
>Titel (10–50 Zeichen)*</label
|
||||
>
|
||||
<input
|
||||
id="reviewTitle"
|
||||
name="title"
|
||||
type="text"
|
||||
minlength="10"
|
||||
maxlength="50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Deine Bewertung*</label>
|
||||
<div
|
||||
class="rating-input"
|
||||
aria-label="Sternebewertung"
|
||||
|
|
@ -1088,10 +1109,14 @@
|
|||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="review">Deine Erfahrung</label>
|
||||
<label for="review"
|
||||
>Deine Erfahrung (10–500 Zeichen)*</label
|
||||
>
|
||||
<textarea
|
||||
id="review"
|
||||
name="review"
|
||||
minlength="10"
|
||||
maxlength="500"
|
||||
placeholder="Was hat dir an der Ernährungsberatung geholfen?"
|
||||
required
|
||||
></textarea>
|
||||
|
|
@ -1100,12 +1125,22 @@
|
|||
<label class="consent">
|
||||
<input type="checkbox" name="consent" required />
|
||||
<span>
|
||||
Ich stimme zu, dass meine Bewertung mit meinem
|
||||
angegebenen Namen auf dieser Seite
|
||||
veröffentlicht werden darf.
|
||||
Ich akzeptiere die
|
||||
<a
|
||||
href="https://www.tri-hub.de/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Datenschutzbestimmungen</a
|
||||
>*: Meine E-Mail-Adresse wird zum Abgleich mit
|
||||
meiner Buchung und zusammen mit meiner Bewertung
|
||||
gespeichert. Titel, Bewertungstext und Sterne
|
||||
dürfen veröffentlicht werden; meine
|
||||
E-Mail-Adresse bleibt nicht öffentlich.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<small>*Pflichtfelder</small>
|
||||
|
||||
<button class="button button-primary" type="submit">
|
||||
Bewertung veröffentlichen
|
||||
</button>
|
||||
|
|
@ -1217,6 +1252,24 @@
|
|||
<small>*Pflichtfelder</small>
|
||||
</div>
|
||||
|
||||
<label class="consent"
|
||||
><input
|
||||
type="checkbox"
|
||||
name="consent"
|
||||
required
|
||||
/><span
|
||||
>Ich akzeptiere die
|
||||
<a
|
||||
href="https://www.tri-hub.de/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Datenschutzbestimmungen</a
|
||||
>
|
||||
(Pflichtfeld).</span
|
||||
></label
|
||||
>
|
||||
<p data-error-for="consent" role="alert"></p>
|
||||
|
||||
<button class="button button-primary" type="submit">
|
||||
Nachricht senden
|
||||
</button>
|
||||
|
|
@ -1250,24 +1303,9 @@
|
|||
type="module"
|
||||
src="/src/features/contact/contact-form.js"
|
||||
></script>
|
||||
<script>
|
||||
const form = document.getElementById("reviewForm");
|
||||
const status = document.getElementById("formStatus");
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = form.elements.name.value.trim();
|
||||
const rating = form.elements.rating.value;
|
||||
const review = form.elements.review.value.trim();
|
||||
|
||||
if (!name || !rating || !review) return;
|
||||
|
||||
status.textContent =
|
||||
"Danke für deine Bewertung. In dieser Demo wurde sie noch nicht dauerhaft gespeichert.";
|
||||
|
||||
form.reset();
|
||||
});
|
||||
</script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/reviews/review-form.js"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ export function mountBookingPage(
|
|||
<input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-error" />
|
||||
<p class="booking__field-error" id="${instanceId}-booking-phone-error"></p>
|
||||
</div>
|
||||
<label class="booking__consent"><input name="consent" type="checkbox" required aria-describedby="${instanceId}-booking-consent-error" /> <span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
|
||||
<p class="booking__field-error" id="${instanceId}-booking-consent-error"></p>
|
||||
<div class="booking__actions">
|
||||
<button class="booking__button booking__cancel" type="button">Abbrechen</button>
|
||||
<button class="booking__button" type="submit">Paket buchen</button>
|
||||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function openContactDialog({
|
|||
<select id="contact-reason" name="reason" required><option value="">Bitte auswählen</option></select>
|
||||
</div>
|
||||
<label class="contact-consent"><input name="simulationAccepted" type="checkbox" required><span></span></label>
|
||||
<label class="contact-consent"><input name="consent" type="checkbox" required><span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
|
||||
<p class="contact-status" role="status" aria-live="polite"></p>
|
||||
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
|
||||
</form>`;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -148,3 +148,8 @@
|
|||
.contact-status a {
|
||||
color: var(--contact-accent);
|
||||
}
|
||||
|
||||
.contact-consent a {
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?? "",
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(",")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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("");
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue