diff --git a/.gitignore b/.gitignore index b4f54fd..03ec7af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,15 @@ -node_modules/ -dist/ -.env -.env.* -!.env.example -.DS_Store -Thumbs.db -*.log - -# Private Buchungsdaten und lokale Testergebnisse -src/server/data/* -!src/server/data/.gitkeep -!src/server/data/contact-requests.csv -playwright-report/ -test-results/ +node_modules/ +dist/ +.env +.env.* +!.env.example +.DS_Store +Thumbs.db +*.log + +# Private Buchungsdaten und lokale Testergebnisse +src/server/data/* +!src/server/data/.gitkeep +!src/server/data/contact-requests.csv +playwright-report/ +test-results/ diff --git a/FAQ.html b/FAQ.html index e9facd3..5d43eb3 100644 --- a/FAQ.html +++ b/FAQ.html @@ -23,6 +23,15 @@ summary::-webkit-details-marker { display: none; } + :root { + --eyebrow-color: #53d5cd; + } + .eyebrow { + color: var(--eyebrow-color); + } + li::marker { + color: var(--eyebrow-color); + } + diff --git a/README.md b/README.md index 70a0012..dea3c78 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,65 @@ -# Tri-Hub Ernährungsberatung - -## Im Dev Container starten - -1. Repository klonen und in VS Code „Dev Containers: Reopen in Container“ wählen. - Bei einem bereits vorhandenen Container einmal „Dev Containers: Rebuild Container“ - ausführen, damit Mailpit installiert wird. -2. Die Einrichtung installiert automatisch die npm-Abhängigkeiten und erstellt - `.env` aus `.env.example`, sofern noch keine `.env` vorhanden ist. -3. In drei getrennten Terminals in dieser Reihenfolge starten: - -```bash -npm run dev:mail -``` - -```bash -npm run dev:server -``` - -```bash -npm run dev -``` - -Startseite: http://localhost:5173 — über „Beratung kennenlernen“ zur -Angebotsübersicht und von dort zum Buchungsformular des gewählten Pakets. -Direkt: http://localhost:5173/angebotsuebersicht.html -Mailansicht: http://localhost:8025. Beide Web-Ports werden vom Dev Container -weitergeleitet. Mailpit läuft im selben Container auf SMTP-Port 1025 und fängt -Testmails lokal ab, ohne sie extern zu versenden. - -Die Container-Konfiguration installiert Mailpit v1.31.2 aus dem -[offiziellen Docker-Image](https://mailpit.axllent.org/docs/install/docker/). -Die Binärdatei, lokale Mails, `.env` und Buchungsdaten werden nicht committed. -Mailpit verwendet ohne Datenbankpfad einen temporären Speicher; Testmails müssen -nach einem Neustart nicht erhalten bleiben. - -Ohne Dev Container: Node.js 24 und Mailpit installieren, `npm ci` ausführen und -`.env.example` nach `.env` kopieren, falls `.env` fehlt. Danach dieselben drei -Startbefehle verwenden. - -Ohne Mailpit wird die Buchung gespeichert, aber der fehlgeschlagene Mailversand -angezeigt. Der Ablauf bleibt eine Simulation ohne Zahlung oder verbindliche -Beratung. Buchungen liegen lokal in `src/server/data/bookings.csv`. Diese Datei -nicht vorab leer anlegen: Das Backend erzeugt sie bei der ersten Buchung selbst. - -## Beraterkontakt testen - -Die [Kontakt-Testseite](http://localhost:5173/contact-test.html?berater-id=relindis-agethen) -öffnet das neue Popup. Kundenprüfung, Mailpit-Versand, Profil-Einbindung und -Checkliste sind in [docs/contact.md](docs/contact.md) dokumentiert. - -## Prüfen - -```bash -npm test -npm run build -npm run test:browser -``` - -Die Browsertests starten eigene Server auf 3000 und 5173; diese Ports vorher -freihalten. Sie verwenden eine temporäre CSV und simulierten Mailversand. -Falls Chromium fehlt: `npx playwright install chromium`. - -Der Build enthält Startseite, Angebotsübersicht und Buchungsseite. Für ein -Deployment zusätzlich das Node-Backend betreiben und `/api` dorthin weiterleiten. +# Tri-Hub Ernährungsberatung + +## Im Dev Container starten + +1. Repository klonen und in VS Code „Dev Containers: Reopen in Container“ wählen. + Bei einem bereits vorhandenen Container einmal „Dev Containers: Rebuild Container“ + ausführen, damit Mailpit installiert wird. +2. Die Einrichtung installiert automatisch die npm-Abhängigkeiten und erstellt + `.env` aus `.env.example`, sofern noch keine `.env` vorhanden ist. +3. In drei getrennten Terminals in dieser Reihenfolge starten: + +```bash +npm run dev:mail +``` + +```bash +npm run dev:server +``` + +```bash +npm run dev +``` + +Startseite: http://localhost:5173 — über „Beratung kennenlernen“ zur +Angebotsübersicht und von dort zum Buchungsformular des gewählten Pakets. +Direkt: http://localhost:5173/angebotsuebersicht.html +Mailansicht: http://localhost:8025. Beide Web-Ports werden vom Dev Container +weitergeleitet. Mailpit läuft im selben Container auf SMTP-Port 1025 und fängt +Testmails lokal ab, ohne sie extern zu versenden. + +Die Container-Konfiguration installiert Mailpit v1.31.2 aus dem +[offiziellen Docker-Image](https://mailpit.axllent.org/docs/install/docker/). +Die Binärdatei, lokale Mails, `.env` und Buchungsdaten werden nicht committed. +Mailpit verwendet ohne Datenbankpfad einen temporären Speicher; Testmails müssen +nach einem Neustart nicht erhalten bleiben. + +Ohne Dev Container: Node.js 24 und Mailpit installieren, `npm ci` ausführen und +`.env.example` nach `.env` kopieren, falls `.env` fehlt. Danach dieselben drei +Startbefehle verwenden. + +Ohne Mailpit wird die Buchung gespeichert, aber der fehlgeschlagene Mailversand +angezeigt. Der Ablauf bleibt eine Simulation ohne Zahlung oder verbindliche +Beratung. Buchungen liegen lokal in `src/server/data/bookings.csv`. Diese Datei +nicht vorab leer anlegen: Das Backend erzeugt sie bei der ersten Buchung selbst. + +## Beraterkontakt testen + +Die [Kontakt-Testseite](http://localhost:5173/contact-test.html?berater-id=relindis-agethen) +öffnet das neue Popup. Kundenprüfung, Mailpit-Versand, Profil-Einbindung und +Checkliste sind in [docs/contact.md](docs/contact.md) dokumentiert. + +## Prüfen + +```bash +npm test +npm run build +npm run test:browser +``` + +Die Browsertests starten eigene Server auf 3000 und 5173; diese Ports vorher +freihalten. Sie verwenden eine temporäre CSV und simulierten Mailversand. +Falls Chromium fehlt: `npx playwright install chromium`. + +Der Build enthält Startseite, Angebotsübersicht und Buchungsseite. Für ein +Deployment zusätzlich das Node-Backend betreiben und `/api` dorthin weiterleiten. diff --git a/berater.html b/berater.html index 722f850..f0f91c7 100644 --- a/berater.html +++ b/berater.html @@ -1,31 +1,31 @@ - - - - - - - Beraterprofil · Tri-Hub - - - - - - -
-
-
-
-

PERSÖNLICH FÜR DICH DA

-

Lerne deine Ernährungsberater kennen.

-

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

-
-
-

Beraterprofile werden geladen …

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

PERSÖNLICH FÜR DICH DA

+

Lerne deine Ernährungsberater kennen.

+

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

+
+
+

Beraterprofile werden geladen …

+
+
+
+
+ + + + diff --git a/contact-test.html b/contact-test.html index 9705d86..cd09112 100644 --- a/contact-test.html +++ b/contact-test.html @@ -1,26 +1,26 @@ - - - - - - Berater kontaktieren – Tri-Hub Testseite - - -
-

TRI-HUB · KONTAKT-DEMO

-

Dein direkter Draht zur Beratung

-

- -

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

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

TRI-HUB · KONTAKT-DEMO

+

Dein direkter Draht zur Beratung

+

+ +

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

+
+ + + diff --git a/docs/berater-tests.md b/docs/berater-tests.md index e3b1d5a..88c2e20 100644 --- a/docs/berater-tests.md +++ b/docs/berater-tests.md @@ -1,36 +1,36 @@ -# Beraterprofile und Bildergalerie testen - -Die Berater-Tests laufen auf dem Branch `feature/beraterbilder`. Dieser enthält auch die Profiländerungen aus `feature/beraterprofile`. - -## Node-Tests - -```sh -npm test -``` - -Die Node-Suite prüft die Beraterdaten, eindeutige IDs, Kontaktanzeigenamen, vorhandene Porträt- und Aktionsbilder sowie den Navigationslink zur eigenständigen Profilseite. - -## Browser-Tests - -```sh -npm run test:browser -- src/tests/browser/berater.spec.js -``` - -Die Browser-Tests prüfen die Profilkarten, den Kontakt-Dialog, die Bildgröße und das manuelle Wechseln der Galerie mit Pfeilen und Positionspunkten. Playwright startet dafür standardmäßig die Testserver auf den Ports 3000 und 5173. Falls diese Ports bereits von den lokalen Projektservern belegt sind, kann Playwright sie wiederverwenden: - -```sh -PW_REUSE_EXISTING=1 npm run test:browser -- src/tests/browser/berater.spec.js -``` - -PowerShell: - -```powershell -$env:PW_REUSE_EXISTING = "1" -npm run test:browser -- src/tests/browser/berater.spec.js -``` - -Beim ersten Browser-Testlauf kann Playwright Chromium benötigen: - -```sh -npx playwright install chromium -``` +# Beraterprofile und Bildergalerie testen + +Die Berater-Tests laufen auf dem Branch `feature/beraterbilder`. Dieser enthält auch die Profiländerungen aus `feature/beraterprofile`. + +## Node-Tests + +```sh +npm test +``` + +Die Node-Suite prüft die Beraterdaten, eindeutige IDs, Kontaktanzeigenamen, vorhandene Porträt- und Aktionsbilder sowie den Navigationslink zur eigenständigen Profilseite. + +## Browser-Tests + +```sh +npm run test:browser -- src/tests/browser/berater.spec.js +``` + +Die Browser-Tests prüfen die Profilkarten, den Kontakt-Dialog, die Bildgröße und das manuelle Wechseln der Galerie mit Pfeilen und Positionspunkten. Playwright startet dafür standardmäßig die Testserver auf den Ports 3000 und 5173. Falls diese Ports bereits von den lokalen Projektservern belegt sind, kann Playwright sie wiederverwenden: + +```sh +PW_REUSE_EXISTING=1 npm run test:browser -- src/tests/browser/berater.spec.js +``` + +PowerShell: + +```powershell +$env:PW_REUSE_EXISTING = "1" +npm run test:browser -- src/tests/browser/berater.spec.js +``` + +Beim ersten Browser-Testlauf kann Playwright Chromium benötigen: + +```sh +npx playwright install chromium +``` diff --git a/docs/contact-integration.md b/docs/contact-integration.md index 8d04360..63d09ad 100644 --- a/docs/contact-integration.md +++ b/docs/contact-integration.md @@ -1,103 +1,103 @@ -# Übergabe an die KI der Beraterprofilseite - -Binde das vorhandene Kontakt-Popup in die Kontaktbuttons der Beraterprofile -ein. Übergib ausschließlich die Berater-ID als `advisorId`. Implementiere -kein eigenes Kontaktformular und keine separate E-Mail-Prüfung. - -## Relevante Dateien - -- `src/shared/berater-daten.json`: gemeinsame Datenquelle für Profile und Kontakt; - Array mit eindeutigen String-IDs, `vorname`, `nachname`, optionalem `spitzname` und `email`. Eigene öffentliche Profilfelder dürfen ergänzt werden. -- `src/shared/berater.js`: `findAdvisor(advisorId)` liefert den passenden Eintrag. -- `src/features/contact/contact-dialog.js`: exportiert `openContactDialog({ advisorId })`. -- `src/features/contact/contact.css`: wird automatisch vom Popup-Modul importiert; - die Schriftdateien liegen unter `public/fonts/`. -- `src/features/contact/contact-entry.js` und `contact-test.html`: Beispielintegration. -- `src/server/services/contact-service.js`: prüft Kunden-E-Mail und Berater-ID, - lädt die Empfängeradresse serverseitig aus der JSON. -- `src/server/swagger.json`: API-Vertrag für `POST /api/contact`. - -## Datenvertrag - -```json -[ - { - "id": "relindis-agethen", - "vorname": "Relindis", - "nachname": "Agethen", - "spitzname": "Lilli", - "email": "relindis.agethen@tri-hub.de" - } -] -``` - -Die Profilseite und das Popup müssen dieselben IDs verwenden. E-Mail-Adressen -werden explizit gepflegt und nicht aus Namen erzeugt. Sie müssen auf `@tri-hub.de` -enden. Keine vertraulichen Informationen in die JSON schreiben: Sie ist Teil -des Frontends. Nach Änderungen API neu starten und Frontend neu bauen. - -## Einzubindender Code - -Beispielbutton (ID aus dem jeweiligen Profil): - -```html - -``` - -Im JavaScript-Modul der Profilseite, einmalig registrieren: - -```js -// Importpfad relativ zu dieser Datei anpassen. -import { openContactDialog } from "./src/features/contact/contact-dialog.js"; - -document.addEventListener("click", (event) => { - const button = event.target.closest("button[data-berater-id]"); - if (!button) return; - openContactDialog({ advisorId: button.dataset.beraterId }); -}); -``` - -Bei dynamischer Erzeugung des Buttons die ID aus dem Profil setzen: - -```js -button.dataset.beraterId = profile.id; -``` - -Alternativ direkt am einzelnen Button binden (nicht zusätzlich zur Delegation): - -```js -button.addEventListener("click", () => { - openContactDialog({ advisorId: profile.id }); -}); -``` - -Falls die Profilseite selbst Daten aus der gemeinsamen Datei benötigt: - -```js -// Beispiel für ein Modul direkt unter src/; Pfad ggf. anpassen. -import berater from "./shared/berater-daten.json" with { type: "json" }; - -const profile = berater.find((entry) => entry.id === advisorId); -// profile.vorname, profile.nachname, profile.spitzname und profile.email sind verfügbar. -``` - -Das Popup zeigt den getrimmten `spitzname`, falls nicht leer, sonst den -getrimmten `vorname`. `nachname` und das bisherige Feld `name` werden für die -Überschrift nicht verwendet. Es wird kein „kontaktieren“ angehängt. - -Das Popup ermittelt den Anzeigenamen selbst und sendet beim Absenden -`{ email, advisorId, reason, simulationAccepted }` an `/api/contact`. Die -Profilseite muss weder Name noch E-Mail an das Popup schicken. Kundenprüfung, -Simulation, Mailpit-Versand und Microsoft-Bookings-Weiterleitung sind bereits -implementiert. Unbekannte IDs sperren das Absenden. - -## Testen - -Mit laufendem Frontend, API und Mailpit: -`http://localhost:5173/contact-test.html?berater-id=relindis-agethen`. -Für die Kundenadresse eine vorhandene Testbuchung verwenden. Die Prüfung -findet ausschließlich beim Absenden statt. Der Branch ist -`feature/terminbuchung-kontaktfeld`; die Kontaktdateien müssen im Arbeitsstand -der Profilseite vorhanden sein. +# Übergabe an die KI der Beraterprofilseite + +Binde das vorhandene Kontakt-Popup in die Kontaktbuttons der Beraterprofile +ein. Übergib ausschließlich die Berater-ID als `advisorId`. Implementiere +kein eigenes Kontaktformular und keine separate E-Mail-Prüfung. + +## Relevante Dateien + +- `src/shared/berater-daten.json`: gemeinsame Datenquelle für Profile und Kontakt; + Array mit eindeutigen String-IDs, `vorname`, `nachname`, optionalem `spitzname` und `email`. Eigene öffentliche Profilfelder dürfen ergänzt werden. +- `src/shared/berater.js`: `findAdvisor(advisorId)` liefert den passenden Eintrag. +- `src/features/contact/contact-dialog.js`: exportiert `openContactDialog({ advisorId })`. +- `src/features/contact/contact.css`: wird automatisch vom Popup-Modul importiert; + die Schriftdateien liegen unter `public/fonts/`. +- `src/features/contact/contact-entry.js` und `contact-test.html`: Beispielintegration. +- `src/server/services/contact-service.js`: prüft Kunden-E-Mail und Berater-ID, + lädt die Empfängeradresse serverseitig aus der JSON. +- `src/server/swagger.json`: API-Vertrag für `POST /api/contact`. + +## Datenvertrag + +```json +[ + { + "id": "relindis-agethen", + "vorname": "Relindis", + "nachname": "Agethen", + "spitzname": "Lilli", + "email": "relindis.agethen@tri-hub.de" + } +] +``` + +Die Profilseite und das Popup müssen dieselben IDs verwenden. E-Mail-Adressen +werden explizit gepflegt und nicht aus Namen erzeugt. Sie müssen auf `@tri-hub.de` +enden. Keine vertraulichen Informationen in die JSON schreiben: Sie ist Teil +des Frontends. Nach Änderungen API neu starten und Frontend neu bauen. + +## Einzubindender Code + +Beispielbutton (ID aus dem jeweiligen Profil): + +```html + +``` + +Im JavaScript-Modul der Profilseite, einmalig registrieren: + +```js +// Importpfad relativ zu dieser Datei anpassen. +import { openContactDialog } from "./src/features/contact/contact-dialog.js"; + +document.addEventListener("click", (event) => { + const button = event.target.closest("button[data-berater-id]"); + if (!button) return; + openContactDialog({ advisorId: button.dataset.beraterId }); +}); +``` + +Bei dynamischer Erzeugung des Buttons die ID aus dem Profil setzen: + +```js +button.dataset.beraterId = profile.id; +``` + +Alternativ direkt am einzelnen Button binden (nicht zusätzlich zur Delegation): + +```js +button.addEventListener("click", () => { + openContactDialog({ advisorId: profile.id }); +}); +``` + +Falls die Profilseite selbst Daten aus der gemeinsamen Datei benötigt: + +```js +// Beispiel für ein Modul direkt unter src/; Pfad ggf. anpassen. +import berater from "./shared/berater-daten.json" with { type: "json" }; + +const profile = berater.find((entry) => entry.id === advisorId); +// profile.vorname, profile.nachname, profile.spitzname und profile.email sind verfügbar. +``` + +Das Popup zeigt den getrimmten `spitzname`, falls nicht leer, sonst den +getrimmten `vorname`. `nachname` und das bisherige Feld `name` werden für die +Überschrift nicht verwendet. Es wird kein „kontaktieren“ angehängt. + +Das Popup ermittelt den Anzeigenamen selbst und sendet beim Absenden +`{ email, advisorId, reason, simulationAccepted }` an `/api/contact`. Die +Profilseite muss weder Name noch E-Mail an das Popup schicken. Kundenprüfung, +Simulation, Mailpit-Versand und Microsoft-Bookings-Weiterleitung sind bereits +implementiert. Unbekannte IDs sperren das Absenden. + +## Testen + +Mit laufendem Frontend, API und Mailpit: +`http://localhost:5173/contact-test.html?berater-id=relindis-agethen`. +Für die Kundenadresse eine vorhandene Testbuchung verwenden. Die Prüfung +findet ausschließlich beim Absenden statt. Der Branch ist +`feature/terminbuchung-kontaktfeld`; die Kontaktdateien müssen im Arbeitsstand +der Profilseite vorhanden sein. diff --git a/docs/contact.md b/docs/contact.md index dc85f84..182f768 100644 --- a/docs/contact.md +++ b/docs/contact.md @@ -1,154 +1,154 @@ -# Beraterkontakt (Simulation) - -## Testseite starten - -Wie bei der Buchung in drei Terminals starten: - -```sh -npm run dev:mail -npm run dev:server -npm run dev -``` - -Öffne . -Der Button öffnet ein modales Fenster. Gib eine E-Mail aus einer vorhandenen -Buchung in `src/server/data/bookings.csv` ein, wähle einen Kontaktgrund und -bestätige den Simulationshinweis. Beim Klick auf „Anfrage senden & Termin wählen“ -prüft der Server die E-Mail und versendet nur bei vorhandener Buchung. Bei Bedarf zuerst -über die bestehende Angebotsseite eine Testbuchung anlegen. - -Nach dem Absenden müssen zwei getrennte Nachrichten in - erscheinen: an die Kundenadresse und beispielsweise -`relindis.agethen@tri-hub.de`. Der Browser wechselt anschließend zur vom Auftrag -vorgegebenen Microsoft-Bookings-Adresse. Es wird dort kein Termin automatisch -gebucht. Die Checkliste spricht von Microsoft Forms; umgesetzt ist der konkret -angegebene Bookings-Link. - -## Checkliste - -- [x] Kontaktgrund auswählbar: Coaching, Ernährungsplan, Wettkampfverpflegung, - Regeneration, Paketfragen, Termin und Sonstiges. -- [x] Getrennte Eingangsbestätigungen an Kunde und Berater, ausschließlich - simuliert über Mailpit, mit Kontaktgrund und Simulationshinweis. -- [x] Weiterleitung zur vorhandenen Microsoft-Bookings-Adresse nach Annahme - beider Nachrichten durch Mailpit; zusätzlicher Link als Rückfalloption. -- [x] Kontakt nur nach erfolgreichem E-Mail-Abgleich mit `bookings.csv`; - der Server prüft beim tatsächlichen Absenden erneut. - -## Einbindung in Beraterprofile - -Die Profilseite importiert `openContactDialog` aus -`src/features/contact/contact-dialog.js` und übergibt ausschließlich die ID: - -```js -import { openContactDialog } from "./src/features/contact/contact-dialog.js"; - -button.addEventListener("click", () => { - openContactDialog({ advisorId: profile.id }); -}); -``` - -Den Importpfad relativ zur einbindenden Datei anpassen. Das Modul bringt die -Popup-Styles und die lokalen DM-Sans-Schriftdateien mit. Die vollständige -Einbauanleitung für die Profilseite steht in [contact-integration.md](contact-integration.md). - -Die gemeinsame Datenquelle `src/shared/berater-daten.json` enthält ein Array -mit eindeutigen String-IDs und den Feldern `id`, `vorname`, `nachname`, `spitzname`, `email`. Die vorhandenen Einträge enthalten die Beraterprofile. Zusätzliche Profilfelder sind möglich. Die Datei -wird im Frontend eingebunden: ausschließlich öffentliche Profildaten eintragen. - -`src/shared/berater.js` stellt `findAdvisor(advisorId)` bereit. Popup und Server -verwenden dieselbe Zuordnung. Der Server holt die Empfängeradresse ausschließlich -aus dieser JSON; vom Browser gesendete Namen oder Empfängeradressen werden -nicht übernommen. E-Mail-Adressen müssen gültig sein und auf `@tri-hub.de` -enden. Eine unbekannte ID wird abgelehnt und sperrt das Absenden im Popup. - -Die Testseite liest den URL-Parameter `berater-id`, zum Beispiel -`contact-test.html?berater-id=maren-hoffmann`. Ohne Parameter verwendet sie -`relindis-agethen`. Nach Änderungen an der JSON den API-Server neu starten und für -das Deployment das Frontend neu bauen. - -Das native `dialog` hält den Tastaturfokus im Popup. Escape und Schließen -bringen ihn zum auslösenden Button zurück. Während einer Anfrage ist Schließen -kurz gesperrt, damit der Versand nicht unbemerkt weiterläuft. Das Fenster ist -auf schmalen Bildschirmen scrollbar. Die E-Mail wird bei jedem Absenden geprüft; -ein separater Prüfbutton ist nicht erforderlich. Es gibt keine zusätzlichen -Laufzeitabhängigkeiten. - -## API - -Der vollständige OpenAPI-Vertrag steht in `src/server/swagger.json`. -Beide Endpunkte erwarten POST mit `Content-Type: application/json`, maximal -8 KiB. Fehler enthalten `error.code` und `error.message`. - -| Endpunkt | Eingabe | Erfolg | -| ----------------------- | --------------------------------------------------- | ---------------------------------------------------------- | -| `/api/contact/validate` | `{ "email": "kunde@example.test" }` | 200: `{ "valid": true, "email": "kunde@example.test" }` | -| `/api/contact` | E-Mail, Berater-ID, Grund, Zustimmung (siehe unten) | 201: `simulated`, `emailStatus: "accepted"`, `redirectUrl` | - -```json -{ - "email": "kunde@example.test", - "advisorId": "relindis-agethen", - "reason": "coaching", - "simulationAccepted": true -} -``` - -400 bedeutet ungültige Eingaben, 403 keine passende Buchung, 405 falsche Methode, -413 zu große Anfrage, 415 falscher Medientyp, 503 nicht lesbarer CSV-Speicher oder ungültige Beraterkontaktdaten. -502 bedeutet, dass mindestens eine Mailannahme fehlgeschlagen oder unklar ist; -der Browser leitet dann nicht weiter. 500 bezeichnet einen unerwarteten Fehler. -Die CSV wird ausschließlich gelesen und bleibt unverändert. Der separate -Validierungsendpunkt bleibt für API-Nutzer verfügbar; das Popup verwendet -ausschließlich `/api/contact` mit integrierter Kundenprüfung. - -## Grenzen der Simulation - -Der E-Mail-Abgleich ignoriert Groß-/Kleinschreibung und äußere Leerzeichen. -Er bestätigt einen Eintrag in der Buchungsdatei, nicht den Besitz des Postfachs. -Die Prüfantwort ist kein Anmeldetoken und gibt keine Buchungsdetails zurück. -Vor einem öffentlichen Produktiveinsatz wären eine Anmeldung oder Bestätigung -per E-Mail sowie ein Schutz vor automatisierten Adressabfragen erforderlich. - -Kontaktmails verwenden fest `127.0.0.1:1025`, unabhängig von einer eventuell -externen SMTP-Konfiguration des Buchungsablaufs. Mailpit muss deshalb auf -demselben Host laufen. Kunden- und Berateradresse sind simulierte Empfänger; -es erfolgt kein externer Versand. SMTP-Annahme ist keine reale Zustellbestätigung. - -Kontaktanfragen werden nicht dauerhaft gespeichert und haben keinen -Idempotenzschlüssel. Doppelklicks sind während des Versands gesperrt. Bei -Verbindungsabbrüchen oder Teilfehlern kann bereits eine Nachricht vorliegen: -vor manuellem Wiederholen Mailpit prüfen. Es gibt keinen automatischen Neuversand. - -## Prüfungen - -- `npm test`: API, CSV-Abgleich, erneute Validierung beim Absenden, ungültige - Eingaben, SMTP-Empfänger und Teilfehler; isolierte temporäre CSV-Dateien. -- `npm run test:browser`: vollständiger Ablauf einschließlich abgefangener - externer Weiterleitung, unbekannter Adresse, geänderter E-Mail, Versandfehler, - mobiler Darstellung, Fokus und Escape; zusätzlich bestehende Buchungstests. -- `npm run build` und `git diff --check`. -- Lokaler SMTP-Smoke-Test mit Mailpit: beide Empfänger und Simulationshinweis - in den empfangenen Nachrichten geprüft. - -Chromium muss für Playwright installiert sein (`npx playwright install chromium`). -In dieser Arbeitsumgebung liegt der Testbrowser unter `/tmp/trihub-playwright`; -hier mit `PLAYWRIGHT_BROWSERS_PATH=/tmp/trihub-playwright npm run test:browser` starten. -Der globale Formatcheck hat bereits bestehende Abweichungen in `src/main.js` -und `src/components/navigationsbar/README.md`; die Kontaktdateien sind formatiert. -Der bestehende Gesamt-Build meldet außerdem fehlende ältere Schriftdateien aus -dem globalen Stylesheet. Das Kontakt-Popup verwendet eigene lokale Schriftdateien. - -### Ergebnis dieser Umsetzung - -31 API-/Servicetests und alle vier Kontakt-Browsertests bestanden. -Build, lokale Mailpit-Prüfung und Formatierung der geänderten Dateien bestanden. -Die bestehenden Buchungs-Browsertests sind teilweise nicht mehr an das schon -vorhandene Buchungs-Popup angepasst: Sie suchen Formularfelder, ohne vorher -„Jetzt buchen“ zu klicken. Weitere Bestandsfälle erwarten einen nicht mehr vorhandenen Startseiten-Link -oder eine Seitennavigation, wo bereits ein Popup geöffnet wird. Beim ursprünglichen Gesamtlauf bestanden 7 von 15 Browsertests; die 8 Fehler -betrafen ausschließlich die unveränderten Buchungstests. Die gezielten -Kontakttests werden nach Anpassungen separat ausgeführt. -`git diff --check` ist für die eigenen Änderungen sauber; die zuvor bereits -geänderte `bookings.csv` erzeugt separat CRLF-Whitespace-Hinweise und wurde -von dieser Umsetzung nicht verändert. +# Beraterkontakt (Simulation) + +## Testseite starten + +Wie bei der Buchung in drei Terminals starten: + +```sh +npm run dev:mail +npm run dev:server +npm run dev +``` + +Öffne . +Der Button öffnet ein modales Fenster. Gib eine E-Mail aus einer vorhandenen +Buchung in `src/server/data/bookings.csv` ein, wähle einen Kontaktgrund und +bestätige den Simulationshinweis. Beim Klick auf „Anfrage senden & Termin wählen“ +prüft der Server die E-Mail und versendet nur bei vorhandener Buchung. Bei Bedarf zuerst +über die bestehende Angebotsseite eine Testbuchung anlegen. + +Nach dem Absenden müssen zwei getrennte Nachrichten in + erscheinen: an die Kundenadresse und beispielsweise +`relindis.agethen@tri-hub.de`. Der Browser wechselt anschließend zur vom Auftrag +vorgegebenen Microsoft-Bookings-Adresse. Es wird dort kein Termin automatisch +gebucht. Die Checkliste spricht von Microsoft Forms; umgesetzt ist der konkret +angegebene Bookings-Link. + +## Checkliste + +- [x] Kontaktgrund auswählbar: Coaching, Ernährungsplan, Wettkampfverpflegung, + Regeneration, Paketfragen, Termin und Sonstiges. +- [x] Getrennte Eingangsbestätigungen an Kunde und Berater, ausschließlich + simuliert über Mailpit, mit Kontaktgrund und Simulationshinweis. +- [x] Weiterleitung zur vorhandenen Microsoft-Bookings-Adresse nach Annahme + beider Nachrichten durch Mailpit; zusätzlicher Link als Rückfalloption. +- [x] Kontakt nur nach erfolgreichem E-Mail-Abgleich mit `bookings.csv`; + der Server prüft beim tatsächlichen Absenden erneut. + +## Einbindung in Beraterprofile + +Die Profilseite importiert `openContactDialog` aus +`src/features/contact/contact-dialog.js` und übergibt ausschließlich die ID: + +```js +import { openContactDialog } from "./src/features/contact/contact-dialog.js"; + +button.addEventListener("click", () => { + openContactDialog({ advisorId: profile.id }); +}); +``` + +Den Importpfad relativ zur einbindenden Datei anpassen. Das Modul bringt die +Popup-Styles und die lokalen DM-Sans-Schriftdateien mit. Die vollständige +Einbauanleitung für die Profilseite steht in [contact-integration.md](contact-integration.md). + +Die gemeinsame Datenquelle `src/shared/berater-daten.json` enthält ein Array +mit eindeutigen String-IDs und den Feldern `id`, `vorname`, `nachname`, `spitzname`, `email`. Die vorhandenen Einträge enthalten die Beraterprofile. Zusätzliche Profilfelder sind möglich. Die Datei +wird im Frontend eingebunden: ausschließlich öffentliche Profildaten eintragen. + +`src/shared/berater.js` stellt `findAdvisor(advisorId)` bereit. Popup und Server +verwenden dieselbe Zuordnung. Der Server holt die Empfängeradresse ausschließlich +aus dieser JSON; vom Browser gesendete Namen oder Empfängeradressen werden +nicht übernommen. E-Mail-Adressen müssen gültig sein und auf `@tri-hub.de` +enden. Eine unbekannte ID wird abgelehnt und sperrt das Absenden im Popup. + +Die Testseite liest den URL-Parameter `berater-id`, zum Beispiel +`contact-test.html?berater-id=maren-hoffmann`. Ohne Parameter verwendet sie +`relindis-agethen`. Nach Änderungen an der JSON den API-Server neu starten und für +das Deployment das Frontend neu bauen. + +Das native `dialog` hält den Tastaturfokus im Popup. Escape und Schließen +bringen ihn zum auslösenden Button zurück. Während einer Anfrage ist Schließen +kurz gesperrt, damit der Versand nicht unbemerkt weiterläuft. Das Fenster ist +auf schmalen Bildschirmen scrollbar. Die E-Mail wird bei jedem Absenden geprüft; +ein separater Prüfbutton ist nicht erforderlich. Es gibt keine zusätzlichen +Laufzeitabhängigkeiten. + +## API + +Der vollständige OpenAPI-Vertrag steht in `src/server/swagger.json`. +Beide Endpunkte erwarten POST mit `Content-Type: application/json`, maximal +8 KiB. Fehler enthalten `error.code` und `error.message`. + +| Endpunkt | Eingabe | Erfolg | +| ----------------------- | --------------------------------------------------- | ---------------------------------------------------------- | +| `/api/contact/validate` | `{ "email": "kunde@example.test" }` | 200: `{ "valid": true, "email": "kunde@example.test" }` | +| `/api/contact` | E-Mail, Berater-ID, Grund, Zustimmung (siehe unten) | 201: `simulated`, `emailStatus: "accepted"`, `redirectUrl` | + +```json +{ + "email": "kunde@example.test", + "advisorId": "relindis-agethen", + "reason": "coaching", + "simulationAccepted": true +} +``` + +400 bedeutet ungültige Eingaben, 403 keine passende Buchung, 405 falsche Methode, +413 zu große Anfrage, 415 falscher Medientyp, 503 nicht lesbarer CSV-Speicher oder ungültige Beraterkontaktdaten. +502 bedeutet, dass mindestens eine Mailannahme fehlgeschlagen oder unklar ist; +der Browser leitet dann nicht weiter. 500 bezeichnet einen unerwarteten Fehler. +Die CSV wird ausschließlich gelesen und bleibt unverändert. Der separate +Validierungsendpunkt bleibt für API-Nutzer verfügbar; das Popup verwendet +ausschließlich `/api/contact` mit integrierter Kundenprüfung. + +## Grenzen der Simulation + +Der E-Mail-Abgleich ignoriert Groß-/Kleinschreibung und äußere Leerzeichen. +Er bestätigt einen Eintrag in der Buchungsdatei, nicht den Besitz des Postfachs. +Die Prüfantwort ist kein Anmeldetoken und gibt keine Buchungsdetails zurück. +Vor einem öffentlichen Produktiveinsatz wären eine Anmeldung oder Bestätigung +per E-Mail sowie ein Schutz vor automatisierten Adressabfragen erforderlich. + +Kontaktmails verwenden fest `127.0.0.1:1025`, unabhängig von einer eventuell +externen SMTP-Konfiguration des Buchungsablaufs. Mailpit muss deshalb auf +demselben Host laufen. Kunden- und Berateradresse sind simulierte Empfänger; +es erfolgt kein externer Versand. SMTP-Annahme ist keine reale Zustellbestätigung. + +Kontaktanfragen werden nicht dauerhaft gespeichert und haben keinen +Idempotenzschlüssel. Doppelklicks sind während des Versands gesperrt. Bei +Verbindungsabbrüchen oder Teilfehlern kann bereits eine Nachricht vorliegen: +vor manuellem Wiederholen Mailpit prüfen. Es gibt keinen automatischen Neuversand. + +## Prüfungen + +- `npm test`: API, CSV-Abgleich, erneute Validierung beim Absenden, ungültige + Eingaben, SMTP-Empfänger und Teilfehler; isolierte temporäre CSV-Dateien. +- `npm run test:browser`: vollständiger Ablauf einschließlich abgefangener + externer Weiterleitung, unbekannter Adresse, geänderter E-Mail, Versandfehler, + mobiler Darstellung, Fokus und Escape; zusätzlich bestehende Buchungstests. +- `npm run build` und `git diff --check`. +- Lokaler SMTP-Smoke-Test mit Mailpit: beide Empfänger und Simulationshinweis + in den empfangenen Nachrichten geprüft. + +Chromium muss für Playwright installiert sein (`npx playwright install chromium`). +In dieser Arbeitsumgebung liegt der Testbrowser unter `/tmp/trihub-playwright`; +hier mit `PLAYWRIGHT_BROWSERS_PATH=/tmp/trihub-playwright npm run test:browser` starten. +Der globale Formatcheck hat bereits bestehende Abweichungen in `src/main.js` +und `src/components/navigationsbar/README.md`; die Kontaktdateien sind formatiert. +Der bestehende Gesamt-Build meldet außerdem fehlende ältere Schriftdateien aus +dem globalen Stylesheet. Das Kontakt-Popup verwendet eigene lokale Schriftdateien. + +### Ergebnis dieser Umsetzung + +31 API-/Servicetests und alle vier Kontakt-Browsertests bestanden. +Build, lokale Mailpit-Prüfung und Formatierung der geänderten Dateien bestanden. +Die bestehenden Buchungs-Browsertests sind teilweise nicht mehr an das schon +vorhandene Buchungs-Popup angepasst: Sie suchen Formularfelder, ohne vorher +„Jetzt buchen“ zu klicken. Weitere Bestandsfälle erwarten einen nicht mehr vorhandenen Startseiten-Link +oder eine Seitennavigation, wo bereits ein Popup geöffnet wird. Beim ursprünglichen Gesamtlauf bestanden 7 von 15 Browsertests; die 8 Fehler +betrafen ausschließlich die unveränderten Buchungstests. Die gezielten +Kontakttests werden nach Anpassungen separat ausgeführt. +`git diff --check` ist für die eigenen Änderungen sauber; die zuvor bereits +geänderte `bookings.csv` erzeugt separat CRLF-Whitespace-Hinweise und wurde +von dieser Umsetzung nicht verändert. diff --git a/docs/nutrition-contact-form.md b/docs/nutrition-contact-form.md index 32b7911..782aa89 100644 --- a/docs/nutrition-contact-form.md +++ b/docs/nutrition-contact-form.md @@ -1,21 +1,21 @@ -# Kontaktformular der Ernährungsseite - -Das Formular in `index.html` sendet seine Eingaben als JSON an `POST /api/nutrition-contact`. -Der Server validiert Name, E-Mail, Betreff und Nachricht, speichert die Anfrage in -`src/server/data/contact-requests.csv` und sendet anschließend zwei E-Mails über -Mailpit auf `127.0.0.1:1025`: - -- eine Eingangsbestätigung an die eingegebene E-Mail-Adresse; -- beide Nachrichten mit dem Absender `Tri-Hub Ernährung `; -- die vollständige Kontaktanfrage an die Mailpit-Testadresse - `ernaehrung@tri-hub.de`; die SMTP-Verbindung ist fest auf den lokalen - Mailpit-Server gesetzt, daher wird diese Nachricht nur simuliert und nicht - an den echten Mailserver zugestellt. - -Die Mailpit-Weboberfläche ist lokal unter `http://127.0.0.1:8025` erreichbar. -Zum Entwickeln Mailpit mit `npm run dev:mail`, den API-Server mit -`npm run dev:server` und Vite mit `npm run dev` starten. - -Die CSV-Datei wird beim ersten erfolgreichen Absenden angelegt. Bei einem -Mailfehler bleibt die Anfrage gespeichert; die API meldet, dass der Versand -nicht vollständig bestätigt werden konnte. +# Kontaktformular der Ernährungsseite + +Das Formular in `index.html` sendet seine Eingaben als JSON an `POST /api/nutrition-contact`. +Der Server validiert Name, E-Mail, Betreff und Nachricht, speichert die Anfrage in +`src/server/data/contact-requests.csv` und sendet anschließend zwei E-Mails über +Mailpit auf `127.0.0.1:1025`: + +- eine Eingangsbestätigung an die eingegebene E-Mail-Adresse; +- beide Nachrichten mit dem Absender `Tri-Hub Ernährung `; +- die vollständige Kontaktanfrage an die Mailpit-Testadresse + `ernaehrung@tri-hub.de`; die SMTP-Verbindung ist fest auf den lokalen + Mailpit-Server gesetzt, daher wird diese Nachricht nur simuliert und nicht + an den echten Mailserver zugestellt. + +Die Mailpit-Weboberfläche ist lokal unter `http://127.0.0.1:8025` erreichbar. +Zum Entwickeln Mailpit mit `npm run dev:mail`, den API-Server mit +`npm run dev:server` und Vite mit `npm run dev` starten. + +Die CSV-Datei wird beim ersten erfolgreichen Absenden angelegt. Bei einem +Mailfehler bleibt die Anfrage gespeichert; die API meldet, dass der Versand +nicht vollständig bestätigt werden konnte. diff --git a/playwright.config.js b/playwright.config.js index bfa81fb..1be796e 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,20 +1,20 @@ -import { defineConfig } from "@playwright/test"; - -export default defineConfig({ - testDir: "./src/tests/browser", - workers: 1, - use: { baseURL: "http://127.0.0.1:5173", browserName: "chromium" }, - webServer: [ - { - command: "node src/tests/helpers/browser-server.js", - url: "http://127.0.0.1:3000/api/angebote", - reuseExistingServer: process.env.PW_REUSE_EXISTING === "1", - gracefulShutdown: { signal: "SIGTERM", timeout: 3000 }, - }, - { - command: "npm run dev", - url: "http://127.0.0.1:5173/booking.html", - reuseExistingServer: process.env.PW_REUSE_EXISTING === "1", - }, - ], -}); +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./src/tests/browser", + workers: 1, + use: { baseURL: "http://127.0.0.1:5173", browserName: "chromium" }, + webServer: [ + { + command: "node src/tests/helpers/browser-server.js", + url: "http://127.0.0.1:3000/api/angebote", + reuseExistingServer: process.env.PW_REUSE_EXISTING === "1", + gracefulShutdown: { signal: "SIGTERM", timeout: 3000 }, + }, + { + command: "npm run dev", + url: "http://127.0.0.1:5173/booking.html", + reuseExistingServer: process.env.PW_REUSE_EXISTING === "1", + }, + ], +}); diff --git a/public/fonts/DM-Sans-LICENSE.txt b/public/fonts/DM-Sans-LICENSE.txt index 917aaa6..dfebb3c 100644 --- a/public/fonts/DM-Sans-LICENSE.txt +++ b/public/fonts/DM-Sans-LICENSE.txt @@ -1,93 +1,93 @@ -Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) DMSans-Italic[opsz,wght].ttf: Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. +Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) DMSans-Italic[opsz,wght].ttf: Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/features/berater/berater-entry.js b/src/features/berater/berater-entry.js index 16eb5e4..eb1de36 100644 --- a/src/features/berater/berater-entry.js +++ b/src/features/berater/berater-entry.js @@ -1,134 +1,134 @@ -import berater from "../../shared/berater-daten.json"; -import "./berater.css"; - -const container = document.querySelector("#advisor-profiles"); - -function escapeHtml(value) { - return String(value).replace( - /[&<>"']/g, - (character) => - ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - })[character], - ); -} - -function contactName(person) { - return ( - (typeof person.spitzname === "string" && person.spitzname.trim()) || - person.vorname || - person.name.trim().split(/\s+/)[0] - ); -} - -if (container) { - if (!Array.isArray(berater) || berater.length === 0) { - container.innerHTML = - '

Aktuell sind keine Beraterprofile verfügbar.

'; - } else { - container.innerHTML = berater - .map( - (person) => ` -
- -

${escapeHtml(person.name)}

-

${escapeHtml(person.titel || "Ernährungsberatung")}

-
-
-

Kurzbiografie

-

${escapeHtml(person.biographie)}

-
-
-

Qualifikationen

-
    ${(person.qualifikationen || []).map((qualification) => `
  • ${escapeHtml(qualification)}
  • `).join("")}
-
-
-

Schwerpunkte

-
    ${(person.schwerpunkte || []).map((focus) => `
  • ${escapeHtml(focus)}
  • `).join("")}
-
-
- -
`, - ) - .join(""); - } -} - -document.addEventListener("click", async (event) => { - const galleryControl = event.target.closest( - "button[data-gallery-direction], button[data-gallery-index]", - ); - if (galleryControl) { - const gallery = galleryControl.closest(".advisor-gallery"); - const slides = [...gallery.querySelectorAll(".advisor-gallery__slide")]; - const currentIndex = Number(gallery.dataset.currentIndex || 0); - const requestedIndex = galleryControl.hasAttribute( - "data-gallery-direction", - ) - ? currentIndex + - (galleryControl.dataset.galleryDirection === "next" ? 1 : -1) - : Number(galleryControl.dataset.galleryIndex); - const nextIndex = (requestedIndex + slides.length) % slides.length; - - gallery.dataset.currentIndex = String(nextIndex); - gallery.querySelector(".advisor-gallery__track").style.transform = - `translateX(-${nextIndex * 100}%)`; - slides.forEach((slide, index) => { - slide.setAttribute("aria-hidden", String(index !== nextIndex)); - }); - gallery.querySelectorAll("[data-gallery-index]").forEach((button) => { - button.setAttribute( - "aria-current", - String(Number(button.dataset.galleryIndex) === nextIndex), - ); - }); - return; - } - - const button = event.target.closest("button[data-berater-id]"); - if (!button) return; - - try { - const { openContactDialog } = await import( - /* @vite-ignore */ "../contact/contact-dialog.js" - ); - openContactDialog({ advisorId: button.dataset.beraterId }); - } catch (error) { - console.error( - "Das Kontaktformular ist derzeit nicht verfügbar.", - error, - ); - button.insertAdjacentHTML( - "afterend", - '

Das Kontaktformular ist momentan nicht verfügbar.

', - ); - button.disabled = true; - } -}); +import berater from "../../shared/berater-daten.json"; +import "./berater.css"; + +const container = document.querySelector("#advisor-profiles"); + +function escapeHtml(value) { + return String(value).replace( + /[&<>"']/g, + (character) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character], + ); +} + +function contactName(person) { + return ( + (typeof person.spitzname === "string" && person.spitzname.trim()) || + person.vorname || + person.name.trim().split(/\s+/)[0] + ); +} + +if (container) { + if (!Array.isArray(berater) || berater.length === 0) { + container.innerHTML = + '

Aktuell sind keine Beraterprofile verfügbar.

'; + } else { + container.innerHTML = berater + .map( + (person) => ` +
+ +

${escapeHtml(person.name)}

+

${escapeHtml(person.titel || "Ernährungsberatung")}

+
+
+

Kurzbiografie

+

${escapeHtml(person.biographie)}

+
+
+

Qualifikationen

+
    ${(person.qualifikationen || []).map((qualification) => `
  • ${escapeHtml(qualification)}
  • `).join("")}
+
+
+

Schwerpunkte

+
    ${(person.schwerpunkte || []).map((focus) => `
  • ${escapeHtml(focus)}
  • `).join("")}
+
+
+ +
`, + ) + .join(""); + } +} + +document.addEventListener("click", async (event) => { + const galleryControl = event.target.closest( + "button[data-gallery-direction], button[data-gallery-index]", + ); + if (galleryControl) { + const gallery = galleryControl.closest(".advisor-gallery"); + const slides = [...gallery.querySelectorAll(".advisor-gallery__slide")]; + const currentIndex = Number(gallery.dataset.currentIndex || 0); + const requestedIndex = galleryControl.hasAttribute( + "data-gallery-direction", + ) + ? currentIndex + + (galleryControl.dataset.galleryDirection === "next" ? 1 : -1) + : Number(galleryControl.dataset.galleryIndex); + const nextIndex = (requestedIndex + slides.length) % slides.length; + + gallery.dataset.currentIndex = String(nextIndex); + gallery.querySelector(".advisor-gallery__track").style.transform = + `translateX(-${nextIndex * 100}%)`; + slides.forEach((slide, index) => { + slide.setAttribute("aria-hidden", String(index !== nextIndex)); + }); + gallery.querySelectorAll("[data-gallery-index]").forEach((button) => { + button.setAttribute( + "aria-current", + String(Number(button.dataset.galleryIndex) === nextIndex), + ); + }); + return; + } + + const button = event.target.closest("button[data-berater-id]"); + if (!button) return; + + try { + const { openContactDialog } = await import( + /* @vite-ignore */ "../contact/contact-dialog.js" + ); + openContactDialog({ advisorId: button.dataset.beraterId }); + } catch (error) { + console.error( + "Das Kontaktformular ist derzeit nicht verfügbar.", + error, + ); + button.insertAdjacentHTML( + "afterend", + '

Das Kontaktformular ist momentan nicht verfügbar.

', + ); + button.disabled = true; + } +}); diff --git a/src/features/berater/berater.css b/src/features/berater/berater.css index cd65b80..8acba09 100644 --- a/src/features/berater/berater.css +++ b/src/features/berater/berater.css @@ -1,235 +1,235 @@ -.advisor-page { - color-scheme: dark; -} - -.advisor-main { - min-height: 100vh; - padding-top: 96px; -} - -.advisor-section { - width: min(1180px, calc(100% - 3rem)); - margin: auto; - padding-block: 5rem; - overflow-wrap: anywhere; -} - -.advisor-heading { - display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); - gap: 3rem; - align-items: end; - margin-bottom: 3rem; -} - -.advisor-eyebrow { - grid-column: 1 / -1; - margin: 0; - color: #53d5cd; - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.14em; - text-transform: uppercase; -} - -.advisor-heading h1 { - margin: 0.75rem 0 1rem; - font-size: clamp(2rem, 4.5vw, 3.75rem); - font-weight: 600; - line-height: 1.15; - letter-spacing: -0.035em; -} - -.advisor-intro { - max-width: 40rem; - margin: 0; - color: #94a3b8; -} - -.advisor-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 310px), 1fr)); - gap: 1.5rem; -} - -.advisor-loading { - grid-column: 1 / -1; - color: #94a3b8; -} - -.advisor-card { - min-width: 0; -} - -.advisor-card .package-title { - margin-bottom: 0.5rem; -} - -.advisor-card .package-type { - margin: 0 0 1rem; -} - -.advisor-card .package-details-box { - flex: 1; - padding-block: 1.25rem; -} - -.advisor-card .detail-title { - margin-bottom: 0.5rem; -} - -.advisor-card .detail-item + .detail-item { - margin-top: 1.25rem; -} - -.advisor-card .detail-list { - margin: 0; - padding-left: 1.25rem; -} - -.advisor-card .detail-list li::marker { - color: #53d5cd; -} - -.advisor-contact { - margin-top: 1.5rem; -} - -.advisor-contact-error { - margin: 10px 0 0; - color: #fca5a5; - font-size: 0.82rem; -} - -@media (max-width: 760px) { - .advisor-section { - width: calc(100% - 2rem); - padding-block: 3.5rem; - } - - .advisor-heading { - grid-template-columns: 1fr; - gap: 1.25rem; - } - - .advisor-eyebrow { - grid-column: auto; - } -} - -.advisor-card .advisor-role { - margin: 0 0 1rem; -} - -.advisor-card .package-details-box { - padding: 1.5rem; - border: 1px solid #f8fafc1f; - border-radius: 1.4rem; - background: #ffffff0b; -} - -.advisor-gallery { - margin-bottom: 1.5rem; -} - -.advisor-gallery__frame { - position: relative; - padding: 0.7rem; - overflow: hidden; - border: 1px dashed #f8fafc38; - border-radius: 1.5rem; - background: #ffffff05; -} - -.advisor-gallery__track { - display: flex; - width: 100%; - height: clamp(240px, 34vw, 390px); - transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1); -} - -.advisor-gallery__slide { - flex: 0 0 100%; - width: 100%; - height: 100%; - margin: 0; -} - -.advisor-gallery__image { - display: block; - width: 100%; - height: 100%; - border-radius: 1.1rem; - background: #071720; - object-fit: cover; - object-position: center; -} - -.advisor-gallery__arrow { - position: absolute; - top: 50%; - display: grid; - width: 2.75rem; - height: 2.75rem; - padding: 0; - place-items: center; - transform: translateY(-50%); - border: 1px solid #ffffff55; - border-radius: 50%; - color: #fff; - background: #071720bb; - font: inherit; - font-size: 2rem; - line-height: 1; - cursor: pointer; - transition: border-color 0.2s ease, background 0.2s ease; -} - -.advisor-gallery__arrow:hover { - border-color: #53d5cd; - background: #071720ee; -} - -.advisor-gallery__arrow--previous { - left: 1.2rem; -} - -.advisor-gallery__arrow--next { - right: 1.2rem; -} - -.advisor-gallery__pagination { - display: flex; - justify-content: center; - gap: 0.55rem; - margin-top: 0.85rem; -} - -.advisor-gallery__dot { - width: 0.7rem; - height: 0.7rem; - padding: 0; - border: 1px solid #94a3b8; - border-radius: 50%; - background: #ffffff12; - cursor: pointer; - transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease; -} - -.advisor-gallery__dot[aria-current="true"] { - transform: scale(1.15); - border-color: #53d5cd; - background: #53d5cd; -} - -.advisor-gallery__arrow:focus-visible, -.advisor-gallery__dot:focus-visible { - outline: 3px solid #53d5cd; - outline-offset: 3px; -} - -@media (prefers-reduced-motion: reduce) { - .advisor-gallery__track { - transition: none; - } -} +.advisor-page { + color-scheme: dark; +} + +.advisor-main { + min-height: 100vh; + padding-top: 96px; +} + +.advisor-section { + width: min(1180px, calc(100% - 3rem)); + margin: auto; + padding-block: 5rem; + overflow-wrap: anywhere; +} + +.advisor-heading { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: 3rem; + align-items: end; + margin-bottom: 3rem; +} + +.advisor-eyebrow { + grid-column: 1 / -1; + margin: 0; + color: #53d5cd; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.advisor-heading h1 { + margin: 0.75rem 0 1rem; + font-size: clamp(2rem, 4.5vw, 3.75rem); + font-weight: 600; + line-height: 1.15; + letter-spacing: -0.035em; +} + +.advisor-intro { + max-width: 40rem; + margin: 0; + color: #94a3b8; +} + +.advisor-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 310px), 1fr)); + gap: 1.5rem; +} + +.advisor-loading { + grid-column: 1 / -1; + color: #94a3b8; +} + +.advisor-card { + min-width: 0; +} + +.advisor-card .package-title { + margin-bottom: 0.5rem; +} + +.advisor-card .package-type { + margin: 0 0 1rem; +} + +.advisor-card .package-details-box { + flex: 1; + padding-block: 1.25rem; +} + +.advisor-card .detail-title { + margin-bottom: 0.5rem; +} + +.advisor-card .detail-item + .detail-item { + margin-top: 1.25rem; +} + +.advisor-card .detail-list { + margin: 0; + padding-left: 1.25rem; +} + +.advisor-card .detail-list li::marker { + color: #53d5cd; +} + +.advisor-contact { + margin-top: 1.5rem; +} + +.advisor-contact-error { + margin: 10px 0 0; + color: #fca5a5; + font-size: 0.82rem; +} + +@media (max-width: 760px) { + .advisor-section { + width: calc(100% - 2rem); + padding-block: 3.5rem; + } + + .advisor-heading { + grid-template-columns: 1fr; + gap: 1.25rem; + } + + .advisor-eyebrow { + grid-column: auto; + } +} + +.advisor-card .advisor-role { + margin: 0 0 1rem; +} + +.advisor-card .package-details-box { + padding: 1.5rem; + border: 1px solid #f8fafc1f; + border-radius: 1.4rem; + background: #ffffff0b; +} + +.advisor-gallery { + margin-bottom: 1.5rem; +} + +.advisor-gallery__frame { + position: relative; + padding: 0.7rem; + overflow: hidden; + border: 1px dashed #f8fafc38; + border-radius: 1.5rem; + background: #ffffff05; +} + +.advisor-gallery__track { + display: flex; + width: 100%; + height: clamp(240px, 34vw, 390px); + transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1); +} + +.advisor-gallery__slide { + flex: 0 0 100%; + width: 100%; + height: 100%; + margin: 0; +} + +.advisor-gallery__image { + display: block; + width: 100%; + height: 100%; + border-radius: 1.1rem; + background: #071720; + object-fit: cover; + object-position: center; +} + +.advisor-gallery__arrow { + position: absolute; + top: 50%; + display: grid; + width: 2.75rem; + height: 2.75rem; + padding: 0; + place-items: center; + transform: translateY(-50%); + border: 1px solid #ffffff55; + border-radius: 50%; + color: #fff; + background: #071720bb; + font: inherit; + font-size: 2rem; + line-height: 1; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.advisor-gallery__arrow:hover { + border-color: #53d5cd; + background: #071720ee; +} + +.advisor-gallery__arrow--previous { + left: 1.2rem; +} + +.advisor-gallery__arrow--next { + right: 1.2rem; +} + +.advisor-gallery__pagination { + display: flex; + justify-content: center; + gap: 0.55rem; + margin-top: 0.85rem; +} + +.advisor-gallery__dot { + width: 0.7rem; + height: 0.7rem; + padding: 0; + border: 1px solid #94a3b8; + border-radius: 50%; + background: #ffffff12; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease; +} + +.advisor-gallery__dot[aria-current="true"] { + transform: scale(1.15); + border-color: #53d5cd; + background: #53d5cd; +} + +.advisor-gallery__arrow:focus-visible, +.advisor-gallery__dot:focus-visible { + outline: 3px solid #53d5cd; + outline-offset: 3px; +} + +@media (prefers-reduced-motion: reduce) { + .advisor-gallery__track { + transition: none; + } +} diff --git a/src/features/contact/contact-dialog.js b/src/features/contact/contact-dialog.js index 7297d73..d050fde 100644 --- a/src/features/contact/contact-dialog.js +++ b/src/features/contact/contact-dialog.js @@ -1,130 +1,130 @@ -import { findAdvisor, advisorDisplayName } from "../../shared/berater.js"; -import { - contactReasons, - contactNotice, - contactBookingUrl, -} from "../../shared/contact.js"; -import "./contact.css"; - -// Auf Profilseiten: openContactDialog({ advisorId: profile.id }). -export function openContactDialog({ - advisorId, - navigate = (url) => window.location.assign(url), -} = {}) { - const advisor = findAdvisor(advisorId); - const returnFocus = document.activeElement; - const dialog = document.createElement("dialog"); - dialog.className = "contact-dialog"; - dialog.setAttribute("aria-labelledby", "contact-title"); - dialog.innerHTML = ` - -

DEIN NÄCHSTER SCHRITT

-

-

-
-
- -

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

-
-
- -
- -

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

DEIN NÄCHSTER SCHRITT

+

+

+
+
+ +

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

+
+
+ +
+ +

+ +
`; + const advisorName = advisorDisplayName(advisor); + dialog.querySelector("#contact-title").textContent = advisorName + ? `${advisorName} kontaktieren` + : "Berater nicht gefunden"; + dialog.querySelector(".contact-advisor").textContent = advisor + ? "Sende deine Kontaktanfrage." + : "Die Berater-ID fehlt oder ist unbekannt. Bitte öffne das Fenster über ein Beraterprofil."; + dialog.querySelector(".contact-consent span").textContent = contactNotice; + const form = dialog.querySelector("form"); + const email = form.elements.email; + const reason = form.elements.reason; + for (const [value, label] of Object.entries(contactReasons)) { + reason.add(new Option(label, value)); + } + const status = dialog.querySelector(".contact-status"); + const submit = dialog.querySelector(".contact-submit"); + const close = dialog.querySelector(".contact-close"); + let busy = false; + let sent = false; + function update() { + submit.disabled = busy || sent || !advisor; + close.disabled = busy; + email.disabled = busy || sent; + reason.disabled = busy || sent; + form.elements.simulationAccepted.disabled = busy || sent; + } + async function post(path, input) { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const result = await response.json(); + if (!response.ok) + throw new Error( + result.error?.message || "Die Anfrage ist fehlgeschlagen.", + ); + return result; + } + email.addEventListener("input", () => { + status.textContent = ""; + }); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submit.disabled || !form.reportValidity()) return; + const input = { + email: email.value, + advisorId, + reason: reason.value, + simulationAccepted: form.elements.simulationAccepted.checked, + }; + busy = true; + update(); + status.textContent = + "E-Mail wird geprüft und simulierte Anfrage gesendet …"; + try { + const result = await post("/api/contact", input); + if (result.redirectUrl !== contactBookingUrl) + throw new Error("Die Weiterleitungsadresse ist ungültig."); + sent = true; + status.textContent = + "Beide Bestätigungen wurden in Mailpit angenommen. Microsoft Bookings wird geöffnet. "; + const link = document.createElement("a"); + link.href = contactBookingUrl; + link.textContent = "Weiter zur Terminwahl"; + status.append(link); + navigate(contactBookingUrl); + } catch (error) { + status.textContent = `${error.message} Bei einem Verbindungsfehler bitte vor erneutem Senden Mailpit prüfen.`; + } finally { + busy = false; + update(); + } + }); + close.addEventListener("click", () => dialog.close()); + dialog.addEventListener("cancel", (event) => { + if (busy) event.preventDefault(); + }); + const previousOverflow = document.body.style.overflow; + dialog.addEventListener( + "close", + () => { + document.body.style.overflow = previousOverflow; + dialog.remove(); + returnFocus?.focus(); + }, + { once: true }, + ); + document.body.append(dialog); + document.body.style.overflow = "hidden"; + update(); + dialog.showModal(); + email.focus(); + return dialog; +} diff --git a/src/features/contact/contact-entry.js b/src/features/contact/contact-entry.js index 9dbf977..e469692 100644 --- a/src/features/contact/contact-entry.js +++ b/src/features/contact/contact-entry.js @@ -1,14 +1,14 @@ -import { openContactDialog } from "./contact-dialog.js"; -import { findAdvisor, advisorDisplayName } from "../../shared/berater.js"; -import "./contact-test.css"; - -const advisorId = - new URLSearchParams(location.search).get("berater-id") ?? - "relindis-agethen"; -const advisor = findAdvisor(advisorId); -document.querySelector("#advisor-name").textContent = advisor - ? `Dein Berater: ${advisorDisplayName(advisor)}` - : "Unbekannte Berater-ID"; -document - .querySelector("#open-contact") - .addEventListener("click", () => openContactDialog({ advisorId })); +import { openContactDialog } from "./contact-dialog.js"; +import { findAdvisor, advisorDisplayName } from "../../shared/berater.js"; +import "./contact-test.css"; + +const advisorId = + new URLSearchParams(location.search).get("berater-id") ?? + "relindis-agethen"; +const advisor = findAdvisor(advisorId); +document.querySelector("#advisor-name").textContent = advisor + ? `Dein Berater: ${advisorDisplayName(advisor)}` + : "Unbekannte Berater-ID"; +document + .querySelector("#open-contact") + .addEventListener("click", () => openContactDialog({ advisorId })); diff --git a/src/features/contact/contact-form.js b/src/features/contact/contact-form.js index 544d19b..428c548 100644 --- a/src/features/contact/contact-form.js +++ b/src/features/contact/contact-form.js @@ -1,70 +1,70 @@ -const form = document.querySelector("#contactForm"); -const status = document.querySelector("#contactFormStatus"); -const message = form?.elements.message; -const messageHint = document.querySelector("#contactMessageHint"); - -function updateMessageHint() { - if (!message || !messageHint) return; - const valid = [...message.value.trim()].length >= 10; - messageHint.classList.toggle("is-valid", valid); - messageHint.textContent = valid - ? "Mindestens 10 Zeichen erreicht." - : "Mindestens 10 Zeichen erforderlich."; -} - -function showErrors(fields = {}) { - for (const input of form.elements) { - if (!input.name) continue; - input.removeAttribute("aria-invalid"); - const error = form.querySelector(`[data-error-for="${input.name}"]`); - if (error) error.textContent = fields[input.name] ?? ""; - if (fields[input.name]) input.setAttribute("aria-invalid", "true"); - } -} - -message?.addEventListener("input", updateMessageHint); -updateMessageHint(); - -form?.addEventListener("submit", async (event) => { - event.preventDefault(); - status.textContent = ""; - status.classList.remove("is-error"); - showErrors(); - - if (!form.reportValidity()) { - status.textContent = "Bitte prüfe die markierten Eingaben."; - status.classList.add("is-error"); - return; - } - - const submit = form.querySelector('[type="submit"]'); - submit.disabled = true; - submit.textContent = "Wird gesendet …"; - try { - const response = await fetch("/api/nutrition-contact", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(Object.fromEntries(new FormData(form))), - }); - const result = await response.json(); - if (!response.ok) { - showErrors(result.error?.fields); - throw new Error( - result.error?.message ?? - "Die Nachricht konnte nicht gesendet werden.", - ); - } - form.reset(); - updateMessageHint(); - status.textContent = - "Danke für deine Nachricht. Wir melden uns schnellstmöglich bei dir."; - } catch (error) { - status.textContent = - error.message || - "Der Server ist gerade nicht erreichbar. Bitte versuche es erneut."; - status.classList.add("is-error"); - } finally { - submit.disabled = false; - submit.textContent = "Nachricht senden"; - } -}); +const form = document.querySelector("#contactForm"); +const status = document.querySelector("#contactFormStatus"); +const message = form?.elements.message; +const messageHint = document.querySelector("#contactMessageHint"); + +function updateMessageHint() { + if (!message || !messageHint) return; + const valid = [...message.value.trim()].length >= 10; + messageHint.classList.toggle("is-valid", valid); + messageHint.textContent = valid + ? "Mindestens 10 Zeichen erreicht." + : "Mindestens 10 Zeichen erforderlich."; +} + +function showErrors(fields = {}) { + for (const input of form.elements) { + if (!input.name) continue; + input.removeAttribute("aria-invalid"); + const error = form.querySelector(`[data-error-for="${input.name}"]`); + if (error) error.textContent = fields[input.name] ?? ""; + if (fields[input.name]) input.setAttribute("aria-invalid", "true"); + } +} + +message?.addEventListener("input", updateMessageHint); +updateMessageHint(); + +form?.addEventListener("submit", async (event) => { + event.preventDefault(); + status.textContent = ""; + status.classList.remove("is-error"); + showErrors(); + + if (!form.reportValidity()) { + status.textContent = "Bitte prüfe die markierten Eingaben."; + status.classList.add("is-error"); + return; + } + + const submit = form.querySelector('[type="submit"]'); + submit.disabled = true; + submit.textContent = "Wird gesendet …"; + try { + const response = await fetch("/api/nutrition-contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(Object.fromEntries(new FormData(form))), + }); + const result = await response.json(); + if (!response.ok) { + showErrors(result.error?.fields); + throw new Error( + result.error?.message ?? + "Die Nachricht konnte nicht gesendet werden.", + ); + } + form.reset(); + updateMessageHint(); + status.textContent = + "Danke für deine Nachricht. Wir melden uns schnellstmöglich bei dir."; + } catch (error) { + status.textContent = + error.message || + "Der Server ist gerade nicht erreichbar. Bitte versuche es erneut."; + status.classList.add("is-error"); + } finally { + submit.disabled = false; + submit.textContent = "Nachricht senden"; + } +}); diff --git a/src/features/contact/contact-test.css b/src/features/contact/contact-test.css index 18607f8..d4c8f74 100644 --- a/src/features/contact/contact-test.css +++ b/src/features/contact/contact-test.css @@ -1,29 +1,29 @@ -body { - margin: 0; - background: oklch(13% 0.022 223); - color: oklch(96% 0.008 210); - font: - 16px/1.5 "DM Sans", - sans-serif; -} -.contact-test { - max-width: 680px; - margin: 12vh auto; - padding: 24px; -} -.contact-test > p { - color: oklch(72% 0.015 214); -} -.contact-test button { - padding: 14px 24px; - border: 0; - border-radius: 6px; - background: white; - color: oklch(20% 0.03 220); - font: inherit; - cursor: pointer; -} -.contact-test button:focus-visible { - outline: 3px solid oklch(70% 0.12 205 / 0.45); - outline-offset: 4px; -} +body { + margin: 0; + background: oklch(13% 0.022 223); + color: oklch(96% 0.008 210); + font: + 16px/1.5 "DM Sans", + sans-serif; +} +.contact-test { + max-width: 680px; + margin: 12vh auto; + padding: 24px; +} +.contact-test > p { + color: oklch(72% 0.015 214); +} +.contact-test button { + padding: 14px 24px; + border: 0; + border-radius: 6px; + background: white; + color: oklch(20% 0.03 220); + font: inherit; + cursor: pointer; +} +.contact-test button:focus-visible { + outline: 3px solid oklch(70% 0.12 205 / 0.45); + outline-offset: 4px; +} diff --git a/src/features/contact/contact.css b/src/features/contact/contact.css index fd7a660..74df7e0 100644 --- a/src/features/contact/contact.css +++ b/src/features/contact/contact.css @@ -1,150 +1,150 @@ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url("/fonts/dm-sans-latin-400-normal.woff2") format("woff2"); -} -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url("/fonts/dm-sans-latin-500-normal.woff2") format("woff2"); -} -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url("/fonts/dm-sans-latin-700-normal.woff2") format("woff2"); -} -.contact-dialog { - --contact-foreground: oklch(96% 0.008 210); - --contact-accent: oklch(70% 0.12 205); - box-sizing: border-box; - width: min(32rem, calc(100vw - 2rem)); - max-height: calc(100dvh - 2rem); - margin: auto; - padding: 1.75rem; - overflow-y: auto; - border: 1px solid #324449; - border-radius: 1.25rem; - background: #071316; - color: var(--contact-foreground); - font: - 400 16px/1.5 "DM Sans", - sans-serif; - -webkit-font-smoothing: antialiased; - box-shadow: 0 1.5rem 4rem #0006; -} -.contact-dialog::backdrop { - background: rgb(0 0 0 / 65%); -} -.contact-dialog * { - box-sizing: border-box; -} -.contact-dialog h2 { - margin: 8px 32px 8px 0; - font-size: 1.35rem; - line-height: 1.25; -} -.contact-dialog p { - margin: 0; -} -.contact-dialog .contact-eyebrow { - color: var(--contact-accent); - font-size: 12px; - letter-spacing: 0.12em; -} -.contact-advisor, -.contact-help, -.contact-consent { - color: oklch(72% 0.015 214); -} -.contact-form { - margin-top: 32px; - display: grid; - gap: 24px; -} -.contact-form label { - display: block; - margin-bottom: 0.35rem; - font-weight: 600; -} -.contact-form input:not([type="checkbox"]), -.contact-form select { - width: 100%; - min-height: 2.75rem; - padding: 0.65rem 0.8rem; - border: 1px solid #61777d; - border-radius: 0.75rem; - background: #101e22; - color: inherit; - font: inherit; - color-scheme: dark; -} -.contact-dialog button { - min-height: 2.75rem; - padding: 0.65rem 0.8rem; - border: 1px solid transparent; - border-radius: 0.75rem; - font: inherit; - cursor: pointer; -} -.contact-dialog .contact-close { - position: absolute; - top: 0.5rem; - right: 0.5rem; - padding: 0; - width: 2.75rem; - height: 2.75rem; - border: 0; - background: transparent; - color: inherit; - font-size: 28px; -} -.contact-submit { - width: 100%; - background: #53d5cd; - color: oklch(20% 0.03 220); -} -.contact-submit:hover:not(:disabled) { - background: #6de0d9; -} - -.contact-dialog :disabled { - opacity: 0.7; - cursor: not-allowed; -} -.contact-dialog :focus-visible { - outline: 3px solid #53d5cd; - outline-offset: 4px; -} -.contact-dialog .contact-help { - margin-top: 8px; - font-size: 14px; -} -.contact-form .contact-consent { - display: flex; - align-items: flex-start; - gap: 12px; - margin: 0; - font-size: 14px; -} -.contact-consent input { - flex-shrink: 0; - width: 20px; - height: 20px; - margin: 2px 0 0; - accent-color: var(--contact-accent); -} -.contact-status { - overflow-wrap: anywhere; -} -.contact-status:empty { - display: none; -} -.contact-status a { - color: var(--contact-accent); -} +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/fonts/dm-sans-latin-400-normal.woff2") format("woff2"); +} +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("/fonts/dm-sans-latin-500-normal.woff2") format("woff2"); +} +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/fonts/dm-sans-latin-700-normal.woff2") format("woff2"); +} +.contact-dialog { + --contact-foreground: oklch(96% 0.008 210); + --contact-accent: oklch(70% 0.12 205); + box-sizing: border-box; + width: min(32rem, calc(100vw - 2rem)); + max-height: calc(100dvh - 2rem); + margin: auto; + padding: 1.75rem; + overflow-y: auto; + border: 1px solid #324449; + border-radius: 1.25rem; + background: #071316; + color: var(--contact-foreground); + font: + 400 16px/1.5 "DM Sans", + sans-serif; + -webkit-font-smoothing: antialiased; + box-shadow: 0 1.5rem 4rem #0006; +} +.contact-dialog::backdrop { + background: rgb(0 0 0 / 65%); +} +.contact-dialog * { + box-sizing: border-box; +} +.contact-dialog h2 { + margin: 8px 32px 8px 0; + font-size: 1.35rem; + line-height: 1.25; +} +.contact-dialog p { + margin: 0; +} +.contact-dialog .contact-eyebrow { + color: var(--contact-accent); + font-size: 12px; + letter-spacing: 0.12em; +} +.contact-advisor, +.contact-help, +.contact-consent { + color: oklch(72% 0.015 214); +} +.contact-form { + margin-top: 32px; + display: grid; + gap: 24px; +} +.contact-form label { + display: block; + margin-bottom: 0.35rem; + font-weight: 600; +} +.contact-form input:not([type="checkbox"]), +.contact-form select { + width: 100%; + min-height: 2.75rem; + padding: 0.65rem 0.8rem; + border: 1px solid #61777d; + border-radius: 0.75rem; + background: #101e22; + color: inherit; + font: inherit; + color-scheme: dark; +} +.contact-dialog button { + min-height: 2.75rem; + padding: 0.65rem 0.8rem; + border: 1px solid transparent; + border-radius: 0.75rem; + font: inherit; + cursor: pointer; +} +.contact-dialog .contact-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0; + width: 2.75rem; + height: 2.75rem; + border: 0; + background: transparent; + color: inherit; + font-size: 28px; +} +.contact-submit { + width: 100%; + background: #53d5cd; + color: oklch(20% 0.03 220); +} +.contact-submit:hover:not(:disabled) { + background: #6de0d9; +} + +.contact-dialog :disabled { + opacity: 0.7; + cursor: not-allowed; +} +.contact-dialog :focus-visible { + outline: 3px solid #53d5cd; + outline-offset: 4px; +} +.contact-dialog .contact-help { + margin-top: 8px; + font-size: 14px; +} +.contact-form .contact-consent { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0; + font-size: 14px; +} +.contact-consent input { + flex-shrink: 0; + width: 20px; + height: 20px; + margin: 2px 0 0; + accent-color: var(--contact-accent); +} +.contact-status { + overflow-wrap: anywhere; +} +.contact-status:empty { + display: none; +} +.contact-status a { + color: var(--contact-accent); +} diff --git a/src/features/faq/faq.js b/src/features/faq/faq.js new file mode 100644 index 0000000..c6e39ab --- /dev/null +++ b/src/features/faq/faq.js @@ -0,0 +1,137 @@ +/** + * Tri-hub FAQ + * Suche, Kategorien, Accordion und Footer-Jahr + */ + +document.addEventListener("DOMContentLoaded", () => { + const searchInput = document.getElementById("search"); + const chipsContainer = document.getElementById("chips"); + const sections = Array.from(document.querySelectorAll(".faq-section")); + const faqItems = Array.from(document.querySelectorAll(".faq-item")); + const emptyMessage = document.getElementById("empty"); + const status = document.getElementById("status"); + const yearElement = document.getElementById("year"); + + // Aktuelles Jahr im Footer + if (yearElement) { + yearElement.textContent = new Date().getFullYear(); + } + + // Aktiver Kategorie-Filter + let activeFilter = "all"; + + // Text für die Suche vereinheitlichen + function normalizeText(text) { + return text + .toLocaleLowerCase("de") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .trim(); + } + + // FAQ-Suche und Kategorie-Filter anwenden + function filterFAQs() { + const searchTerm = normalizeText(searchInput?.value || ""); + + let visibleCount = 0; + + sections.forEach((section) => { + const category = section.dataset.cat; + const categoryMatches = + activeFilter === "all" || category === activeFilter; + + const items = Array.from(section.querySelectorAll(".faq-item")); + + let visibleInSection = 0; + + items.forEach((item) => { + const searchableText = normalizeText(item.textContent); + + const searchMatches = + searchTerm === "" || searchableText.includes(searchTerm); + + const isVisible = categoryMatches && searchMatches; + + item.hidden = !isVisible; + + if (isVisible) { + visibleInSection++; + visibleCount++; + } + }); + + // Kategorie nur anzeigen, wenn Fragen passen + section.hidden = visibleInSection === 0; + }); + + // Hinweis anzeigen, wenn nichts gefunden wurde + if (emptyMessage) { + emptyMessage.classList.toggle("hidden", visibleCount > 0); + } + + // Screenreader über das Suchergebnis informieren + if (status) { + status.textContent = + visibleCount === 1 + ? "Eine Frage gefunden." + : `${visibleCount} Fragen gefunden.`; + } + } + + // Kategorie-Buttons initialisieren + if (chipsContainer) { + const chips = Array.from( + chipsContainer.querySelectorAll("[data-filter]"), + ); + + function updateActiveChip() { + chips.forEach((chip) => { + const isActive = chip.dataset.filter === activeFilter; + + chip.classList.toggle("is-active", isActive); + + chip.setAttribute("aria-pressed", String(isActive)); + }); + } + + chips.forEach((chip) => { + chip.addEventListener("click", () => { + activeFilter = chip.dataset.filter; + + updateActiveChip(); + filterFAQs(); + }); + }); + + updateActiveChip(); + } + + // Suche bei jeder Eingabe aktualisieren + if (searchInput) { + searchInput.addEventListener("input", filterFAQs); + } + + // Accordion: Immer nur eine Frage gleichzeitig öffnen + faqItems.forEach((item) => { + item.addEventListener("toggle", () => { + const isOpen = item.open; + + item.dataset.state = isOpen ? "open" : "closed"; + + if (!isOpen) return; + + faqItems.forEach((otherItem) => { + if (otherItem !== item && otherItem.open) { + otherItem.open = false; + } + }); + }); + }); + + // Initialzustand setzen + faqItems.forEach((item) => { + item.dataset.state = item.open ? "open" : "closed"; + }); + + filterFAQs(); +}); diff --git a/src/server/index.js b/src/server/index.js index e21556d..90348e1 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1,148 +1,148 @@ -import { createContactService } from "./services/contact-service.js"; -import { createContactEmailService } from "./services/contact-email.js"; -import { handleContact } from "./routes/contact.js"; -import { createServer } from "node:http"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { packages } from "../shared/packages.js"; -import { - BookingError, - createBookingService, -} from "./services/booking-service.js"; -import { createCsvStorage } from "./services/csv-storage.js"; -import { createEmailService } from "./services/email-service.js"; -import { handleBooking, json } from "./routes/bookings.js"; -import { handleContactForm } from "./routes/contact-form.js"; -import { createContactFormService } from "./services/contact-form-service.js"; -import { createContactFormStorage } from "./services/contact-form-storage.js"; -import { createContactFormEmailService } from "./services/contact-form-email.js"; - -export function createApp({ - catalog = packages, - storage = createCsvStorage( - fileURLToPath(new URL("./data/bookings.csv", import.meta.url)), - ), - details, - sendConfirmation, - sendContact, - contactFormStorage = createContactFormStorage( - fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)), - ), - sendContactForm, -} = {}) { - const ids = new Set(); - for (const entry of catalog) { - if ( - !entry || - typeof entry.id !== "string" || - !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) || - entry.id.length > 80 || - ids.has(entry.id) || - typeof entry.name !== "string" || - !entry.name.trim() || - typeof entry.summary !== "string" || - !entry.summary.trim() - ) { - throw new Error( - "Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.", - ); - } - ids.add(entry.id); - } - const service = createBookingService({ - storage, - details, - packages: catalog, - sendConfirmation: sendConfirmation ?? createEmailService(), - }); - const contact = createContactService({ - storage, - sendContact: sendContact ?? createContactEmailService(), - }); - // Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand. - const contactForm = createContactFormService({ - storage: contactFormStorage, - sendContactForm: sendContactForm ?? createContactFormEmailService(), - }); - return createServer(async (request, response) => { - const path = new URL(request.url, "http://localhost").pathname; - // Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt. - if (path === "/api/nutrition-contact") { - return handleContactForm(request, response, contactForm); - } - if (path === "/api/contact" || path === "/api/contact/validate") { - return handleContact( - request, - response, - contact, - path.endsWith("/validate"), - ); - } - if (path === "/api/angebote" && request.method === "GET") { - return json(response, 200, { - packages: catalog.map(({ id, name, summary }) => ({ - id, - name, - summary, - })), - }); - } - if (path === "/api/packages" && request.method === "GET") { - return json( - response, - 200, - catalog.map(({ name, summary, ...entry }) => ({ - ...entry, - title: name, - description: summary, - })), - ); - } - if (path.startsWith("/api/packages/") && request.method === "GET") { - try { - return json( - response, - 200, - service.getPackage(path.slice("/api/packages/".length)), - ); - } catch (error) { - if (!(error instanceof BookingError)) throw error; - return json(response, error.status, { - error: { - code: error.code, - message: error.message, - fields: error.fields, - }, - }); - } - } - if (path === "/api/bookings" || path === "/api/bookings/preview") { - if (request.method !== "POST") { - response.setHeader("Allow", "POST"); - return json(response, 405, { - error: { - code: "METHOD_NOT_ALLOWED", - message: "Bitte POST verwenden.", - }, - }); - } - return handleBooking(request, response, service, { - preview: path.endsWith("/preview"), - }); - } - // Ausschließlich API; keine Auslieferung von CSV oder Serverdateien. - return json(response, 404, { - error: { code: "NOT_FOUND", message: "Adresse nicht gefunden." }, - }); - }); -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - const port = Number(process.env.PORT || 3000); - const server = createApp(); - server.listen(port, process.env.HOST || "127.0.0.1", () => { - console.log(`Buchungs-API auf Port ${port}`); - }); -} +import { createContactService } from "./services/contact-service.js"; +import { createContactEmailService } from "./services/contact-email.js"; +import { handleContact } from "./routes/contact.js"; +import { createServer } from "node:http"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { packages } from "../shared/packages.js"; +import { + BookingError, + createBookingService, +} from "./services/booking-service.js"; +import { createCsvStorage } from "./services/csv-storage.js"; +import { createEmailService } from "./services/email-service.js"; +import { handleBooking, json } from "./routes/bookings.js"; +import { handleContactForm } from "./routes/contact-form.js"; +import { createContactFormService } from "./services/contact-form-service.js"; +import { createContactFormStorage } from "./services/contact-form-storage.js"; +import { createContactFormEmailService } from "./services/contact-form-email.js"; + +export function createApp({ + catalog = packages, + storage = createCsvStorage( + fileURLToPath(new URL("./data/bookings.csv", import.meta.url)), + ), + details, + sendConfirmation, + sendContact, + contactFormStorage = createContactFormStorage( + fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)), + ), + sendContactForm, +} = {}) { + const ids = new Set(); + for (const entry of catalog) { + if ( + !entry || + typeof entry.id !== "string" || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) || + entry.id.length > 80 || + ids.has(entry.id) || + typeof entry.name !== "string" || + !entry.name.trim() || + typeof entry.summary !== "string" || + !entry.summary.trim() + ) { + throw new Error( + "Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.", + ); + } + ids.add(entry.id); + } + const service = createBookingService({ + storage, + details, + packages: catalog, + sendConfirmation: sendConfirmation ?? createEmailService(), + }); + const contact = createContactService({ + storage, + sendContact: sendContact ?? createContactEmailService(), + }); + // Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand. + const contactForm = createContactFormService({ + storage: contactFormStorage, + sendContactForm: sendContactForm ?? createContactFormEmailService(), + }); + return createServer(async (request, response) => { + const path = new URL(request.url, "http://localhost").pathname; + // Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt. + if (path === "/api/nutrition-contact") { + return handleContactForm(request, response, contactForm); + } + if (path === "/api/contact" || path === "/api/contact/validate") { + return handleContact( + request, + response, + contact, + path.endsWith("/validate"), + ); + } + if (path === "/api/angebote" && request.method === "GET") { + return json(response, 200, { + packages: catalog.map(({ id, name, summary }) => ({ + id, + name, + summary, + })), + }); + } + if (path === "/api/packages" && request.method === "GET") { + return json( + response, + 200, + catalog.map(({ name, summary, ...entry }) => ({ + ...entry, + title: name, + description: summary, + })), + ); + } + if (path.startsWith("/api/packages/") && request.method === "GET") { + try { + return json( + response, + 200, + service.getPackage(path.slice("/api/packages/".length)), + ); + } catch (error) { + if (!(error instanceof BookingError)) throw error; + return json(response, error.status, { + error: { + code: error.code, + message: error.message, + fields: error.fields, + }, + }); + } + } + if (path === "/api/bookings" || path === "/api/bookings/preview") { + if (request.method !== "POST") { + response.setHeader("Allow", "POST"); + return json(response, 405, { + error: { + code: "METHOD_NOT_ALLOWED", + message: "Bitte POST verwenden.", + }, + }); + } + return handleBooking(request, response, service, { + preview: path.endsWith("/preview"), + }); + } + // Ausschließlich API; keine Auslieferung von CSV oder Serverdateien. + return json(response, 404, { + error: { code: "NOT_FOUND", message: "Adresse nicht gefunden." }, + }); + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const port = Number(process.env.PORT || 3000); + const server = createApp(); + server.listen(port, process.env.HOST || "127.0.0.1", () => { + console.log(`Buchungs-API auf Port ${port}`); + }); +} diff --git a/src/server/routes/bookings.js b/src/server/routes/bookings.js index 5618720..e4125a8 100644 --- a/src/server/routes/bookings.js +++ b/src/server/routes/bookings.js @@ -1,81 +1,81 @@ -import { BookingError } from "../services/booking-service.js"; - -export function json(response, status, body) { - response.writeHead(status, { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "no-store", - "X-Content-Type-Options": "nosniff", - }); - response.end(JSON.stringify(body)); -} - -export async function readJson(request) { - if ( - request.headers["content-type"]?.split(";")[0].trim() !== - "application/json" - ) { - throw new BookingError( - 415, - "UNSUPPORTED_CONTENT_TYPE", - "Bitte JSON senden.", - ); - } - let size = 0; - const chunks = []; - for await (const chunk of request) { - size += chunk.length; - if (size > 8192) - throw new BookingError( - 413, - "BODY_TOO_LARGE", - "Die Anfrage ist zu groß.", - ); - chunks.push(chunk); - } - try { - return JSON.parse(Buffer.concat(chunks).toString("utf8")); - } catch { - throw new BookingError( - 400, - "INVALID_JSON", - "Die Anfrage enthält kein gültiges JSON.", - ); - } -} - -export async function handleBooking( - request, - response, - service, - { preview = false } = {}, -) { - try { - const input = await readJson(request); - if (preview) return json(response, 200, service.preview(input)); - const result = await service.book( - input, - request.headers["idempotency-key"], - ); - json(response, result.status, result.body); - } catch (error) { - if (error instanceof BookingError) { - json(response, error.status, { - error: { - code: error.code, - message: error.message, - fields: error.fields, - }, - }); - } else { - // Keine persönlichen Daten oder SMTP-Zugangsdaten ins Log schreiben. - console.error("Buchungsanfrage unerwartet fehlgeschlagen."); - json(response, 500, { - error: { - code: "INTERNAL_ERROR", - message: - "Der Status ist unklar. Bitte dieselbe Anfrage erneut versuchen.", - }, - }); - } - } -} +import { BookingError } from "../services/booking-service.js"; + +export function json(response, status, body) { + response.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + response.end(JSON.stringify(body)); +} + +export async function readJson(request) { + if ( + request.headers["content-type"]?.split(";")[0].trim() !== + "application/json" + ) { + throw new BookingError( + 415, + "UNSUPPORTED_CONTENT_TYPE", + "Bitte JSON senden.", + ); + } + let size = 0; + const chunks = []; + for await (const chunk of request) { + size += chunk.length; + if (size > 8192) + throw new BookingError( + 413, + "BODY_TOO_LARGE", + "Die Anfrage ist zu groß.", + ); + chunks.push(chunk); + } + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw new BookingError( + 400, + "INVALID_JSON", + "Die Anfrage enthält kein gültiges JSON.", + ); + } +} + +export async function handleBooking( + request, + response, + service, + { preview = false } = {}, +) { + try { + const input = await readJson(request); + if (preview) return json(response, 200, service.preview(input)); + const result = await service.book( + input, + request.headers["idempotency-key"], + ); + json(response, result.status, result.body); + } catch (error) { + if (error instanceof BookingError) { + json(response, error.status, { + error: { + code: error.code, + message: error.message, + fields: error.fields, + }, + }); + } else { + // Keine persönlichen Daten oder SMTP-Zugangsdaten ins Log schreiben. + console.error("Buchungsanfrage unerwartet fehlgeschlagen."); + json(response, 500, { + error: { + code: "INTERNAL_ERROR", + message: + "Der Status ist unklar. Bitte dieselbe Anfrage erneut versuchen.", + }, + }); + } + } +} diff --git a/src/server/routes/contact-form.js b/src/server/routes/contact-form.js index 59bc359..b5a4468 100644 --- a/src/server/routes/contact-form.js +++ b/src/server/routes/contact-form.js @@ -1,43 +1,43 @@ -import { BookingError } from "../services/booking-service.js"; -import { json, readJson } from "./bookings.js"; - -// Nimmt HTTP-Anfragen des Ernährungs-Kontaktformulars entgegen und übersetzt Service-Ergebnisse in JSON. -export async function handleContactForm(request, response, service) { - // Das Formular darf Anfragen ausschließlich per POST absenden. - if (request.method !== "POST") { - response.setHeader("Allow", "POST"); - return json(response, 405, { - error: { - code: "METHOD_NOT_ALLOWED", - message: "Bitte POST verwenden.", - }, - }); - } - try { - // Liest JSON, führt den Kontaktablauf aus und antwortet bei Erfolg mit HTTP 201. - const result = await service.submit(await readJson(request)); - return json(response, 201, result); - } catch (error) { - // Erwartete Validierungs- und Dienstfehler behalten Status und Fehlercode. - if (error instanceof BookingError) { - return json(response, error.status, { - error: { - code: error.code, - message: error.message, - ...(Object.keys(error.fields ?? {}).length - ? { fields: error.fields } - : {}), - }, - }); - } - // Unerwartete Fehler werden protokolliert, ohne Formulardaten offenzulegen. - console.error("Kontaktformular konnte nicht verarbeitet werden."); - return json(response, 500, { - error: { - code: "INTERNAL_ERROR", - message: - "Die Nachricht konnte gerade nicht verarbeitet werden.", - }, - }); - } -} +import { BookingError } from "../services/booking-service.js"; +import { json, readJson } from "./bookings.js"; + +// Nimmt HTTP-Anfragen des Ernährungs-Kontaktformulars entgegen und übersetzt Service-Ergebnisse in JSON. +export async function handleContactForm(request, response, service) { + // Das Formular darf Anfragen ausschließlich per POST absenden. + if (request.method !== "POST") { + response.setHeader("Allow", "POST"); + return json(response, 405, { + error: { + code: "METHOD_NOT_ALLOWED", + message: "Bitte POST verwenden.", + }, + }); + } + try { + // Liest JSON, führt den Kontaktablauf aus und antwortet bei Erfolg mit HTTP 201. + const result = await service.submit(await readJson(request)); + return json(response, 201, result); + } catch (error) { + // Erwartete Validierungs- und Dienstfehler behalten Status und Fehlercode. + if (error instanceof BookingError) { + return json(response, error.status, { + error: { + code: error.code, + message: error.message, + ...(Object.keys(error.fields ?? {}).length + ? { fields: error.fields } + : {}), + }, + }); + } + // Unerwartete Fehler werden protokolliert, ohne Formulardaten offenzulegen. + console.error("Kontaktformular konnte nicht verarbeitet werden."); + return json(response, 500, { + error: { + code: "INTERNAL_ERROR", + message: + "Die Nachricht konnte gerade nicht verarbeitet werden.", + }, + }); + } +} diff --git a/src/server/routes/contact.js b/src/server/routes/contact.js index 464e240..16a0318 100644 --- a/src/server/routes/contact.js +++ b/src/server/routes/contact.js @@ -1,38 +1,38 @@ -import { BookingError } from "../services/booking-service.js"; -import { json, readJson } from "./bookings.js"; - -export async function handleContact(request, response, service, validateOnly) { - if (request.method !== "POST") { - response.setHeader("Allow", "POST"); - return json(response, 405, { - error: { - code: "METHOD_NOT_ALLOWED", - message: "Bitte POST verwenden.", - }, - }); - } - try { - const input = await readJson(request); - const result = await (validateOnly - ? service.validate(input) - : service.submit(input)); - return json(response, validateOnly ? 200 : 201, result); - } catch (error) { - return json( - response, - error instanceof BookingError ? error.status : 500, - { - error: { - code: - error instanceof BookingError - ? error.code - : "INTERNAL_ERROR", - message: - error instanceof BookingError - ? error.message - : "Die Kontaktanfrage konnte nicht verarbeitet werden.", - }, - }, - ); - } -} +import { BookingError } from "../services/booking-service.js"; +import { json, readJson } from "./bookings.js"; + +export async function handleContact(request, response, service, validateOnly) { + if (request.method !== "POST") { + response.setHeader("Allow", "POST"); + return json(response, 405, { + error: { + code: "METHOD_NOT_ALLOWED", + message: "Bitte POST verwenden.", + }, + }); + } + try { + const input = await readJson(request); + const result = await (validateOnly + ? service.validate(input) + : service.submit(input)); + return json(response, validateOnly ? 200 : 201, result); + } catch (error) { + return json( + response, + error instanceof BookingError ? error.status : 500, + { + error: { + code: + error instanceof BookingError + ? error.code + : "INTERNAL_ERROR", + message: + error instanceof BookingError + ? error.message + : "Die Kontaktanfrage konnte nicht verarbeitet werden.", + }, + }, + ); + } +} diff --git a/src/server/services/booking-service.js b/src/server/services/booking-service.js index 4d04ec0..f8a8ab3 100644 --- a/src/server/services/booking-service.js +++ b/src/server/services/booking-service.js @@ -1,367 +1,367 @@ -import { - isValidBookingPhone, - phoneError, -} from "../../shared/phone-validation.js"; -import { createHash } from "node:crypto"; -import { packagesDetails } from "../../shared/packagesdetails.js"; - -export class BookingError extends Error { - constructor(status, code, message, fields = {}) { - super(message); - this.status = status; - this.code = code; - this.fields = fields; - } -} - -const uuidPattern = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -// Übliche unquotierte E-Mail-Adressen; einzelne Domainlabels maximal 63 Zeichen. -export const emailPattern = - /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; - -function validateInput(input, key) { - if (!uuidPattern.test(key ?? "")) { - throw new BookingError( - 400, - "INVALID_KEY", - "Ein gültiger Idempotency-Key (UUID v4) ist erforderlich.", - ); - } - if (!input || typeof input !== "object" || Array.isArray(input)) { - throw new BookingError( - 400, - "INVALID_INPUT", - "Erwartet wird ein JSON-Objekt.", - ); - } - let customer; - if (Object.hasOwn(input, "customer")) { - if ( - !input.customer || - typeof input.customer !== "object" || - Array.isArray(input.customer) || - input.acceptedTerms !== true - ) { - throw new BookingError( - 400, - "INVALID_INPUT", - "Kundendaten und Zustimmung zu den Bedingungen sind erforderlich.", - ); - } - customer = {}; - for (const [field, max, required] of [ - ["firstName", 60, true], - ["lastName", 60, true], - ["email", 254, true], - ["phone", 50, false], - ["ageGroup", 40, false], - ["notes", 2000, false], - ]) { - const value = input.customer[field]; - if (value === undefined && !required) continue; - if ( - typeof value !== "string" || - value.length > max || - (required && !value.trim()) || - /[\x00-\x1f\x7f]/.test(value) - ) { - throw new BookingError( - 400, - "INVALID_INPUT", - "Bitte die Kundendaten prüfen.", - { [field]: "Ungültige Angabe." }, - ); - } - customer[field] = value.trim(); - } - input = { - ...input, - name: `${customer.firstName} ${customer.lastName}`, - email: customer.email, - }; - } - const fields = {}; - const result = {}; - for (const [field, max, message] of [ - ["packageId", 80, "Bitte ein gültiges Paket auswählen."], - ["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."], - [ - "email", - 254, - "Bitte eine gültige E-Mail-Adresse eingeben (maximal 254 Zeichen).", - ], - ]) { - const value = input[field]; - if (typeof value !== "string" || !value.trim() || value.length > max) { - fields[field] = message; - } else { - result[field] = value.trim(); - } - } - if ( - result.packageId && - !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result.packageId) - ) { - fields.packageId = "Die Paket-ID ist ungültig."; - } - if (result.name && /[\x00-\x1f\x7f]/.test(result.name)) { - fields.name = - "Bitte den Namen ohne Zeilenumbrüche oder Steuerzeichen eingeben."; - } - if ( - result.email && - (!emailPattern.test(result.email) || - result.email.split("@")[0].length > 64) - ) { - fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; - } - const phone = customer ? customer.phone : input.phone; - if (phone !== undefined) { - if (!isValidBookingPhone(phone)) { - fields.phone = phoneError; - } else if (!customer && phone.trim()) { - result.phone = phone.trim(); - } - } - if (Object.keys(fields).length) { - throw new BookingError( - 400, - "INVALID_INPUT", - "Bitte die markierten Angaben prüfen.", - fields, - ); - } - if (input.variantId != null) { - if ( - typeof input.variantId !== "string" || - !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.variantId) || - input.variantId.length > 80 - ) { - throw new BookingError( - 400, - "INVALID_VARIANT", - "Bitte eine gültige Variante auswählen.", - ); - } - result.variantId = input.variantId; - } - if (customer) { - result.customer = customer; - result.acceptedTerms = true; - } - return result; -} - -// Der höchste gespeicherte Wert ist der persistente Zähler. Die bestehende -// Warteschlange verhindert, dass parallele Anfragen dieselbe Nummer erhalten. -function nextBookingId(records) { - let highest = 0n; - for (const record of records) { - const match = /^TH-([0-9]{6,})$/.exec(record.bookingId); - if (match) { - const number = BigInt(match[1]); - if (number > highest) highest = number; - } - } - return `TH-${String(highest + 1n).padStart(6, "0")}`; -} - -function responseFor(record, replayed) { - // Nach Prozessabbruch während SMTP bleibt die tatsächliche Annahme unklar. - const emailStatus = - record.emailStatus === "sending" ? "unknown" : record.emailStatus; - return { - status: emailStatus === "accepted" ? (replayed ? 200 : 201) : 202, - body: { - bookingId: record.bookingId, - createdAt: record.createdAt, - packageId: record.packageId, - packageName: record.packageName, - bookedPackage: record.bookedPackage || null, - customerEmail: record.email, - status: "CONFIRMED", - saved: true, - emailStatus, - replayed, - }, - }; -} - -export function createBookingService({ - storage, - sendConfirmation, - packages, - details = packagesDetails, -}) { - function getPackage(id) { - const selected = - packages.some((entry) => entry.id === id) && - details.find((entry) => entry.id === id); - if (!selected) - throw new BookingError( - 404, - "UNKNOWN_PACKAGE", - "Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.", - ); - return structuredClone(selected); - } - function preview(input) { - if ( - !input || - typeof input !== "object" || - Array.isArray(input) || - typeof input.packageId !== "string" - ) { - throw new BookingError( - 400, - "INVALID_INPUT", - "Bitte ein gültiges Paket auswählen.", - ); - } - let selected; - try { - selected = getPackage(input.packageId); - } catch (error) { - error.status = 400; - throw error; - } - const variants = selected.variants || []; - const variantId = input.variantId ?? variants[0]?.variantId ?? null; - const variant = variants.find((entry) => entry.variantId === variantId); - if ( - (variants.length && !variant) || - (!variants.length && - variantId !== null && - variantId !== selected.id) - ) { - throw new BookingError( - 400, - "INVALID_VARIANT", - "Diese Variante gehört nicht zum ausgewählten Paket.", - ); - } - const grossCents = Math.round((variant?.price ?? selected.price) * 100); - const netCents = Math.round(grossCents / (1 + selected.taxRate / 100)); - return { - packageId: selected.id, - variantId: variant?.variantId ?? null, - variantLabel: variant?.label ?? null, - type: selected.type, - title: selected.title, - subtitle: selected.subtitle, - summaryForBooking: selected.summaryForBooking, - durationWeeks: variant?.durationWeeks ?? selected.durationWeeks, - durationLabel: variant - ? `${variant.durationWeeks} Wochen Begleitung` - : selected.durationLabel, - quantity: 1, - billingInterval: selected.billingInterval, - includedFeatures: selected.includedFeatures, - processSteps: selected.processSteps, - netPrice: netCents / 100, - taxRate: selected.taxRate, - taxAmount: (grossCents - netCents) / 100, - grossPrice: grossCents / 100, - currency: selected.currency, - }; - } - // Lesen, Speichern und Versand laufen innerhalb eines Prozesses nacheinander. - let queue = Promise.resolve(); - async function processBooking(input, key) { - const data = validateInput(input, key); - const requestHash = createHash("sha256") - .update(JSON.stringify(data)) - .digest("hex"); - let records; - try { - records = await storage.readAll(); - } catch { - throw new BookingError( - 503, - "STORAGE_UNAVAILABLE", - "Der Buchungsstatus kann gerade nicht geprüft werden. Bitte mit derselben Anfrage erneut versuchen.", - ); - } - let record = records.find((entry) => entry.idempotencyKey === key); - const replayed = Boolean(record); - if (record && record.requestHash !== requestHash) { - throw new BookingError( - 409, - "IDEMPOTENCY_CONFLICT", - "Dieser Anfrageschlüssel gehört zu anderen Buchungsdaten. Bitte die ursprünglichen Angaben verwenden.", - ); - } - if (record && record.emailStatus !== "pending") - return responseFor(record, true); - if (!record) { - const selected = packages.find( - (entry) => entry.id === data.packageId, - ); - if (!selected) { - throw new BookingError( - 400, - "UNKNOWN_PACKAGE", - "Das ausgewählte Paket ist nicht verfügbar.", - { packageId: "Bitte ein verfügbares Paket auswählen." }, - ); - } - const bookedPackage = preview(data); - record = { - bookingId: nextBookingId(records), - createdAt: new Date().toISOString(), - packageId: selected.id, - packageName: selected.name, - bookedPackage, - customer: data.customer ?? null, - acceptedTerms: data.acceptedTerms ?? null, - name: data.name, - email: data.email, - phone: data.customer?.phone ?? data.phone ?? "", - emailStatus: "pending", - idempotencyKey: key, - requestHash, - }; - records.push(record); - try { - await storage.writeAll(records); - } catch { - throw new BookingError( - 503, - "BOOKING_NOT_SAVED", - "Die Buchung konnte nicht gespeichert werden. Bitte erneut versuchen.", - ); - } - } - // Versandabsicht zuerst persistieren. Nach einem Absturz niemals blind - // noch einmal senden: SMTP und CSV bilden keine gemeinsame Transaktion. - record.emailStatus = "sending"; - try { - await storage.writeAll(records); - } catch { - record.emailStatus = "pending"; - return responseFor(record, replayed); - } - try { - await sendConfirmation(record); - record.emailStatus = "accepted"; - } catch (error) { - record.emailStatus = error.deliveryUnknown ? "unknown" : "failed"; - } - try { - await storage.writeAll(records); - } catch { - record.emailStatus = "unknown"; - } - return responseFor(record, replayed); - } - return { - getPackage, - preview, - book(input, key) { - const task = queue.then(() => processBooking(input, key)); - queue = task.catch(() => {}); - return task; - }, - }; -} +import { + isValidBookingPhone, + phoneError, +} from "../../shared/phone-validation.js"; +import { createHash } from "node:crypto"; +import { packagesDetails } from "../../shared/packagesdetails.js"; + +export class BookingError extends Error { + constructor(status, code, message, fields = {}) { + super(message); + this.status = status; + this.code = code; + this.fields = fields; + } +} + +const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +// Übliche unquotierte E-Mail-Adressen; einzelne Domainlabels maximal 63 Zeichen. +export const emailPattern = + /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; + +function validateInput(input, key) { + if (!uuidPattern.test(key ?? "")) { + throw new BookingError( + 400, + "INVALID_KEY", + "Ein gültiger Idempotency-Key (UUID v4) ist erforderlich.", + ); + } + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Erwartet wird ein JSON-Objekt.", + ); + } + let customer; + if (Object.hasOwn(input, "customer")) { + if ( + !input.customer || + typeof input.customer !== "object" || + Array.isArray(input.customer) || + input.acceptedTerms !== true + ) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Kundendaten und Zustimmung zu den Bedingungen sind erforderlich.", + ); + } + customer = {}; + for (const [field, max, required] of [ + ["firstName", 60, true], + ["lastName", 60, true], + ["email", 254, true], + ["phone", 50, false], + ["ageGroup", 40, false], + ["notes", 2000, false], + ]) { + const value = input.customer[field]; + if (value === undefined && !required) continue; + if ( + typeof value !== "string" || + value.length > max || + (required && !value.trim()) || + /[\x00-\x1f\x7f]/.test(value) + ) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte die Kundendaten prüfen.", + { [field]: "Ungültige Angabe." }, + ); + } + customer[field] = value.trim(); + } + input = { + ...input, + name: `${customer.firstName} ${customer.lastName}`, + email: customer.email, + }; + } + const fields = {}; + const result = {}; + for (const [field, max, message] of [ + ["packageId", 80, "Bitte ein gültiges Paket auswählen."], + ["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."], + [ + "email", + 254, + "Bitte eine gültige E-Mail-Adresse eingeben (maximal 254 Zeichen).", + ], + ]) { + const value = input[field]; + if (typeof value !== "string" || !value.trim() || value.length > max) { + fields[field] = message; + } else { + result[field] = value.trim(); + } + } + if ( + result.packageId && + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result.packageId) + ) { + fields.packageId = "Die Paket-ID ist ungültig."; + } + if (result.name && /[\x00-\x1f\x7f]/.test(result.name)) { + fields.name = + "Bitte den Namen ohne Zeilenumbrüche oder Steuerzeichen eingeben."; + } + if ( + result.email && + (!emailPattern.test(result.email) || + result.email.split("@")[0].length > 64) + ) { + fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; + } + const phone = customer ? customer.phone : input.phone; + if (phone !== undefined) { + if (!isValidBookingPhone(phone)) { + fields.phone = phoneError; + } else if (!customer && phone.trim()) { + result.phone = phone.trim(); + } + } + if (Object.keys(fields).length) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte die markierten Angaben prüfen.", + fields, + ); + } + if (input.variantId != null) { + if ( + typeof input.variantId !== "string" || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.variantId) || + input.variantId.length > 80 + ) { + throw new BookingError( + 400, + "INVALID_VARIANT", + "Bitte eine gültige Variante auswählen.", + ); + } + result.variantId = input.variantId; + } + if (customer) { + result.customer = customer; + result.acceptedTerms = true; + } + return result; +} + +// Der höchste gespeicherte Wert ist der persistente Zähler. Die bestehende +// Warteschlange verhindert, dass parallele Anfragen dieselbe Nummer erhalten. +function nextBookingId(records) { + let highest = 0n; + for (const record of records) { + const match = /^TH-([0-9]{6,})$/.exec(record.bookingId); + if (match) { + const number = BigInt(match[1]); + if (number > highest) highest = number; + } + } + return `TH-${String(highest + 1n).padStart(6, "0")}`; +} + +function responseFor(record, replayed) { + // Nach Prozessabbruch während SMTP bleibt die tatsächliche Annahme unklar. + const emailStatus = + record.emailStatus === "sending" ? "unknown" : record.emailStatus; + return { + status: emailStatus === "accepted" ? (replayed ? 200 : 201) : 202, + body: { + bookingId: record.bookingId, + createdAt: record.createdAt, + packageId: record.packageId, + packageName: record.packageName, + bookedPackage: record.bookedPackage || null, + customerEmail: record.email, + status: "CONFIRMED", + saved: true, + emailStatus, + replayed, + }, + }; +} + +export function createBookingService({ + storage, + sendConfirmation, + packages, + details = packagesDetails, +}) { + function getPackage(id) { + const selected = + packages.some((entry) => entry.id === id) && + details.find((entry) => entry.id === id); + if (!selected) + throw new BookingError( + 404, + "UNKNOWN_PACKAGE", + "Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.", + ); + return structuredClone(selected); + } + function preview(input) { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) || + typeof input.packageId !== "string" + ) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte ein gültiges Paket auswählen.", + ); + } + let selected; + try { + selected = getPackage(input.packageId); + } catch (error) { + error.status = 400; + throw error; + } + const variants = selected.variants || []; + const variantId = input.variantId ?? variants[0]?.variantId ?? null; + const variant = variants.find((entry) => entry.variantId === variantId); + if ( + (variants.length && !variant) || + (!variants.length && + variantId !== null && + variantId !== selected.id) + ) { + throw new BookingError( + 400, + "INVALID_VARIANT", + "Diese Variante gehört nicht zum ausgewählten Paket.", + ); + } + const grossCents = Math.round((variant?.price ?? selected.price) * 100); + const netCents = Math.round(grossCents / (1 + selected.taxRate / 100)); + return { + packageId: selected.id, + variantId: variant?.variantId ?? null, + variantLabel: variant?.label ?? null, + type: selected.type, + title: selected.title, + subtitle: selected.subtitle, + summaryForBooking: selected.summaryForBooking, + durationWeeks: variant?.durationWeeks ?? selected.durationWeeks, + durationLabel: variant + ? `${variant.durationWeeks} Wochen Begleitung` + : selected.durationLabel, + quantity: 1, + billingInterval: selected.billingInterval, + includedFeatures: selected.includedFeatures, + processSteps: selected.processSteps, + netPrice: netCents / 100, + taxRate: selected.taxRate, + taxAmount: (grossCents - netCents) / 100, + grossPrice: grossCents / 100, + currency: selected.currency, + }; + } + // Lesen, Speichern und Versand laufen innerhalb eines Prozesses nacheinander. + let queue = Promise.resolve(); + async function processBooking(input, key) { + const data = validateInput(input, key); + const requestHash = createHash("sha256") + .update(JSON.stringify(data)) + .digest("hex"); + let records; + try { + records = await storage.readAll(); + } catch { + throw new BookingError( + 503, + "STORAGE_UNAVAILABLE", + "Der Buchungsstatus kann gerade nicht geprüft werden. Bitte mit derselben Anfrage erneut versuchen.", + ); + } + let record = records.find((entry) => entry.idempotencyKey === key); + const replayed = Boolean(record); + if (record && record.requestHash !== requestHash) { + throw new BookingError( + 409, + "IDEMPOTENCY_CONFLICT", + "Dieser Anfrageschlüssel gehört zu anderen Buchungsdaten. Bitte die ursprünglichen Angaben verwenden.", + ); + } + if (record && record.emailStatus !== "pending") + return responseFor(record, true); + if (!record) { + const selected = packages.find( + (entry) => entry.id === data.packageId, + ); + if (!selected) { + throw new BookingError( + 400, + "UNKNOWN_PACKAGE", + "Das ausgewählte Paket ist nicht verfügbar.", + { packageId: "Bitte ein verfügbares Paket auswählen." }, + ); + } + const bookedPackage = preview(data); + record = { + bookingId: nextBookingId(records), + createdAt: new Date().toISOString(), + packageId: selected.id, + packageName: selected.name, + bookedPackage, + customer: data.customer ?? null, + acceptedTerms: data.acceptedTerms ?? null, + name: data.name, + email: data.email, + phone: data.customer?.phone ?? data.phone ?? "", + emailStatus: "pending", + idempotencyKey: key, + requestHash, + }; + records.push(record); + try { + await storage.writeAll(records); + } catch { + throw new BookingError( + 503, + "BOOKING_NOT_SAVED", + "Die Buchung konnte nicht gespeichert werden. Bitte erneut versuchen.", + ); + } + } + // Versandabsicht zuerst persistieren. Nach einem Absturz niemals blind + // noch einmal senden: SMTP und CSV bilden keine gemeinsame Transaktion. + record.emailStatus = "sending"; + try { + await storage.writeAll(records); + } catch { + record.emailStatus = "pending"; + return responseFor(record, replayed); + } + try { + await sendConfirmation(record); + record.emailStatus = "accepted"; + } catch (error) { + record.emailStatus = error.deliveryUnknown ? "unknown" : "failed"; + } + try { + await storage.writeAll(records); + } catch { + record.emailStatus = "unknown"; + } + return responseFor(record, replayed); + } + return { + getPackage, + preview, + book(input, key) { + const task = queue.then(() => processBooking(input, key)); + queue = task.catch(() => {}); + return task; + }, + }; +} diff --git a/src/server/services/contact-email.js b/src/server/services/contact-email.js index d127af3..27e908f 100644 --- a/src/server/services/contact-email.js +++ b/src/server/services/contact-email.js @@ -1,53 +1,53 @@ -import { advisorDisplayName } from "../../shared/berater.js"; -import { createMailTransport } from "./email-service.js"; -import { BookingError } from "./booking-service.js"; -import { contactNotice } from "../../shared/contact.js"; - -export function createContactEmailService(makeTransport) { - // Dieser Ablauf bleibt auch bei extern konfiguriertem Buchungs-SMTP lokal. - const { transport, from } = createMailTransport( - { - SMTP_HOST: "127.0.0.1", - SMTP_PORT: "1025", - SMTP_FROM: "Tri-Hub Ernährung ", - }, - makeTransport, - ); - return async ({ email, advisor, reason }) => { - const results = await Promise.allSettled( - [ - { - to: email, - text: `Deine Kontaktanfrage an ${advisorDisplayName(advisor)} ist in der Simulation eingegangen. ${advisorDisplayName(advisor)} wird sich bei dir melden.`, - closing: "Viele Grüße\nTri-Hub Ernährung", - }, - { - to: advisor.email, - text: `Hallo ${advisorDisplayName(advisor)}, bitte setze dich mit ${email} in Kontakt.`, - }, - ].map(async ({ to, text, closing }) => { - const result = await transport.sendMail({ - from, - to, - subject: - "Eingangsbestätigung – Tri-Hub Kontakt (Simulation)", - text: `${text}\n\nKontaktgrund: ${reason}\n\n${contactNotice}${closing ? `\n\n${closing}` : ""}`, - }); - if ( - !result.accepted?.some( - (address) => address.toLowerCase() === to.toLowerCase(), - ) - ) { - throw new Error("SMTP hat den Empfänger nicht angenommen."); - } - }), - ); - if (results.some((result) => result.status === "rejected")) { - throw new BookingError( - 502, - "CONTACT_MAIL_FAILED", - "Nicht beide Bestätigungen konnten bestätigt werden. Bitte Mailpit prüfen, bevor du erneut sendest; eine Nachricht kann bereits eingegangen sein.", - ); - } - }; -} +import { advisorDisplayName } from "../../shared/berater.js"; +import { createMailTransport } from "./email-service.js"; +import { BookingError } from "./booking-service.js"; +import { contactNotice } from "../../shared/contact.js"; + +export function createContactEmailService(makeTransport) { + // Dieser Ablauf bleibt auch bei extern konfiguriertem Buchungs-SMTP lokal. + const { transport, from } = createMailTransport( + { + SMTP_HOST: "127.0.0.1", + SMTP_PORT: "1025", + SMTP_FROM: "Tri-Hub Ernährung ", + }, + makeTransport, + ); + return async ({ email, advisor, reason }) => { + const results = await Promise.allSettled( + [ + { + to: email, + text: `Deine Kontaktanfrage an ${advisorDisplayName(advisor)} ist in der Simulation eingegangen. ${advisorDisplayName(advisor)} wird sich bei dir melden.`, + closing: "Viele Grüße\nTri-Hub Ernährung", + }, + { + to: advisor.email, + text: `Hallo ${advisorDisplayName(advisor)}, bitte setze dich mit ${email} in Kontakt.`, + }, + ].map(async ({ to, text, closing }) => { + const result = await transport.sendMail({ + from, + to, + subject: + "Eingangsbestätigung – Tri-Hub Kontakt (Simulation)", + text: `${text}\n\nKontaktgrund: ${reason}\n\n${contactNotice}${closing ? `\n\n${closing}` : ""}`, + }); + if ( + !result.accepted?.some( + (address) => address.toLowerCase() === to.toLowerCase(), + ) + ) { + throw new Error("SMTP hat den Empfänger nicht angenommen."); + } + }), + ); + if (results.some((result) => result.status === "rejected")) { + throw new BookingError( + 502, + "CONTACT_MAIL_FAILED", + "Nicht beide Bestätigungen konnten bestätigt werden. Bitte Mailpit prüfen, bevor du erneut sendest; eine Nachricht kann bereits eingegangen sein.", + ); + } + }; +} diff --git a/src/server/services/contact-form-email.js b/src/server/services/contact-form-email.js index 95d854f..9527192 100644 --- a/src/server/services/contact-form-email.js +++ b/src/server/services/contact-form-email.js @@ -1,83 +1,83 @@ -import { createMailTransport } from "./email-service.js"; -import { BookingError } from "./booking-service.js"; - -// Sichtbarer Empfänger in Mailpit; der Transport darunter bleibt lokal. -const contactAddress = "ernaehrung@tri-hub.de"; - -// Richtet den SMTP-Transport fest auf Mailpit ein und liefert den E-Mail-Versand zurück. -export function createContactFormEmailService( - env = process.env, - makeTransport, -) { - const { transport, from } = createMailTransport( - { - SMTP_HOST: "127.0.0.1", - SMTP_PORT: "1025", - SMTP_FROM: "Tri-Hub Ernährung ", - }, - makeTransport, - ); - - return async function sendContactForm(data) { - // Erstellt die Eingangsbestätigung für den Absender und die Anfrage für Tri-Hub. - const messages = [ - { - to: data.email, - subject: "Wir haben deine Nachricht erhalten – Tri-Hub", - text: [ - `Hallo ${data.name},`, - "", - "vielen Dank für deine Nachricht. Wir haben deine Anfrage erhalten und melden uns schnellstmöglich bei dir.", - "", - `Betreff: ${data.subject}`, - "Deine Nachricht:", - data.message, - "", - "Viele Grüße", - "Tri-Hub Ernährung", - ].join("\n"), - }, - { - to: contactAddress, - subject: `Tri-Hub Kontaktanfrage: ${data.subject}`, - text: [ - "Neue Kontaktanfrage über die Ernährungsseite", - "", - `Name: ${data.name}`, - `E-Mail: ${data.email}`, - `Betreff: ${data.subject}`, - "", - "Nachricht:", - data.message, - ].join("\n"), - replyTo: data.email, - }, - ]; - - // Sendet beide Nachrichten und prüft, dass Mailpit beide Empfänger angenommen hat. - const results = await Promise.allSettled( - messages.map(async (message) => { - const result = await transport.sendMail({ from, ...message }); - if ( - !result.accepted?.some( - (address) => - address.toLowerCase() === message.to.toLowerCase(), - ) - ) { - throw new Error("SMTP hat den Empfänger nicht angenommen."); - } - }), - ); - // Ein Fehler bei mindestens einer Nachricht wird an den Service weitergegeben. - if (results.some((result) => result.status === "rejected")) { - throw new BookingError( - 502, - "CONTACT_EMAIL_FAILED", - "Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.", - ); - } - return { emailStatus: "accepted" }; - }; -} - -export { contactAddress }; +import { createMailTransport } from "./email-service.js"; +import { BookingError } from "./booking-service.js"; + +// Sichtbarer Empfänger in Mailpit; der Transport darunter bleibt lokal. +const contactAddress = "ernaehrung@tri-hub.de"; + +// Richtet den SMTP-Transport fest auf Mailpit ein und liefert den E-Mail-Versand zurück. +export function createContactFormEmailService( + env = process.env, + makeTransport, +) { + const { transport, from } = createMailTransport( + { + SMTP_HOST: "127.0.0.1", + SMTP_PORT: "1025", + SMTP_FROM: "Tri-Hub Ernährung ", + }, + makeTransport, + ); + + return async function sendContactForm(data) { + // Erstellt die Eingangsbestätigung für den Absender und die Anfrage für Tri-Hub. + const messages = [ + { + to: data.email, + subject: "Wir haben deine Nachricht erhalten – Tri-Hub", + text: [ + `Hallo ${data.name},`, + "", + "vielen Dank für deine Nachricht. Wir haben deine Anfrage erhalten und melden uns schnellstmöglich bei dir.", + "", + `Betreff: ${data.subject}`, + "Deine Nachricht:", + data.message, + "", + "Viele Grüße", + "Tri-Hub Ernährung", + ].join("\n"), + }, + { + to: contactAddress, + subject: `Tri-Hub Kontaktanfrage: ${data.subject}`, + text: [ + "Neue Kontaktanfrage über die Ernährungsseite", + "", + `Name: ${data.name}`, + `E-Mail: ${data.email}`, + `Betreff: ${data.subject}`, + "", + "Nachricht:", + data.message, + ].join("\n"), + replyTo: data.email, + }, + ]; + + // Sendet beide Nachrichten und prüft, dass Mailpit beide Empfänger angenommen hat. + const results = await Promise.allSettled( + messages.map(async (message) => { + const result = await transport.sendMail({ from, ...message }); + if ( + !result.accepted?.some( + (address) => + address.toLowerCase() === message.to.toLowerCase(), + ) + ) { + throw new Error("SMTP hat den Empfänger nicht angenommen."); + } + }), + ); + // Ein Fehler bei mindestens einer Nachricht wird an den Service weitergegeben. + if (results.some((result) => result.status === "rejected")) { + throw new BookingError( + 502, + "CONTACT_EMAIL_FAILED", + "Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.", + ); + } + return { emailStatus: "accepted" }; + }; +} + +export { contactAddress }; diff --git a/src/server/services/contact-form-service.js b/src/server/services/contact-form-service.js index 3013065..30f1000 100644 --- a/src/server/services/contact-form-service.js +++ b/src/server/services/contact-form-service.js @@ -1,90 +1,90 @@ -import { BookingError, emailPattern } from "./booking-service.js"; - -// Prüft und normalisiert die vom Browser übermittelten Kontaktangaben. -function normalizeInput(input) { - if (!input || typeof input !== "object" || Array.isArray(input)) { - throw new BookingError( - 400, - "INVALID_INPUT", - "Bitte alle Pflichtfelder prüfen.", - ); - } - - // Legt die zulässige Höchstlänge für jedes Formularfeld fest. - const limits = { name: 120, email: 254, subject: 160, message: 5000 }; - const fields = {}; - const data = {}; - for (const [field, max] of Object.entries(limits)) { - const value = input[field]; - if (typeof value !== "string") { - fields[field] = "Dieses Feld ist erforderlich."; - continue; - } - const trimmed = value.trim(); - if (!trimmed) fields[field] = "Dieses Feld ist erforderlich."; - else if (trimmed.length > max) - fields[field] = `Maximal ${max} Zeichen eingeben.`; - else if ( - (field === "message" - ? /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/ - : /[\x00-\x1f\x7f]/ - ).test(trimmed) - ) - fields[field] = "Bitte entferne ungültige Steuerzeichen."; - else data[field] = trimmed; - } - // Prüft zusätzlich das E-Mail-Format und die Mindestlänge der Nachricht. - if ( - data.email && - (!emailPattern.test(data.email) || data.email.split("@")[0].length > 64) - ) { - fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; - } - if (data.message && [...data.message].length < 10) { - fields.message = - "Deine Nachricht muss mindestens 10 Zeichen enthalten."; - } - // Gibt alle gefundenen Feldfehler gesammelt an die Route zurück. - if (Object.keys(fields).length) { - throw new BookingError( - 400, - "INVALID_INPUT", - "Bitte prüfe deine Eingaben.", - fields, - ); - } - return data; -} - -// Verbindet Validierung, CSV-Speicherung und anschließenden E-Mail-Versand. -export function createContactFormService({ storage, sendContactForm }) { - return { - async submit(input) { - const data = normalizeInput(input); - // Ergänzt einen Zeitstempel, damit die Anfrage zeitlich nachvollziehbar bleibt. - const record = { createdAt: new Date().toISOString(), ...data }; - // Speichert vor dem Versand, damit die Anfrage bei SMTP-Problemen erhalten bleibt. - try { - await storage.append(record); - } catch { - throw new BookingError( - 503, - "CONTACT_NOT_SAVED", - "Deine Nachricht konnte gerade nicht gespeichert werden. Bitte versuche es erneut.", - ); - } - // Sendet die beiden Mails erst nach erfolgreichem Speichern der Anfrage. - try { - await sendContactForm(record); - } catch (error) { - if (error instanceof BookingError) throw error; - throw new BookingError( - 502, - "CONTACT_EMAIL_FAILED", - "Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.", - ); - } - return { saved: true, emailStatus: "accepted" }; - }, - }; -} +import { BookingError, emailPattern } from "./booking-service.js"; + +// Prüft und normalisiert die vom Browser übermittelten Kontaktangaben. +function normalizeInput(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte alle Pflichtfelder prüfen.", + ); + } + + // Legt die zulässige Höchstlänge für jedes Formularfeld fest. + const limits = { name: 120, email: 254, subject: 160, message: 5000 }; + const fields = {}; + const data = {}; + for (const [field, max] of Object.entries(limits)) { + const value = input[field]; + if (typeof value !== "string") { + fields[field] = "Dieses Feld ist erforderlich."; + continue; + } + const trimmed = value.trim(); + if (!trimmed) fields[field] = "Dieses Feld ist erforderlich."; + else if (trimmed.length > max) + fields[field] = `Maximal ${max} Zeichen eingeben.`; + else if ( + (field === "message" + ? /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/ + : /[\x00-\x1f\x7f]/ + ).test(trimmed) + ) + fields[field] = "Bitte entferne ungültige Steuerzeichen."; + else data[field] = trimmed; + } + // Prüft zusätzlich das E-Mail-Format und die Mindestlänge der Nachricht. + if ( + data.email && + (!emailPattern.test(data.email) || data.email.split("@")[0].length > 64) + ) { + fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; + } + if (data.message && [...data.message].length < 10) { + fields.message = + "Deine Nachricht muss mindestens 10 Zeichen enthalten."; + } + // Gibt alle gefundenen Feldfehler gesammelt an die Route zurück. + if (Object.keys(fields).length) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte prüfe deine Eingaben.", + fields, + ); + } + return data; +} + +// Verbindet Validierung, CSV-Speicherung und anschließenden E-Mail-Versand. +export function createContactFormService({ storage, sendContactForm }) { + return { + async submit(input) { + const data = normalizeInput(input); + // Ergänzt einen Zeitstempel, damit die Anfrage zeitlich nachvollziehbar bleibt. + const record = { createdAt: new Date().toISOString(), ...data }; + // Speichert vor dem Versand, damit die Anfrage bei SMTP-Problemen erhalten bleibt. + try { + await storage.append(record); + } catch { + throw new BookingError( + 503, + "CONTACT_NOT_SAVED", + "Deine Nachricht konnte gerade nicht gespeichert werden. Bitte versuche es erneut.", + ); + } + // Sendet die beiden Mails erst nach erfolgreichem Speichern der Anfrage. + try { + await sendContactForm(record); + } catch (error) { + if (error instanceof BookingError) throw error; + throw new BookingError( + 502, + "CONTACT_EMAIL_FAILED", + "Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.", + ); + } + return { saved: true, emailStatus: "accepted" }; + }, + }; +} diff --git a/src/server/services/contact-form-storage.js b/src/server/services/contact-form-storage.js index 03e19fc..6ad09e1 100644 --- a/src/server/services/contact-form-storage.js +++ b/src/server/services/contact-form-storage.js @@ -1,93 +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 Kontaktanfragen-CSV. -const columns = ["createdAt", "name", "email", "subject", "message"]; - -// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird. -function protect(value) { - const text = String(value ?? ""); - return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text; -} - -// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her. -function restore(value) { - return value.startsWith("'") ? value.slice(1) : value; -} - -// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange. -export function createContactFormStorage(filePath) { - let queue = Promise.resolve(); - - // Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt. - async function readAll() { - let content; - try { - content = await readFile(filePath, "utf8"); - } catch (error) { - if (error.code === "ENOENT") return []; - throw error; - } - if (!content.trim()) throw new Error("Leere Kontaktdatei"); - return parse(content, { - bom: true, - columns(header) { - if (header.join(",") !== columns.join(",")) { - throw new Error("Unbekanntes Kontakt-CSV-Format"); - } - return header; - }, - skip_empty_lines: true, - }).map((row) => - Object.fromEntries( - Object.entries(row).map(([key, value]) => [ - key, - restore(value), - ]), - ), - ); - } - - // Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original. - async function writeAll(records) { - await mkdir(dirname(filePath), { recursive: true }); - const temporary = `${filePath}.${randomUUID()}.tmp`; - const content = stringify( - records.map((row) => - Object.fromEntries( - columns.map((column) => [column, protect(row[column])]), - ), - ), - { header: true, columns, record_delimiter: "\r\n" }, - ); - try { - const handle = await open(temporary, "wx", 0o600); - try { - await handle.writeFile(content, "utf8"); - await handle.sync(); - } finally { - await handle.close(); - } - await rename(temporary, filePath); - } finally { - await rm(temporary, { force: true }); - } - } - - return { - readAll, - // Hängt eine Anfrage nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren. - append(record) { - const operation = queue.then(async () => { - const records = await readAll(); - records.push(record); - await writeAll(records); - }); - queue = operation.catch(() => {}); - return operation; - }, - }; -} +import { mkdir, open, readFile, rename, rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import { parse } from "csv-parse/sync"; +import { stringify } from "csv-stringify/sync"; + +// Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV. +const columns = ["createdAt", "name", "email", "subject", "message"]; + +// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird. +function protect(value) { + const text = String(value ?? ""); + return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text; +} + +// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her. +function restore(value) { + return value.startsWith("'") ? value.slice(1) : value; +} + +// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange. +export function createContactFormStorage(filePath) { + let queue = Promise.resolve(); + + // Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt. + async function readAll() { + let content; + try { + content = await readFile(filePath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + if (!content.trim()) throw new Error("Leere Kontaktdatei"); + return parse(content, { + bom: true, + columns(header) { + if (header.join(",") !== columns.join(",")) { + throw new Error("Unbekanntes Kontakt-CSV-Format"); + } + return header; + }, + skip_empty_lines: true, + }).map((row) => + Object.fromEntries( + Object.entries(row).map(([key, value]) => [ + key, + restore(value), + ]), + ), + ); + } + + // Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original. + async function writeAll(records) { + await mkdir(dirname(filePath), { recursive: true }); + const temporary = `${filePath}.${randomUUID()}.tmp`; + const content = stringify( + records.map((row) => + Object.fromEntries( + columns.map((column) => [column, protect(row[column])]), + ), + ), + { header: true, columns, record_delimiter: "\r\n" }, + ); + try { + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(content, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, filePath); + } finally { + await rm(temporary, { force: true }); + } + } + + return { + readAll, + // Hängt eine Anfrage nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren. + append(record) { + const operation = queue.then(async () => { + const records = await readAll(); + records.push(record); + await writeAll(records); + }); + queue = operation.catch(() => {}); + return operation; + }, + }; +} diff --git a/src/server/services/contact-service.js b/src/server/services/contact-service.js index 6ba349f..6146748 100644 --- a/src/server/services/contact-service.js +++ b/src/server/services/contact-service.js @@ -1,108 +1,108 @@ -import { findAdvisor } from "../../shared/berater.js"; -import { BookingError, emailPattern } from "./booking-service.js"; -import { contactReasons, contactBookingUrl } from "../../shared/contact.js"; - -function advisorDetails(advisorId) { - const advisor = findAdvisor(advisorId); - if (!advisor) { - throw new BookingError( - 400, - "INVALID_ADVISOR", - "Bitte eine gültige Berater-ID übergeben.", - ); - } - if ( - typeof advisor.vorname !== "string" || - !advisor.vorname.trim() || - typeof advisor.email !== "string" || - advisor.email.length > 254 || - !emailPattern.test(advisor.email) || - advisor.email.split("@")[0].length > 64 || - !advisor.email.toLowerCase().endsWith("@tri-hub.de") - ) { - throw new BookingError( - 503, - "ADVISOR_UNAVAILABLE", - "Die Kontaktdaten dieses Beraters sind nicht verfügbar.", - ); - } - return { ...advisor }; -} - -export function createContactService({ storage, sendContact }) { - async function validate(input) { - if ( - !input || - typeof input !== "object" || - Array.isArray(input) || - typeof input.email !== "string" || - input.email.length > 254 - ) { - throw new BookingError( - 400, - "INVALID_EMAIL", - "Bitte eine gültige E-Mail-Adresse eingeben.", - ); - } - const email = input.email.trim().toLowerCase(); - if (!emailPattern.test(email) || email.split("@")[0].length > 64) { - throw new BookingError( - 400, - "INVALID_EMAIL", - "Bitte eine gültige E-Mail-Adresse eingeben.", - ); - } - let records; - try { - records = await storage.readAll(); - } catch { - throw new BookingError( - 503, - "STORAGE_UNAVAILABLE", - "Die Kundendaten können gerade nicht geprüft werden.", - ); - } - if (!records.some((row) => row.email?.trim().toLowerCase() === email)) { - throw new BookingError( - 403, - "CUSTOMER_NOT_FOUND", - "Zu dieser E-Mail-Adresse liegt keine Buchung vor. Bitte verwende die Adresse deiner Buchung.", - ); - } - return { email, valid: true }; - } - return { - validate, - async submit(input) { - const { email } = await validate(input); - const advisor = advisorDetails(input.advisorId); - if ( - typeof input.reason !== "string" || - !Object.hasOwn(contactReasons, input.reason) - ) { - throw new BookingError( - 400, - "INVALID_REASON", - "Bitte einen Kontaktgrund auswählen.", - ); - } - if (input.simulationAccepted !== true) { - throw new BookingError( - 400, - "SIMULATION_REQUIRED", - "Bitte den Simulationshinweis bestätigen.", - ); - } - await sendContact({ - email, - advisor, - reason: contactReasons[input.reason], - }); - return { - simulated: true, - emailStatus: "accepted", - redirectUrl: contactBookingUrl, - }; - }, - }; -} +import { findAdvisor } from "../../shared/berater.js"; +import { BookingError, emailPattern } from "./booking-service.js"; +import { contactReasons, contactBookingUrl } from "../../shared/contact.js"; + +function advisorDetails(advisorId) { + const advisor = findAdvisor(advisorId); + if (!advisor) { + throw new BookingError( + 400, + "INVALID_ADVISOR", + "Bitte eine gültige Berater-ID übergeben.", + ); + } + if ( + typeof advisor.vorname !== "string" || + !advisor.vorname.trim() || + typeof advisor.email !== "string" || + advisor.email.length > 254 || + !emailPattern.test(advisor.email) || + advisor.email.split("@")[0].length > 64 || + !advisor.email.toLowerCase().endsWith("@tri-hub.de") + ) { + throw new BookingError( + 503, + "ADVISOR_UNAVAILABLE", + "Die Kontaktdaten dieses Beraters sind nicht verfügbar.", + ); + } + return { ...advisor }; +} + +export function createContactService({ storage, sendContact }) { + async function validate(input) { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) || + typeof input.email !== "string" || + input.email.length > 254 + ) { + throw new BookingError( + 400, + "INVALID_EMAIL", + "Bitte eine gültige E-Mail-Adresse eingeben.", + ); + } + const email = input.email.trim().toLowerCase(); + if (!emailPattern.test(email) || email.split("@")[0].length > 64) { + throw new BookingError( + 400, + "INVALID_EMAIL", + "Bitte eine gültige E-Mail-Adresse eingeben.", + ); + } + let records; + try { + records = await storage.readAll(); + } catch { + throw new BookingError( + 503, + "STORAGE_UNAVAILABLE", + "Die Kundendaten können gerade nicht geprüft werden.", + ); + } + if (!records.some((row) => row.email?.trim().toLowerCase() === email)) { + throw new BookingError( + 403, + "CUSTOMER_NOT_FOUND", + "Zu dieser E-Mail-Adresse liegt keine Buchung vor. Bitte verwende die Adresse deiner Buchung.", + ); + } + return { email, valid: true }; + } + return { + validate, + async submit(input) { + const { email } = await validate(input); + const advisor = advisorDetails(input.advisorId); + if ( + typeof input.reason !== "string" || + !Object.hasOwn(contactReasons, input.reason) + ) { + throw new BookingError( + 400, + "INVALID_REASON", + "Bitte einen Kontaktgrund auswählen.", + ); + } + if (input.simulationAccepted !== true) { + throw new BookingError( + 400, + "SIMULATION_REQUIRED", + "Bitte den Simulationshinweis bestätigen.", + ); + } + await sendContact({ + email, + advisor, + reason: contactReasons[input.reason], + }); + return { + simulated: true, + emailStatus: "accepted", + redirectUrl: contactBookingUrl, + }; + }, + }; +} diff --git a/src/server/services/email-service.js b/src/server/services/email-service.js index be715c8..6796d37 100644 --- a/src/server/services/email-service.js +++ b/src/server/services/email-service.js @@ -1,132 +1,132 @@ -import nodemailer from "nodemailer"; -import { orderSummaryRows } from "../../shared/order-summary.js"; -import { paymentNotice } from "../../shared/booking-copy.js"; - -export function createMailTransport( - env = process.env, - makeTransport = nodemailer.createTransport, -) { - const localHosts = ["127.0.0.1", "localhost", "::1", "mailpit"]; - const host = env.SMTP_HOST || "127.0.0.1"; - const local = localHosts.includes(host); - if (!local && env.SMTP_ALLOW_EXTERNAL !== "true") { - throw new Error( - "Externes SMTP ist gesperrt. Erst nach ausdrücklicher Freigabe SMTP_ALLOW_EXTERNAL=true setzen.", - ); - } - const port = Number(env.SMTP_PORT || (local ? 1025 : 587)); - if (!Number.isInteger(port) || port < 1 || port > 65535) - throw new Error("Ungültiger SMTP_PORT"); - if (Boolean(env.SMTP_USER) !== Boolean(env.SMTP_PASS)) - throw new Error("SMTP_USER und SMTP_PASS gemeinsam setzen"); - const transport = makeTransport({ - host, - port, - secure: env.SMTP_SECURE === "true", - requireTLS: !local, - auth: env.SMTP_USER - ? { user: env.SMTP_USER, pass: env.SMTP_PASS } - : undefined, - connectionTimeout: 10000, - greetingTimeout: 10000, - socketTimeout: 20000, - disableFileAccess: true, - disableUrlAccess: true, - }); - const from = env.SMTP_FROM || "Tri-Hub "; - return { transport, from }; -} - -export function createEmailService( - env = process.env, - makeTransport = nodemailer.createTransport, -) { - const { transport, from } = createMailTransport(env, makeTransport); - return async function sendConfirmation(booking) { - const order = booking.bookedPackage; - const phone = booking.phone || booking.customer?.phone; - const lines = [ - `Hallo ${booking.name},`, - "", - "vielen Dank für deine Bestellung bei Tri-Hub. Hier findest du die Zusammenfassung deiner Buchung.", - `Buchungsnummer: ${booking.bookingId}`, - ...(booking.createdAt - ? [ - `Bestelldatum: ${new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(booking.createdAt))} (Europe/Berlin)`, - ] - : []), - `Kunde: ${booking.name}`, - `E-Mail: ${booking.email}`, - ...(phone ? [`Telefon: ${phone}`] : []), - ...(booking.customer?.notes - ? [`Deine Anmerkungen: ${booking.customer.notes}`] - : []), - "", - `Paket: ${order?.title ?? booking.packageName}`, - ...(order - ? [ - order.summaryForBooking, - order.subtitle, - "", - ...orderSummaryRows(order).map( - ([label, value]) => `${label}: ${value}`, - ), - "", - "Enthaltene Leistungen:", - ...order.includedFeatures.map( - (feature) => `• ${feature}`, - ), - "", - "So geht es weiter:", - ...(order.processSteps || []).map( - ({ step, text }) => `${step}: ${text}`, - ), - ] - : []), - "", - paymentNotice, - "", - "Bitte bewahre diese Bestätigung auf und gib bei Rückfragen deine Buchungsnummer an.", - "Dein Tri-Hub-Team", - ]; - const escapeHtml = (value) => - String(value).replace( - /[&<>"']/g, - (char) => - ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - })[char], - ); - let info; - try { - info = await transport.sendMail({ - from, - to: { address: booking.email, name: booking.name }, - subject: `Bestellbestätigung ${booking.bookingId} – Tri-Hub`, - text: lines.join("\n"), - html: `

Deine Bestellbestätigung

${lines.map((line) => (line ? `

${escapeHtml(line)}

` : "")).join("")}`, - }); - } catch (error) { - // Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen - // haben. Nur ausdrückliche Ablehnungen gelten sicher als Fehler. - const explicitRejection = - ["EAUTH", "EENVELOPE", "ECONNECTION", "EDNS"].includes( - error.code, - ) || Number(error.responseCode) >= 400; - error.deliveryUnknown = !explicitRejection; - throw error; - } - if ( - !info.accepted?.some( - (address) => - address.toLowerCase() === booking.email.toLowerCase(), - ) - ) { - throw new Error("Empfänger vom Mailserver nicht angenommen"); - } - }; -} +import nodemailer from "nodemailer"; +import { orderSummaryRows } from "../../shared/order-summary.js"; +import { paymentNotice } from "../../shared/booking-copy.js"; + +export function createMailTransport( + env = process.env, + makeTransport = nodemailer.createTransport, +) { + const localHosts = ["127.0.0.1", "localhost", "::1", "mailpit"]; + const host = env.SMTP_HOST || "127.0.0.1"; + const local = localHosts.includes(host); + if (!local && env.SMTP_ALLOW_EXTERNAL !== "true") { + throw new Error( + "Externes SMTP ist gesperrt. Erst nach ausdrücklicher Freigabe SMTP_ALLOW_EXTERNAL=true setzen.", + ); + } + const port = Number(env.SMTP_PORT || (local ? 1025 : 587)); + if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new Error("Ungültiger SMTP_PORT"); + if (Boolean(env.SMTP_USER) !== Boolean(env.SMTP_PASS)) + throw new Error("SMTP_USER und SMTP_PASS gemeinsam setzen"); + const transport = makeTransport({ + host, + port, + secure: env.SMTP_SECURE === "true", + requireTLS: !local, + auth: env.SMTP_USER + ? { user: env.SMTP_USER, pass: env.SMTP_PASS } + : undefined, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 20000, + disableFileAccess: true, + disableUrlAccess: true, + }); + const from = env.SMTP_FROM || "Tri-Hub "; + return { transport, from }; +} + +export function createEmailService( + env = process.env, + makeTransport = nodemailer.createTransport, +) { + const { transport, from } = createMailTransport(env, makeTransport); + return async function sendConfirmation(booking) { + const order = booking.bookedPackage; + const phone = booking.phone || booking.customer?.phone; + const lines = [ + `Hallo ${booking.name},`, + "", + "vielen Dank für deine Bestellung bei Tri-Hub. Hier findest du die Zusammenfassung deiner Buchung.", + `Buchungsnummer: ${booking.bookingId}`, + ...(booking.createdAt + ? [ + `Bestelldatum: ${new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(booking.createdAt))} (Europe/Berlin)`, + ] + : []), + `Kunde: ${booking.name}`, + `E-Mail: ${booking.email}`, + ...(phone ? [`Telefon: ${phone}`] : []), + ...(booking.customer?.notes + ? [`Deine Anmerkungen: ${booking.customer.notes}`] + : []), + "", + `Paket: ${order?.title ?? booking.packageName}`, + ...(order + ? [ + order.summaryForBooking, + order.subtitle, + "", + ...orderSummaryRows(order).map( + ([label, value]) => `${label}: ${value}`, + ), + "", + "Enthaltene Leistungen:", + ...order.includedFeatures.map( + (feature) => `• ${feature}`, + ), + "", + "So geht es weiter:", + ...(order.processSteps || []).map( + ({ step, text }) => `${step}: ${text}`, + ), + ] + : []), + "", + paymentNotice, + "", + "Bitte bewahre diese Bestätigung auf und gib bei Rückfragen deine Buchungsnummer an.", + "Dein Tri-Hub-Team", + ]; + const escapeHtml = (value) => + String(value).replace( + /[&<>"']/g, + (char) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[char], + ); + let info; + try { + info = await transport.sendMail({ + from, + to: { address: booking.email, name: booking.name }, + subject: `Bestellbestätigung ${booking.bookingId} – Tri-Hub`, + text: lines.join("\n"), + html: `

Deine Bestellbestätigung

${lines.map((line) => (line ? `

${escapeHtml(line)}

` : "")).join("")}`, + }); + } catch (error) { + // Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen + // haben. Nur ausdrückliche Ablehnungen gelten sicher als Fehler. + const explicitRejection = + ["EAUTH", "EENVELOPE", "ECONNECTION", "EDNS"].includes( + error.code, + ) || Number(error.responseCode) >= 400; + error.deliveryUnknown = !explicitRejection; + throw error; + } + if ( + !info.accepted?.some( + (address) => + address.toLowerCase() === booking.email.toLowerCase(), + ) + ) { + throw new Error("Empfänger vom Mailserver nicht angenommen"); + } + }; +} diff --git a/src/server/swagger.json b/src/server/swagger.json index a380052..8a43d82 100644 --- a/src/server/swagger.json +++ b/src/server/swagger.json @@ -1,982 +1,982 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "Tri-Hub Ernährungsberatung & Booking API", - "description": "API-Spezifikation für die Angebotsübersicht (US2.1), die Paket-Detailansicht und den Buchungsservice (US2.4 Paket buchen). Kontakt-Simulation mit CSV-Kundenvalidierung und Mailpit.", - "version": "1.2.0" - }, - "servers": [ - { - "url": "http://localhost:3000/api", - "description": "Lokaler Node.js Entwicklungsserver" - } - ], - "paths": { - "/packages": { - "get": { - "summary": "Alle Ernährungs-Pakete abrufen (Kurzübersicht)", - "description": "Liefert die Basisdaten aus packages.js für die Angebotsübersicht (US2.1).", - "tags": ["Packages"], - "responses": { - "200": { - "description": "Erfolgreiche Rückgabe der Paketliste", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PackageOverview" - } - } - } - } - } - } - } - }, - "/packages/{id}": { - "get": { - "summary": "Detaillierte Paketinformationen für Detailseite & Bestellprozess abrufen", - "description": "Liefert die ausführlichen Paketdaten aus packagesdetails.js (inkl. Preis, Laufzeit, Leistungen und Varianten), um sie auf der Detailseite und in der Bestellzusammenfassung des Booking-Service anzuzeigen.", - "tags": ["Packages", "Booking"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "description": "Eindeutige ID des Pakets (z. B. ernaehrung-starter)", - "schema": { - "type": "string", - "example": "ernaehrung-standard" - } - } - ], - "responses": { - "200": { - "description": "Detaillierte Paketdaten erfolgreich geladen", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackageDetail" - } - } - } - }, - "404": { - "description": "Paket mit dieser ID wurde nicht gefunden", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/bookings/preview": { - "post": { - "summary": "Preis- und Leistungsübersicht für den Bestellprozess berechnen", - "description": "Berechnet die Bestellübersicht aus dem serverseitigen Paketkatalog. Preisangaben aus der Anfrage werden ignoriert. Ohne Varianten-ID wird bei Paketen mit Varianten die erste Variante gewählt. Es wird keine Buchung angelegt.", - "tags": ["Booking"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingPreviewRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Berechnete Bestellübersicht für das Checkout-Formular", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingPreviewResponse" - } - } - } - }, - "400": { - "description": "Ungültige Paket- oder Varianten-ID", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/bookings": { - "post": { - "summary": "Paket verbindlich buchen (US2.4)", - "description": "Speichert Paket, gewählte Variante, Leistungen und Preise zum Buchungszeitpunkt und startet den Bestätigungsversand. Der aktuelle Ablauf simuliert die Bestellung ohne Zahlung.", - "tags": ["Booking"], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingCreateRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Buchung erfolgreich angelegt (Bestellbestätigung)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingConfirmation" - } - } - } - }, - "400": { - "description": "Fehlende oder ungültige Eingabedaten", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "200": { - "description": "Bereits gespeicherte Buchung; keine zweite Buchung oder Mail", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingConfirmation" - } - } - } - }, - "202": { - "description": "Buchung gespeichert; Versand ausstehend, fehlgeschlagen oder unklar", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BookingConfirmation" - } - } - } - }, - "409": { - "description": "Anfrageschlüssel wurde mit anderen Buchungsdaten verwendet", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "413": { - "description": "Anfrage größer als 8 KiB", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "415": { - "description": "JSON-Content-Type erforderlich", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "503": { - "description": "Speicherung oder Statusprüfung nicht verfügbar", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "parameters": [ - { - "name": "Idempotency-Key", - "in": "header", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - }, - "description": "UUID v4; bei Wiederholung denselben Schlüssel und dieselben Buchungsdaten verwenden." - } - ] - } - }, - "/contact/validate": { - "post": { - "tags": ["Contact"], - "summary": "Kunden-E-Mail prüfen", - "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email"], - "properties": { - "email": { - "type": "string", - "format": "email", - "maxLength": 254 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Kunde in CSV gefunden", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["valid", "email"], - "properties": { - "valid": { - "type": "boolean", - "enum": [true] - }, - "email": { - "type": "string", - "format": "email", - "maxLength": 254 - } - } - } - } - } - }, - "400": { - "description": "Ungültige Eingabe oder JSON", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "403": { - "description": "Keine Buchung zur E-Mail", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "405": { - "description": "Nur POST erlaubt", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "413": { - "description": "Mehr als 8 KiB", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "415": { - "description": "Content-Type muss application/json sein", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "503": { - "description": "CSV nicht lesbar", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "500": { - "description": "Unerwarteter Fehler", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - } - } - } - }, - "/contact": { - "post": { - "tags": ["Contact"], - "summary": "Beraterkontakt simulieren", - "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes. Prüft CSV bei jedem Absenden erneut. Versand ausschließlich an lokales Mailpit (127.0.0.1:1025), zwei getrennte Bestätigungen. Kein automatischer Neuversand, keine persistente Kontaktablage.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "email", - "advisorId", - "reason", - "simulationAccepted" - ], - "properties": { - "email": { - "type": "string", - "format": "email", - "maxLength": 254 - }, - "reason": { - "type": "string", - "enum": [ - "coaching", - "nutrition", - "competition", - "recovery", - "package", - "appointment", - "other" - ] - }, - "simulationAccepted": { - "type": "boolean", - "enum": [true] - }, - "advisorId": { - "type": "string", - "example": "relindis-agethen", - "description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen." - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Beide simulierten E-Mails von Mailpit angenommen; Weiterleitung möglich", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "simulated", - "emailStatus", - "redirectUrl" - ], - "properties": { - "simulated": { - "type": "boolean", - "enum": [true] - }, - "emailStatus": { - "type": "string", - "enum": ["accepted"] - }, - "redirectUrl": { - "type": "string", - "format": "uri", - "description": "Feste Microsoft-Bookings-Adresse; keine benutzerdefinierten Redirects." - } - } - } - } - } - }, - "400": { - "description": "Ungültige Eingabe oder JSON", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "403": { - "description": "Keine Buchung zur E-Mail", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "405": { - "description": "Nur POST erlaubt", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "413": { - "description": "Mehr als 8 KiB", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "415": { - "description": "Content-Type muss application/json sein", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "503": { - "description": "CSV nicht lesbar oder Beraterkontaktdaten ungültig", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "500": { - "description": "Unerwarteter Fehler", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - }, - "502": { - "description": "Mindestens eine Mailannahme fehlgeschlagen oder unklar; vor Wiederholung Mailpit prüfen", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactError" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "PackageOverview": { - "type": "object", - "required": [ - "id", - "type", - "title", - "description", - "fokus", - "anamnese", - "begleitung", - "buttonText" - ], - "properties": { - "id": { - "type": "string", - "example": "ernaehrung-standard" - }, - "type": { - "type": "string", - "example": "STANDARD" - }, - "title": { - "type": "string", - "example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen" - }, - "description": { - "type": "string" - }, - "fokus": { - "type": "string" - }, - "anamnese": { - "type": "string" - }, - "begleitung": { - "type": "string" - }, - "buttonText": { - "type": "string", - "example": "Standard-Paket ansehen" - }, - "highlight": { - "type": "string", - "nullable": true, - "example": "BESONDERS PASSEND" - } - } - }, - "PackageVariant": { - "type": "object", - "properties": { - "variantId": { - "type": "string", - "example": "ernaehrung-premium-24" - }, - "label": { - "type": "string", - "example": "24-Wochen-Variante" - }, - "durationWeeks": { - "type": "integer", - "example": 24 - }, - "price": { - "type": "number", - "format": "float", - "example": 799.0, - "description": "Bruttopreis der Variante inklusive MwSt." - } - } - }, - "PackageDetail": { - "type": "object", - "required": [ - "id", - "type", - "title", - "subtitle", - "durationWeeks", - "price", - "currency", - "taxRate", - "includedFeatures" - ], - "properties": { - "id": { - "type": "string", - "example": "ernaehrung-standard" - }, - "type": { - "type": "string", - "example": "STANDARD" - }, - "badge": { - "type": "string", - "example": "BESONDERS PASSEND" - }, - "title": { - "type": "string", - "example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen" - }, - "subtitle": { - "type": "string" - }, - "durationWeeks": { - "type": "integer", - "example": 24 - }, - "durationLabel": { - "type": "string", - "example": "24 Wochen Begleitung" - }, - "price": { - "type": "number", - "format": "float", - "example": 449.0, - "description": "Bruttopreis inklusive der angegebenen MwSt." - }, - "currency": { - "type": "string", - "example": "EUR" - }, - "billingInterval": { - "type": "string", - "example": "einmalig" - }, - "taxRate": { - "type": "integer", - "example": 19 - }, - "summaryForBooking": { - "type": "string", - "example": "Standard-Paket Sporternährung (24 Wochen Coaching)" - }, - "targetGroup": { - "type": "array", - "items": { - "type": "string" - } - }, - "includedFeatures": { - "type": "array", - "items": { - "type": "string" - } - }, - "processSteps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "text": { - "type": "string" - } - } - } - }, - "variants": { - "type": "array", - "nullable": true, - "items": { - "$ref": "#/components/schemas/PackageVariant" - } - }, - "ctaButtonText": { - "type": "string", - "example": "Standard-Paket jetzt buchen" - } - } - }, - "BookingPreviewRequest": { - "type": "object", - "required": ["packageId"], - "properties": { - "packageId": { - "type": "string", - "example": "ernaehrung-premium" - }, - "variantId": { - "type": "string", - "nullable": true, - "example": "ernaehrung-premium-52" - } - } - }, - "BookingPreviewResponse": { - "type": "object", - "properties": { - "packageId": { - "type": "string", - "example": "ernaehrung-premium" - }, - "variantId": { - "type": "string", - "example": "ernaehrung-premium-52", - "nullable": true - }, - "title": { - "type": "string", - "example": "Für maximale Individualität und intensive 1:1-Begleitung" - }, - "summaryForBooking": { - "type": "string" - }, - "durationWeeks": { - "type": "integer", - "example": 52 - }, - "netPrice": { - "type": "number", - "format": "float", - "example": 1175.63 - }, - "taxAmount": { - "type": "number", - "format": "float", - "example": 223.37 - }, - "grossPrice": { - "type": "number", - "format": "float", - "example": 1399.0 - }, - "currency": { - "type": "string", - "example": "EUR" - }, - "variantLabel": { - "type": "string", - "nullable": true - }, - "type": { - "type": "string" - }, - "subtitle": { - "type": "string" - }, - "durationLabel": { - "type": "string" - }, - "quantity": { - "type": "integer", - "enum": [1] - }, - "billingInterval": { - "type": "string", - "example": "einmalig" - }, - "taxRate": { - "type": "number", - "example": 19 - }, - "includedFeatures": { - "type": "array", - "items": { - "type": "string" - } - }, - "processSteps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "text": { - "type": "string" - } - } - } - } - }, - "required": [ - "packageId", - "variantId", - "title", - "summaryForBooking", - "durationWeeks", - "netPrice", - "taxAmount", - "grossPrice", - "currency", - "variantLabel", - "type", - "subtitle", - "durationLabel", - "quantity", - "billingInterval", - "taxRate", - "includedFeatures", - "processSteps" - ] - }, - "BookingCreateRequest": { - "type": "object", - "required": ["packageId"], - "properties": { - "packageId": { - "type": "string", - "example": "ernaehrung-standard" - }, - "variantId": { - "type": "string", - "nullable": true, - "example": "ernaehrung-premium-52" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 120, - "example": "Thomas Müller" - }, - "email": { - "type": "string", - "format": "email", - "maxLength": 254, - "example": "thomas.mueller@example.de" - }, - "phone": { - "type": "string", - "maxLength": 50, - "description": "Optionale Telefonnummer: deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl. Prüfung des Nummernformats, keine Bestätigung der Erreichbarkeit. Bei customer gilt customer.phone.", - "example": "+49 170 1234567" - }, - "customer": { - "type": "object", - "required": ["firstName", "lastName", "email"], - "properties": { - "firstName": { - "type": "string", - "example": "Thomas" - }, - "lastName": { - "type": "string", - "example": "Müller" - }, - "email": { - "type": "string", - "format": "email", - "example": "thomas.mueller@example.de" - }, - "phone": { - "type": "string", - "maxLength": 50, - "description": "Optional. Deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl; keine Bestätigung der Erreichbarkeit.", - "example": "+49 170 1234567" - }, - "ageGroup": { - "type": "string", - "example": "50-59" - }, - "notes": { - "type": "string", - "example": "Vorbereitung auf meine erste Mitteldistanz im August." - } - } - }, - "acceptedTerms": { - "type": "boolean", - "example": true, - "enum": [true] - } - }, - "anyOf": [ - { - "required": ["customer", "acceptedTerms"] - }, - { - "required": ["name", "email"] - } - ], - "description": "Kundendaten gemäß customer mit acceptedTerms=true. Bestehende Clients können alternativ name und email sowie optional phone übergeben." - }, - "BookingConfirmation": { - "type": "object", - "properties": { - "bookingId": { - "type": "string", - "example": "TH-000001" - }, - "status": { - "type": "string", - "example": "CONFIRMED" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "bookedPackage": { - "allOf": [ - { - "$ref": "#/components/schemas/BookingPreviewResponse" - } - ], - "nullable": true, - "description": "Gespeicherter Bestellstand; bei älteren Buchungen ohne diese Daten null." - }, - "customerEmail": { - "type": "string", - "example": "thomas.mueller@example.de" - }, - "packageId": { - "type": "string" - }, - "packageName": { - "type": "string" - }, - "saved": { - "type": "boolean" - }, - "emailStatus": { - "type": "string", - "enum": ["pending", "accepted", "failed", "unknown"], - "description": "accepted bedeutet SMTP-Annahme, keine bestätigte Zustellung." - }, - "replayed": { - "type": "boolean" - } - } - }, - "ErrorResponse": { - "type": "object", - "required": ["error"], - "properties": { - "error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "example": "INVALID_VARIANT" - }, - "message": { - "type": "string" - }, - "fields": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "ContactError": { - "type": "object", - "required": ["error"], - "properties": { - "error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } -} +{ + "openapi": "3.0.3", + "info": { + "title": "Tri-Hub Ernährungsberatung & Booking API", + "description": "API-Spezifikation für die Angebotsübersicht (US2.1), die Paket-Detailansicht und den Buchungsservice (US2.4 Paket buchen). Kontakt-Simulation mit CSV-Kundenvalidierung und Mailpit.", + "version": "1.2.0" + }, + "servers": [ + { + "url": "http://localhost:3000/api", + "description": "Lokaler Node.js Entwicklungsserver" + } + ], + "paths": { + "/packages": { + "get": { + "summary": "Alle Ernährungs-Pakete abrufen (Kurzübersicht)", + "description": "Liefert die Basisdaten aus packages.js für die Angebotsübersicht (US2.1).", + "tags": ["Packages"], + "responses": { + "200": { + "description": "Erfolgreiche Rückgabe der Paketliste", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackageOverview" + } + } + } + } + } + } + } + }, + "/packages/{id}": { + "get": { + "summary": "Detaillierte Paketinformationen für Detailseite & Bestellprozess abrufen", + "description": "Liefert die ausführlichen Paketdaten aus packagesdetails.js (inkl. Preis, Laufzeit, Leistungen und Varianten), um sie auf der Detailseite und in der Bestellzusammenfassung des Booking-Service anzuzeigen.", + "tags": ["Packages", "Booking"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Eindeutige ID des Pakets (z. B. ernaehrung-starter)", + "schema": { + "type": "string", + "example": "ernaehrung-standard" + } + } + ], + "responses": { + "200": { + "description": "Detaillierte Paketdaten erfolgreich geladen", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackageDetail" + } + } + } + }, + "404": { + "description": "Paket mit dieser ID wurde nicht gefunden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/bookings/preview": { + "post": { + "summary": "Preis- und Leistungsübersicht für den Bestellprozess berechnen", + "description": "Berechnet die Bestellübersicht aus dem serverseitigen Paketkatalog. Preisangaben aus der Anfrage werden ignoriert. Ohne Varianten-ID wird bei Paketen mit Varianten die erste Variante gewählt. Es wird keine Buchung angelegt.", + "tags": ["Booking"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingPreviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Berechnete Bestellübersicht für das Checkout-Formular", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingPreviewResponse" + } + } + } + }, + "400": { + "description": "Ungültige Paket- oder Varianten-ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/bookings": { + "post": { + "summary": "Paket verbindlich buchen (US2.4)", + "description": "Speichert Paket, gewählte Variante, Leistungen und Preise zum Buchungszeitpunkt und startet den Bestätigungsversand. Der aktuelle Ablauf simuliert die Bestellung ohne Zahlung.", + "tags": ["Booking"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Buchung erfolgreich angelegt (Bestellbestätigung)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingConfirmation" + } + } + } + }, + "400": { + "description": "Fehlende oder ungültige Eingabedaten", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "Bereits gespeicherte Buchung; keine zweite Buchung oder Mail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingConfirmation" + } + } + } + }, + "202": { + "description": "Buchung gespeichert; Versand ausstehend, fehlgeschlagen oder unklar", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingConfirmation" + } + } + } + }, + "409": { + "description": "Anfrageschlüssel wurde mit anderen Buchungsdaten verwendet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "413": { + "description": "Anfrage größer als 8 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "415": { + "description": "JSON-Content-Type erforderlich", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Speicherung oder Statusprüfung nicht verfügbar", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "UUID v4; bei Wiederholung denselben Schlüssel und dieselben Buchungsdaten verwenden." + } + ] + } + }, + "/contact/validate": { + "post": { + "tags": ["Contact"], + "summary": "Kunden-E-Mail prüfen", + "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Kunde in CSV gefunden", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["valid", "email"], + "properties": { + "valid": { + "type": "boolean", + "enum": [true] + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + } + } + } + } + } + }, + "400": { + "description": "Ungültige Eingabe oder JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "403": { + "description": "Keine Buchung zur E-Mail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "405": { + "description": "Nur POST erlaubt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "413": { + "description": "Mehr als 8 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "415": { + "description": "Content-Type muss application/json sein", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "503": { + "description": "CSV nicht lesbar", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "500": { + "description": "Unerwarteter Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + } + } + } + }, + "/contact": { + "post": { + "tags": ["Contact"], + "summary": "Beraterkontakt simulieren", + "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes. Prüft CSV bei jedem Absenden erneut. Versand ausschließlich an lokales Mailpit (127.0.0.1:1025), zwei getrennte Bestätigungen. Kein automatischer Neuversand, keine persistente Kontaktablage.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "advisorId", + "reason", + "simulationAccepted" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + }, + "reason": { + "type": "string", + "enum": [ + "coaching", + "nutrition", + "competition", + "recovery", + "package", + "appointment", + "other" + ] + }, + "simulationAccepted": { + "type": "boolean", + "enum": [true] + }, + "advisorId": { + "type": "string", + "example": "relindis-agethen", + "description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Beide simulierten E-Mails von Mailpit angenommen; Weiterleitung möglich", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "simulated", + "emailStatus", + "redirectUrl" + ], + "properties": { + "simulated": { + "type": "boolean", + "enum": [true] + }, + "emailStatus": { + "type": "string", + "enum": ["accepted"] + }, + "redirectUrl": { + "type": "string", + "format": "uri", + "description": "Feste Microsoft-Bookings-Adresse; keine benutzerdefinierten Redirects." + } + } + } + } + } + }, + "400": { + "description": "Ungültige Eingabe oder JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "403": { + "description": "Keine Buchung zur E-Mail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "405": { + "description": "Nur POST erlaubt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "413": { + "description": "Mehr als 8 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "415": { + "description": "Content-Type muss application/json sein", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "503": { + "description": "CSV nicht lesbar oder Beraterkontaktdaten ungültig", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "500": { + "description": "Unerwarteter Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "502": { + "description": "Mindestens eine Mailannahme fehlgeschlagen oder unklar; vor Wiederholung Mailpit prüfen", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "PackageOverview": { + "type": "object", + "required": [ + "id", + "type", + "title", + "description", + "fokus", + "anamnese", + "begleitung", + "buttonText" + ], + "properties": { + "id": { + "type": "string", + "example": "ernaehrung-standard" + }, + "type": { + "type": "string", + "example": "STANDARD" + }, + "title": { + "type": "string", + "example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen" + }, + "description": { + "type": "string" + }, + "fokus": { + "type": "string" + }, + "anamnese": { + "type": "string" + }, + "begleitung": { + "type": "string" + }, + "buttonText": { + "type": "string", + "example": "Standard-Paket ansehen" + }, + "highlight": { + "type": "string", + "nullable": true, + "example": "BESONDERS PASSEND" + } + } + }, + "PackageVariant": { + "type": "object", + "properties": { + "variantId": { + "type": "string", + "example": "ernaehrung-premium-24" + }, + "label": { + "type": "string", + "example": "24-Wochen-Variante" + }, + "durationWeeks": { + "type": "integer", + "example": 24 + }, + "price": { + "type": "number", + "format": "float", + "example": 799.0, + "description": "Bruttopreis der Variante inklusive MwSt." + } + } + }, + "PackageDetail": { + "type": "object", + "required": [ + "id", + "type", + "title", + "subtitle", + "durationWeeks", + "price", + "currency", + "taxRate", + "includedFeatures" + ], + "properties": { + "id": { + "type": "string", + "example": "ernaehrung-standard" + }, + "type": { + "type": "string", + "example": "STANDARD" + }, + "badge": { + "type": "string", + "example": "BESONDERS PASSEND" + }, + "title": { + "type": "string", + "example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen" + }, + "subtitle": { + "type": "string" + }, + "durationWeeks": { + "type": "integer", + "example": 24 + }, + "durationLabel": { + "type": "string", + "example": "24 Wochen Begleitung" + }, + "price": { + "type": "number", + "format": "float", + "example": 449.0, + "description": "Bruttopreis inklusive der angegebenen MwSt." + }, + "currency": { + "type": "string", + "example": "EUR" + }, + "billingInterval": { + "type": "string", + "example": "einmalig" + }, + "taxRate": { + "type": "integer", + "example": 19 + }, + "summaryForBooking": { + "type": "string", + "example": "Standard-Paket Sporternährung (24 Wochen Coaching)" + }, + "targetGroup": { + "type": "array", + "items": { + "type": "string" + } + }, + "includedFeatures": { + "type": "array", + "items": { + "type": "string" + } + }, + "processSteps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + }, + "variants": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/PackageVariant" + } + }, + "ctaButtonText": { + "type": "string", + "example": "Standard-Paket jetzt buchen" + } + } + }, + "BookingPreviewRequest": { + "type": "object", + "required": ["packageId"], + "properties": { + "packageId": { + "type": "string", + "example": "ernaehrung-premium" + }, + "variantId": { + "type": "string", + "nullable": true, + "example": "ernaehrung-premium-52" + } + } + }, + "BookingPreviewResponse": { + "type": "object", + "properties": { + "packageId": { + "type": "string", + "example": "ernaehrung-premium" + }, + "variantId": { + "type": "string", + "example": "ernaehrung-premium-52", + "nullable": true + }, + "title": { + "type": "string", + "example": "Für maximale Individualität und intensive 1:1-Begleitung" + }, + "summaryForBooking": { + "type": "string" + }, + "durationWeeks": { + "type": "integer", + "example": 52 + }, + "netPrice": { + "type": "number", + "format": "float", + "example": 1175.63 + }, + "taxAmount": { + "type": "number", + "format": "float", + "example": 223.37 + }, + "grossPrice": { + "type": "number", + "format": "float", + "example": 1399.0 + }, + "currency": { + "type": "string", + "example": "EUR" + }, + "variantLabel": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string" + }, + "subtitle": { + "type": "string" + }, + "durationLabel": { + "type": "string" + }, + "quantity": { + "type": "integer", + "enum": [1] + }, + "billingInterval": { + "type": "string", + "example": "einmalig" + }, + "taxRate": { + "type": "number", + "example": 19 + }, + "includedFeatures": { + "type": "array", + "items": { + "type": "string" + } + }, + "processSteps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + } + }, + "required": [ + "packageId", + "variantId", + "title", + "summaryForBooking", + "durationWeeks", + "netPrice", + "taxAmount", + "grossPrice", + "currency", + "variantLabel", + "type", + "subtitle", + "durationLabel", + "quantity", + "billingInterval", + "taxRate", + "includedFeatures", + "processSteps" + ] + }, + "BookingCreateRequest": { + "type": "object", + "required": ["packageId"], + "properties": { + "packageId": { + "type": "string", + "example": "ernaehrung-standard" + }, + "variantId": { + "type": "string", + "nullable": true, + "example": "ernaehrung-premium-52" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "example": "Thomas Müller" + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 254, + "example": "thomas.mueller@example.de" + }, + "phone": { + "type": "string", + "maxLength": 50, + "description": "Optionale Telefonnummer: deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl. Prüfung des Nummernformats, keine Bestätigung der Erreichbarkeit. Bei customer gilt customer.phone.", + "example": "+49 170 1234567" + }, + "customer": { + "type": "object", + "required": ["firstName", "lastName", "email"], + "properties": { + "firstName": { + "type": "string", + "example": "Thomas" + }, + "lastName": { + "type": "string", + "example": "Müller" + }, + "email": { + "type": "string", + "format": "email", + "example": "thomas.mueller@example.de" + }, + "phone": { + "type": "string", + "maxLength": 50, + "description": "Optional. Deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl; keine Bestätigung der Erreichbarkeit.", + "example": "+49 170 1234567" + }, + "ageGroup": { + "type": "string", + "example": "50-59" + }, + "notes": { + "type": "string", + "example": "Vorbereitung auf meine erste Mitteldistanz im August." + } + } + }, + "acceptedTerms": { + "type": "boolean", + "example": true, + "enum": [true] + } + }, + "anyOf": [ + { + "required": ["customer", "acceptedTerms"] + }, + { + "required": ["name", "email"] + } + ], + "description": "Kundendaten gemäß customer mit acceptedTerms=true. Bestehende Clients können alternativ name und email sowie optional phone übergeben." + }, + "BookingConfirmation": { + "type": "object", + "properties": { + "bookingId": { + "type": "string", + "example": "TH-000001" + }, + "status": { + "type": "string", + "example": "CONFIRMED" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "bookedPackage": { + "allOf": [ + { + "$ref": "#/components/schemas/BookingPreviewResponse" + } + ], + "nullable": true, + "description": "Gespeicherter Bestellstand; bei älteren Buchungen ohne diese Daten null." + }, + "customerEmail": { + "type": "string", + "example": "thomas.mueller@example.de" + }, + "packageId": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "saved": { + "type": "boolean" + }, + "emailStatus": { + "type": "string", + "enum": ["pending", "accepted", "failed", "unknown"], + "description": "accepted bedeutet SMTP-Annahme, keine bestätigte Zustellung." + }, + "replayed": { + "type": "boolean" + } + } + }, + "ErrorResponse": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "example": "INVALID_VARIANT" + }, + "message": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "ContactError": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/src/shared/berater-daten.json b/src/shared/berater-daten.json index 1bdfa3f..797cff4 100644 --- a/src/shared/berater-daten.json +++ b/src/shared/berater-daten.json @@ -1,64 +1,64 @@ -[ - { - "id": "relindis-agethen", - "name": "Relindis Agethen (Lilli)", - "email": "relindis.agethen@tri-hub.de", - "titel": "Triathletin und Ernährungsberaterin", - "biographie": "Relindis Agethen, genannt Lilli, ist seit 16 Jahren im Triathlon aktiv und bringt starke sportliche Erfolge mit: Vize-Europameisterin, Deutsche Meisterin und mehrfache Baden-Württembergische Meisterin. Als zertifizierte Ernährungsberaterin für Sportler B-Lizenz ergänzt sie das Team um Leistungsentwicklung und Ernährungswissen.", - "qualifikationen": [ - "Zertifizierte Ernährungsberaterin für Sportler (B-Lizenz)", - "Vize-Europameisterin", - "Deutsche Meisterin", - "Mehrfache Baden-Württembergische Meisterin" - ], - "schwerpunkte": [ - "Ernährung", - "Leistungssport-Perspektive", - "Wettkampforientierte Entwicklung" - ], - "bildquelle": "", - "vorname": "Relindis", - "nachname": "Agethen", - "spitzname": "Lilli", - "bilder": [ - { - "quelle": "/images/Lilli1.jpeg", - "typ": "Porträt" - }, - { - "quelle": "/images/Lilli2.jpeg", - "typ": "Aktionsbild" - } - ] - }, - { - "id": "maren-hoffmann", - "name": "Maren Hoffmann", - "email": "maren.hoffmann@tri-hub.de", - "titel": "Triathletin und Ernährungsberaterin", - "biographie": "Maren Hoffmann verbindet ihre langjährige Erfahrung im Ausdauersport mit ihrem Wissen aus der Ernährungsberatung. Sie unterstützt Athletinnen und Athleten dabei, Training und Ernährung alltagstauglich aufeinander abzustimmen und sich gezielt auf Wettkämpfe vorzubereiten.", - "qualifikationen": [ - "Ernährungsberaterin für Sporternährung", - "Langjährige Erfahrung im Ausdauersport" - ], - "schwerpunkte": [ - "Ernährung im Ausdauertraining", - "Wettkampfverpflegung", - "Alltagstaugliche Ernährungsroutinen" - ], - "bildquelle": "", - "vorname": "Maren", - "nachname": "Hoffmann", - "spitzname": "", - "bilder": [ - { - "quelle": "/images/Maren1.jpeg", - "typ": "Porträt" - }, - { - "quelle": "/images/Maren2.jpeg", - "typ": "Aktionsbild" - } - ] - } -] +[ + { + "id": "relindis-agethen", + "name": "Relindis Agethen (Lilli)", + "email": "relindis.agethen@tri-hub.de", + "titel": "Triathletin und Ernährungsberaterin", + "biographie": "Relindis Agethen, genannt Lilli, ist seit 16 Jahren im Triathlon aktiv und bringt starke sportliche Erfolge mit: Vize-Europameisterin, Deutsche Meisterin und mehrfache Baden-Württembergische Meisterin. Als zertifizierte Ernährungsberaterin für Sportler B-Lizenz ergänzt sie das Team um Leistungsentwicklung und Ernährungswissen.", + "qualifikationen": [ + "Zertifizierte Ernährungsberaterin für Sportler (B-Lizenz)", + "Vize-Europameisterin", + "Deutsche Meisterin", + "Mehrfache Baden-Württembergische Meisterin" + ], + "schwerpunkte": [ + "Ernährung", + "Leistungssport-Perspektive", + "Wettkampforientierte Entwicklung" + ], + "bildquelle": "", + "vorname": "Relindis", + "nachname": "Agethen", + "spitzname": "Lilli", + "bilder": [ + { + "quelle": "/images/Lilli1.jpeg", + "typ": "Porträt" + }, + { + "quelle": "/images/Lilli2.jpeg", + "typ": "Aktionsbild" + } + ] + }, + { + "id": "maren-hoffmann", + "name": "Maren Hoffmann", + "email": "maren.hoffmann@tri-hub.de", + "titel": "Triathletin und Ernährungsberaterin", + "biographie": "Maren Hoffmann verbindet ihre langjährige Erfahrung im Ausdauersport mit ihrem Wissen aus der Ernährungsberatung. Sie unterstützt Athletinnen und Athleten dabei, Training und Ernährung alltagstauglich aufeinander abzustimmen und sich gezielt auf Wettkämpfe vorzubereiten.", + "qualifikationen": [ + "Ernährungsberaterin für Sporternährung", + "Langjährige Erfahrung im Ausdauersport" + ], + "schwerpunkte": [ + "Ernährung im Ausdauertraining", + "Wettkampfverpflegung", + "Alltagstaugliche Ernährungsroutinen" + ], + "bildquelle": "", + "vorname": "Maren", + "nachname": "Hoffmann", + "spitzname": "", + "bilder": [ + { + "quelle": "/images/Maren1.jpeg", + "typ": "Porträt" + }, + { + "quelle": "/images/Maren2.jpeg", + "typ": "Aktionsbild" + } + ] + } +] diff --git a/src/shared/berater.js b/src/shared/berater.js index f724079..3cf1419 100644 --- a/src/shared/berater.js +++ b/src/shared/berater.js @@ -1,11 +1,11 @@ -import berater from "./berater-daten.json" with { type: "json" }; - -export function findAdvisor(advisorId) { - return typeof advisorId === "string" - ? berater.find((entry) => entry.id === advisorId) - : undefined; -} - -export function advisorDisplayName(advisor) { - return advisor?.spitzname?.trim() || advisor?.vorname?.trim() || ""; -} +import berater from "./berater-daten.json" with { type: "json" }; + +export function findAdvisor(advisorId) { + return typeof advisorId === "string" + ? berater.find((entry) => entry.id === advisorId) + : undefined; +} + +export function advisorDisplayName(advisor) { + return advisor?.spitzname?.trim() || advisor?.vorname?.trim() || ""; +} diff --git a/src/shared/contact.js b/src/shared/contact.js index 41ae552..5a7a0fb 100644 --- a/src/shared/contact.js +++ b/src/shared/contact.js @@ -1,15 +1,15 @@ -export const contactReasons = { - coaching: "Persönliches Coaching und Erstgespräch", - nutrition: "Ernährungsplan und Anpassungen", - competition: "Wettkampfverpflegung und Hydration", - recovery: "Regeneration und Verträglichkeit", - package: "Fragen zum gebuchten Paket", - appointment: "Termin vereinbaren oder ändern", - other: "Sonstiges Anliegen", -}; - -export const contactNotice = - "Dies ist eine Simulation des Kontaktprozesses. Die Bestätigungen werden nur im lokalen Mailpit gesammelt. Es wird kein Berater tatsächlich kontaktiert. Die Weiterleitung öffnet die externe Microsoft-Bookings-Seite; dort wird hierdurch kein Termin gebucht."; - -export const contactBookingUrl = - "https://bookings.cloud.microsoft/bookwithme/user/b055625c7e5b4bf3866230ffa066536b%40Tri-hub.de/meetingtype/qkJP99nqg0a_Na5Z-gY9WQ2?anonymous&ismsaljsauthenabled"; +export const contactReasons = { + coaching: "Persönliches Coaching und Erstgespräch", + nutrition: "Ernährungsplan und Anpassungen", + competition: "Wettkampfverpflegung und Hydration", + recovery: "Regeneration und Verträglichkeit", + package: "Fragen zum gebuchten Paket", + appointment: "Termin vereinbaren oder ändern", + other: "Sonstiges Anliegen", +}; + +export const contactNotice = + "Dies ist eine Simulation des Kontaktprozesses. Die Bestätigungen werden nur im lokalen Mailpit gesammelt. Es wird kein Berater tatsächlich kontaktiert. Die Weiterleitung öffnet die externe Microsoft-Bookings-Seite; dort wird hierdurch kein Termin gebucht."; + +export const contactBookingUrl = + "https://bookings.cloud.microsoft/bookwithme/user/b055625c7e5b4bf3866230ffa066536b%40Tri-hub.de/meetingtype/qkJP99nqg0a_Na5Z-gY9WQ2?anonymous&ismsaljsauthenabled"; diff --git a/src/tests/berater.test.js b/src/tests/berater.test.js index 982833b..31f78ca 100644 --- a/src/tests/berater.test.js +++ b/src/tests/berater.test.js @@ -1,83 +1,83 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { access, readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import berater from "../shared/berater-daten.json" with { type: "json" }; -import { advisorDisplayName, findAdvisor } from "../shared/berater.js"; - -const projectRoot = fileURLToPath(new URL("../../", import.meta.url)); - -test("Beraterdaten haben eindeutige IDs und vollständige Profilfelder", () => { - assert.ok(berater.length >= 2); - assert.equal(new Set(berater.map(({ id }) => id)).size, berater.length); - - for (const person of berater) { - assert.ok(person.id.trim()); - assert.ok(person.name.trim()); - assert.match(person.email, /^[^@\s]+@tri-hub\.de$/); - assert.ok(person.biographie.trim()); - assert.ok(Array.isArray(person.qualifikationen)); - assert.ok(Array.isArray(person.schwerpunkte)); - assert.ok("bildquelle" in person); - } -}); - -test("Berater lassen sich per ID finden; unbekannte IDs liefern undefined", () => { - assert.equal(findAdvisor("relindis-agethen")?.spitzname, "Lilli"); - assert.equal(findAdvisor("maren-hoffmann")?.vorname, "Maren"); - assert.equal(findAdvisor("nicht-vorhanden"), undefined); - assert.equal(findAdvisor(null), undefined); -}); - -test("Kontaktname bevorzugt Spitzname und fällt auf den Vornamen zurück", () => { - assert.equal( - advisorDisplayName({ spitzname: " Lilli ", vorname: "Relindis" }), - "Lilli", - ); - assert.equal( - advisorDisplayName({ spitzname: "", vorname: "Maren" }), - "Maren", - ); - assert.equal( - advisorDisplayName({ spitzname: null, vorname: "Maren" }), - "Maren", - ); - assert.equal(advisorDisplayName({ spitzname: " ", vorname: " " }), ""); -}); - -test("jedes Profil hat Porträt und Aktionsbild, deren Dateien vorhanden sind", async () => { - for (const person of berater) { - assert.equal( - person.bilder?.length, - 2, - `${person.id} muss zwei Bilder haben`, - ); - assert.deepEqual( - person.bilder.map(({ typ }) => typ), - ["Porträt", "Aktionsbild"], - ); - - for (const { quelle } of person.bilder) { - assert.match(quelle, /^\/images\//); - await access(join(projectRoot, "public", quelle.slice(1))); - } - } -}); - -test("Profilseite ist separat verlinkt und die Landingpage rendert keine Beraterkarten", async () => { - const [page, landingPage, navigation] = await Promise.all([ - readFile(join(projectRoot, "berater.html"), "utf8"), - readFile(join(projectRoot, "index.html"), "utf8"), - readFile( - join(projectRoot, "src/components/navigationsbar/navigation.html"), - "utf8", - ), - ]); - - assert.match(page, /id="advisor-profiles"/); - assert.match(page, /src="\/src\/features\/berater\/berater-entry\.js"/); - assert.match(navigation, /href="\/berater\.html"[^>]*>Beraterprofil/); - assert.doesNotMatch(landingPage, /id="advisor-profiles"/); - assert.doesNotMatch(landingPage, /berater-entry\.js/); -}); +import test from "node:test"; +import assert from "node:assert/strict"; +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import berater from "../shared/berater-daten.json" with { type: "json" }; +import { advisorDisplayName, findAdvisor } from "../shared/berater.js"; + +const projectRoot = fileURLToPath(new URL("../../", import.meta.url)); + +test("Beraterdaten haben eindeutige IDs und vollständige Profilfelder", () => { + assert.ok(berater.length >= 2); + assert.equal(new Set(berater.map(({ id }) => id)).size, berater.length); + + for (const person of berater) { + assert.ok(person.id.trim()); + assert.ok(person.name.trim()); + assert.match(person.email, /^[^@\s]+@tri-hub\.de$/); + assert.ok(person.biographie.trim()); + assert.ok(Array.isArray(person.qualifikationen)); + assert.ok(Array.isArray(person.schwerpunkte)); + assert.ok("bildquelle" in person); + } +}); + +test("Berater lassen sich per ID finden; unbekannte IDs liefern undefined", () => { + assert.equal(findAdvisor("relindis-agethen")?.spitzname, "Lilli"); + assert.equal(findAdvisor("maren-hoffmann")?.vorname, "Maren"); + assert.equal(findAdvisor("nicht-vorhanden"), undefined); + assert.equal(findAdvisor(null), undefined); +}); + +test("Kontaktname bevorzugt Spitzname und fällt auf den Vornamen zurück", () => { + assert.equal( + advisorDisplayName({ spitzname: " Lilli ", vorname: "Relindis" }), + "Lilli", + ); + assert.equal( + advisorDisplayName({ spitzname: "", vorname: "Maren" }), + "Maren", + ); + assert.equal( + advisorDisplayName({ spitzname: null, vorname: "Maren" }), + "Maren", + ); + assert.equal(advisorDisplayName({ spitzname: " ", vorname: " " }), ""); +}); + +test("jedes Profil hat Porträt und Aktionsbild, deren Dateien vorhanden sind", async () => { + for (const person of berater) { + assert.equal( + person.bilder?.length, + 2, + `${person.id} muss zwei Bilder haben`, + ); + assert.deepEqual( + person.bilder.map(({ typ }) => typ), + ["Porträt", "Aktionsbild"], + ); + + for (const { quelle } of person.bilder) { + assert.match(quelle, /^\/images\//); + await access(join(projectRoot, "public", quelle.slice(1))); + } + } +}); + +test("Profilseite ist separat verlinkt und die Landingpage rendert keine Beraterkarten", async () => { + const [page, landingPage, navigation] = await Promise.all([ + readFile(join(projectRoot, "berater.html"), "utf8"), + readFile(join(projectRoot, "index.html"), "utf8"), + readFile( + join(projectRoot, "src/components/navigationsbar/navigation.html"), + "utf8", + ), + ]); + + assert.match(page, /id="advisor-profiles"/); + assert.match(page, /src="\/src\/features\/berater\/berater-entry\.js"/); + assert.match(navigation, /href="\/berater\.html"[^>]*>Beraterprofil/); + assert.doesNotMatch(landingPage, /id="advisor-profiles"/); + assert.doesNotMatch(landingPage, /berater-entry\.js/); +}); diff --git a/src/tests/browser/berater.spec.js b/src/tests/browser/berater.spec.js index d2db396..45ffd41 100644 --- a/src/tests/browser/berater.spec.js +++ b/src/tests/browser/berater.spec.js @@ -1,113 +1,113 @@ -import { test, expect } from "@playwright/test"; - -test.beforeEach(async ({ page }) => { - await page.goto("/berater.html"); -}); - -test("Navigation öffnet die separate Beraterseite und Profile werden geladen", async ({ - page, -}) => { - const profileLink = page.locator('.navbar__link[href="/berater.html"]'); - if (!(await profileLink.isVisible())) { - await page.getByRole("button", { name: "Menü öffnen" }).click(); - } - await expect(profileLink).toBeVisible(); - await expect( - page.getByRole("heading", { name: /Ernährungsberater/ }), - ).toBeVisible(); - - const cards = page.locator(".advisor-card"); - await expect(cards).toHaveCount(2); - await expect(cards.nth(0)).toContainText("Relindis Agethen (Lilli)"); - await expect(cards.nth(0)).toContainText("Kurzbiografie"); - await expect(cards.nth(0)).toContainText("Qualifikationen"); - await expect(cards.nth(0)).toContainText("Schwerpunkte"); - const roleBackground = await cards - .nth(0) - .locator(".advisor-role") - .evaluate((element) => getComputedStyle(element).backgroundColor); - const detailsBackground = await cards - .nth(0) - .locator(".package-details-box") - .evaluate((element) => getComputedStyle(element).backgroundColor); - expect(roleBackground).toBe("rgba(0, 0, 0, 0)"); - expect(detailsBackground).not.toBe("rgba(0, 0, 0, 0)"); - await expect( - cards.nth(0).getByRole("button", { name: "Lilli kontaktieren" }), - ).toBeVisible(); - await expect( - cards.nth(1).getByRole("button", { name: "Maren kontaktieren" }), - ).toBeVisible(); - - await cards - .nth(0) - .getByRole("button", { name: "Lilli kontaktieren" }) - .click(); - const contactDialog = page.getByRole("dialog"); - await expect(contactDialog).toBeVisible(); - await expect(contactDialog).toContainText("Lilli"); -}); - -test("Beraterbilder sind präsent und lassen sich mit Karussell-Pfeilen wechseln", async ({ - page, -}) => { - const lilli = page - .locator(".advisor-card") - .filter({ hasText: "Relindis Agethen" }); - const image = lilli.locator( - '.advisor-gallery__slide[aria-hidden="false"] .advisor-gallery__image', - ); - const track = lilli.locator(".advisor-gallery__track"); - const previous = lilli.getByRole("button", { - name: "Vorheriges Bild von Relindis Agethen (Lilli)", - }); - const next = lilli.getByRole("button", { - name: "Nächstes Bild von Relindis Agethen (Lilli)", - }); - const portraitDot = lilli.getByRole("button", { name: "Bild 1: Porträt" }); - const actionDot = lilli.getByRole("button", { - name: "Bild 2: Aktionsbild", - }); - - await expect(image).toBeVisible(); - await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg"); - const bounds = await image.boundingBox(); - expect(bounds.width).toBeGreaterThan(150); - expect(bounds.height).toBeGreaterThan(150); - await expect(portraitDot).toHaveAttribute("aria-current", "true"); - await expect(actionDot).toHaveAttribute("aria-current", "false"); - - await next.click(); - await expect(track).toHaveCSS("transform", /matrix\(1, 0, 0, 1, -/); - await expect(image).toHaveAttribute("src", "/images/Lilli2.jpeg"); - await expect(image).toHaveAttribute( - "alt", - "Relindis Agethen (Lilli) – Aktionsbild", - ); - await expect(actionDot).toHaveAttribute("aria-current", "true"); - await expect(portraitDot).toHaveAttribute("aria-current", "false"); - - await previous.click(); - await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg"); - await expect(portraitDot).toHaveAttribute("aria-current", "true"); - - await portraitDot.click(); - await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg"); -}); - -test("Maren hat ebenfalls ein umschaltbares Porträt- und Aktionsbild", async ({ - page, -}) => { - const maren = page - .locator(".advisor-card") - .filter({ hasText: "Maren Hoffmann" }); - const image = maren.locator( - '.advisor-gallery__slide[aria-hidden="false"] .advisor-gallery__image', - ); - - await expect(image).toHaveAttribute("src", "/images/Maren1.jpeg"); - await maren - .getByRole("button", { name: "Nächstes Bild von Maren Hoffmann" }) - .click(); - await expect(image).toHaveAttribute("src", "/images/Maren2.jpeg"); -}); +import { test, expect } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/berater.html"); +}); + +test("Navigation öffnet die separate Beraterseite und Profile werden geladen", async ({ + page, +}) => { + const profileLink = page.locator('.navbar__link[href="/berater.html"]'); + if (!(await profileLink.isVisible())) { + await page.getByRole("button", { name: "Menü öffnen" }).click(); + } + await expect(profileLink).toBeVisible(); + await expect( + page.getByRole("heading", { name: /Ernährungsberater/ }), + ).toBeVisible(); + + const cards = page.locator(".advisor-card"); + await expect(cards).toHaveCount(2); + await expect(cards.nth(0)).toContainText("Relindis Agethen (Lilli)"); + await expect(cards.nth(0)).toContainText("Kurzbiografie"); + await expect(cards.nth(0)).toContainText("Qualifikationen"); + await expect(cards.nth(0)).toContainText("Schwerpunkte"); + const roleBackground = await cards + .nth(0) + .locator(".advisor-role") + .evaluate((element) => getComputedStyle(element).backgroundColor); + const detailsBackground = await cards + .nth(0) + .locator(".package-details-box") + .evaluate((element) => getComputedStyle(element).backgroundColor); + expect(roleBackground).toBe("rgba(0, 0, 0, 0)"); + expect(detailsBackground).not.toBe("rgba(0, 0, 0, 0)"); + await expect( + cards.nth(0).getByRole("button", { name: "Lilli kontaktieren" }), + ).toBeVisible(); + await expect( + cards.nth(1).getByRole("button", { name: "Maren kontaktieren" }), + ).toBeVisible(); + + await cards + .nth(0) + .getByRole("button", { name: "Lilli kontaktieren" }) + .click(); + const contactDialog = page.getByRole("dialog"); + await expect(contactDialog).toBeVisible(); + await expect(contactDialog).toContainText("Lilli"); +}); + +test("Beraterbilder sind präsent und lassen sich mit Karussell-Pfeilen wechseln", async ({ + page, +}) => { + const lilli = page + .locator(".advisor-card") + .filter({ hasText: "Relindis Agethen" }); + const image = lilli.locator( + '.advisor-gallery__slide[aria-hidden="false"] .advisor-gallery__image', + ); + const track = lilli.locator(".advisor-gallery__track"); + const previous = lilli.getByRole("button", { + name: "Vorheriges Bild von Relindis Agethen (Lilli)", + }); + const next = lilli.getByRole("button", { + name: "Nächstes Bild von Relindis Agethen (Lilli)", + }); + const portraitDot = lilli.getByRole("button", { name: "Bild 1: Porträt" }); + const actionDot = lilli.getByRole("button", { + name: "Bild 2: Aktionsbild", + }); + + await expect(image).toBeVisible(); + await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg"); + const bounds = await image.boundingBox(); + expect(bounds.width).toBeGreaterThan(150); + expect(bounds.height).toBeGreaterThan(150); + await expect(portraitDot).toHaveAttribute("aria-current", "true"); + await expect(actionDot).toHaveAttribute("aria-current", "false"); + + await next.click(); + await expect(track).toHaveCSS("transform", /matrix\(1, 0, 0, 1, -/); + await expect(image).toHaveAttribute("src", "/images/Lilli2.jpeg"); + await expect(image).toHaveAttribute( + "alt", + "Relindis Agethen (Lilli) – Aktionsbild", + ); + await expect(actionDot).toHaveAttribute("aria-current", "true"); + await expect(portraitDot).toHaveAttribute("aria-current", "false"); + + await previous.click(); + await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg"); + await expect(portraitDot).toHaveAttribute("aria-current", "true"); + + await portraitDot.click(); + await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg"); +}); + +test("Maren hat ebenfalls ein umschaltbares Porträt- und Aktionsbild", async ({ + page, +}) => { + const maren = page + .locator(".advisor-card") + .filter({ hasText: "Maren Hoffmann" }); + const image = maren.locator( + '.advisor-gallery__slide[aria-hidden="false"] .advisor-gallery__image', + ); + + await expect(image).toHaveAttribute("src", "/images/Maren1.jpeg"); + await maren + .getByRole("button", { name: "Nächstes Bild von Maren Hoffmann" }) + .click(); + await expect(image).toHaveAttribute("src", "/images/Maren2.jpeg"); +}); diff --git a/src/tests/browser/contact.spec.js b/src/tests/browser/contact.spec.js index abc6203..0f7df1b 100644 --- a/src/tests/browser/contact.spec.js +++ b/src/tests/browser/contact.spec.js @@ -1,98 +1,98 @@ -import { test, expect } from "@playwright/test"; -import { randomUUID } from "node:crypto"; -import { contactBookingUrl } from "../../shared/contact.js"; - -async function open(page) { - await page.goto("/contact-test.html?berater-id=maren-hoffmann"); - await page - .getByRole("button", { name: "Berater kontaktieren", exact: true }) - .click(); -} - -test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter", async ({ - page, - request, -}) => { - const email = `kontakt-${randomUUID()}@example.test`; - const booking = await request.post("/api/bookings", { - headers: { "Idempotency-Key": randomUUID() }, - data: { packageId: "ernaehrung-starter", name: "Testkunde", email }, - }); - expect(booking.ok()).toBeTruthy(); - await open(page); - await expect(page.getByRole("dialog")).toContainText("Maren"); - const submit = page.getByRole("button", { name: "Anfrage senden" }); - await expect( - page.getByRole("button", { name: "E-Mail prüfen" }), - ).toHaveCount(0); - await expect(submit).toBeEnabled(); - await page - .getByLabel("Deine Buchungs-E-Mail") - .fill("unbekannt@example.test"); - await page.getByLabel("Grund für den Kontakt").selectOption("nutrition"); - await page.getByRole("checkbox").check(); - await submit.click(); - await expect(page.getByRole("status")).toContainText("keine Buchung"); - await expect(page.getByRole("dialog")).toBeVisible(); - await page.getByLabel("Deine Buchungs-E-Mail").fill(email); - await page.route("https://bookings.cloud.microsoft/**", (route) => - route.fulfill({ body: "Microsoft Bookings (Test)" }), - ); - const sentRequest = page.waitForRequest((request) => - request.url().endsWith("/api/contact"), - ); - await submit.click(); - expect((await sentRequest).postDataJSON().advisorId).toBe("maren-hoffmann"); - await expect(page).toHaveURL(contactBookingUrl); -}); - -test("Mobiles Popup: Fokus, Escape, Rückkehr zum Auslöser und keine Überbreite", async ({ - page, -}) => { - await page.setViewportSize({ width: 375, height: 667 }); - await open(page); - await expect(page.getByLabel("Deine Buchungs-E-Mail")).toBeFocused(); - const box = await page.getByRole("dialog").boundingBox(); - expect(box.x).toBeGreaterThanOrEqual(0); - expect(box.x + box.width).toBeLessThanOrEqual(375); - await page.keyboard.press("Shift+Tab"); - await expect( - page.getByRole("button", { name: "Kontaktfenster schließen" }), - ).toBeFocused(); - await page.keyboard.press("Escape"); - await expect(page.getByRole("dialog")).toHaveCount(0); - await expect( - page.getByRole("button", { name: "Berater kontaktieren", exact: true }), - ).toBeFocused(); -}); - -test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => { - await page.route("**/api/contact", (route) => - route.fulfill({ - status: 502, - json: { error: { message: "Mailpit nicht erreichbar." } }, - }), - ); - await open(page); - await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test"); - await page.getByLabel("Grund für den Kontakt").selectOption("package"); - await page.getByRole("checkbox").check(); - await page.getByRole("button", { name: "Anfrage senden" }).click(); - await expect(page.getByRole("status")).toContainText( - "Mailpit nicht erreichbar", - ); - await expect(page.getByRole("dialog")).toBeVisible(); -}); - -test("Unbekannte Berater-ID sperrt das Absenden", async ({ page }) => { - await page.goto("/contact-test.html?berater-id=unbekannt"); - await page - .getByRole("button", { name: "Berater kontaktieren", exact: true }) - .click(); - await expect(page.getByRole("dialog")).toContainText( - "Berater-ID fehlt oder ist unbekannt", - ); - await expect( - page.getByRole("button", { name: "Anfrage senden" }), - ).toBeDisabled(); -}); +import { test, expect } from "@playwright/test"; +import { randomUUID } from "node:crypto"; +import { contactBookingUrl } from "../../shared/contact.js"; + +async function open(page) { + await page.goto("/contact-test.html?berater-id=maren-hoffmann"); + await page + .getByRole("button", { name: "Berater kontaktieren", exact: true }) + .click(); +} + +test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter", async ({ + page, + request, +}) => { + const email = `kontakt-${randomUUID()}@example.test`; + const booking = await request.post("/api/bookings", { + headers: { "Idempotency-Key": randomUUID() }, + data: { packageId: "ernaehrung-starter", name: "Testkunde", email }, + }); + expect(booking.ok()).toBeTruthy(); + await open(page); + await expect(page.getByRole("dialog")).toContainText("Maren"); + const submit = page.getByRole("button", { name: "Anfrage senden" }); + await expect( + page.getByRole("button", { name: "E-Mail prüfen" }), + ).toHaveCount(0); + await expect(submit).toBeEnabled(); + await page + .getByLabel("Deine Buchungs-E-Mail") + .fill("unbekannt@example.test"); + await page.getByLabel("Grund für den Kontakt").selectOption("nutrition"); + await page.getByRole("checkbox").check(); + await submit.click(); + await expect(page.getByRole("status")).toContainText("keine Buchung"); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.getByLabel("Deine Buchungs-E-Mail").fill(email); + await page.route("https://bookings.cloud.microsoft/**", (route) => + route.fulfill({ body: "Microsoft Bookings (Test)" }), + ); + const sentRequest = page.waitForRequest((request) => + request.url().endsWith("/api/contact"), + ); + await submit.click(); + expect((await sentRequest).postDataJSON().advisorId).toBe("maren-hoffmann"); + await expect(page).toHaveURL(contactBookingUrl); +}); + +test("Mobiles Popup: Fokus, Escape, Rückkehr zum Auslöser und keine Überbreite", async ({ + page, +}) => { + await page.setViewportSize({ width: 375, height: 667 }); + await open(page); + await expect(page.getByLabel("Deine Buchungs-E-Mail")).toBeFocused(); + const box = await page.getByRole("dialog").boundingBox(); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(375); + await page.keyboard.press("Shift+Tab"); + await expect( + page.getByRole("button", { name: "Kontaktfenster schließen" }), + ).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Berater kontaktieren", exact: true }), + ).toBeFocused(); +}); + +test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => { + await page.route("**/api/contact", (route) => + route.fulfill({ + status: 502, + json: { error: { message: "Mailpit nicht erreichbar." } }, + }), + ); + await open(page); + await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test"); + await page.getByLabel("Grund für den Kontakt").selectOption("package"); + await page.getByRole("checkbox").check(); + await page.getByRole("button", { name: "Anfrage senden" }).click(); + await expect(page.getByRole("status")).toContainText( + "Mailpit nicht erreichbar", + ); + await expect(page.getByRole("dialog")).toBeVisible(); +}); + +test("Unbekannte Berater-ID sperrt das Absenden", async ({ page }) => { + await page.goto("/contact-test.html?berater-id=unbekannt"); + await page + .getByRole("button", { name: "Berater kontaktieren", exact: true }) + .click(); + await expect(page.getByRole("dialog")).toContainText( + "Berater-ID fehlt oder ist unbekannt", + ); + await expect( + page.getByRole("button", { name: "Anfrage senden" }), + ).toBeDisabled(); +}); diff --git a/src/tests/contact-form.test.js b/src/tests/contact-form.test.js index c1dd4a8..4dfb8a4 100644 --- a/src/tests/contact-form.test.js +++ b/src/tests/contact-form.test.js @@ -1,269 +1,269 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createApp } from "../server/index.js"; -import { createContactFormEmailService } from "../server/services/contact-form-email.js"; -import { createContactFormStorage } from "../server/services/contact-form-storage.js"; - -const validInput = { - name: "Test Person", - email: "person@example.test", - subject: "Eine Frage", - message: "Das ist eine ausreichend lange Nachricht.", -}; - -// Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand. -async function fixture(t, options = {}) { - const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-")); - const storage = - options.storage ?? - createContactFormStorage(join(directory, "contact-requests.csv")); - const sent = []; - const app = createApp({ - sendConfirmation: async () => {}, - sendContact: async () => {}, - contactFormStorage: storage, - sendContactForm: async (data) => sent.push(data), - ...options, - }); - await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); - t.after(async () => { - app.closeAllConnections(); - await new Promise((resolve) => app.close(resolve)); - await rm(directory, { recursive: true, force: true }); - }); - - const base = `http://127.0.0.1:${app.address().port}`; - async function post(input = validInput, headers = {}) { - const response = await fetch(`${base}/api/nutrition-contact`, { - method: "POST", - headers: { "Content-Type": "application/json", ...headers }, - body: JSON.stringify(input), - }); - return { status: response.status, body: await response.json() }; - } - return { app, base, post, sent, storage, directory }; -} - -// Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit. -test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => { - const f = await fixture(t); - const result = await f.post(); - - assert.equal(result.status, 201); - assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" }); - assert.equal(f.sent.length, 1); - assert.deepEqual( - { - name: f.sent[0].name, - email: f.sent[0].email, - subject: f.sent[0].subject, - message: f.sent[0].message, - }, - validInput, - ); - assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt))); - assert.deepEqual(await f.storage.readAll(), f.sent); -}); - -// Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden. -test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => { - const f = await fixture(t); - const invalidInputs = [ - { ...validInput, name: " " }, - { ...validInput, name: "Test\nBcc: fremd" }, - { ...validInput, name: "x".repeat(121) }, - { ...validInput, email: "keine-adresse" }, - { ...validInput, email: "x".repeat(255) }, - { ...validInput, subject: "x".repeat(161) }, - { ...validInput, message: "zu kurz" }, - { ...validInput, message: "x".repeat(5001) }, - { ...validInput, message: "Nachricht\u0000ungültig" }, - { ...validInput, message: undefined }, - null, - [], - ]; - - for (const input of invalidInputs) { - const result = await f.post(input); - assert.equal(result.status, 400, JSON.stringify(input)); - } - assert.deepEqual(await f.storage.readAll(), []); - assert.equal(f.sent.length, 0); -}); - -// Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts. -test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => { - const f = await fixture(t); - assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405); - - const wrongType = await fetch(`${f.base}/api/nutrition-contact`, { - method: "POST", - body: "{}", - }); - assert.equal(wrongType.status, 415); - - const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{", - }); - assert.equal(invalidJson.status, 400); - - const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "x".repeat(9000) }), - }); - assert.equal(tooLarge.status, 413); - assert.deepEqual(await f.storage.readAll(), []); -}); - -// Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden. -test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => { - let sendCount = 0; - const f = await fixture(t, { - storage: { - async append() { - throw new Error("Datenträger nicht verfügbar"); - }, - }, - sendContactForm: async () => sendCount++, - }); - const result = await f.post(); - - assert.equal(result.status, 503); - assert.equal(result.body.error.code, "CONTACT_NOT_SAVED"); - assert.equal(sendCount, 0); -}); - -// Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten. -test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => { - const f = await fixture(t, { - sendContactForm: async () => { - throw new Error("Mailpit nicht erreichbar"); - }, - }); - const result = await f.post(); - - assert.equal(result.status, 502); - assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED"); - assert.equal(result.body.error.message.includes("gespeichert"), true); - assert.equal((await f.storage.readAll()).length, 1); -}); - -// Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen. -test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => { - const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-")); - const file = join(directory, "contact-requests.csv"); - const storage = createContactFormStorage(file); - t.after(() => rm(directory, { recursive: true, force: true })); - const names = [ - '=HYPERLINK("evil")', - 'Test, "Person"', - "Zeile 1\nZeile 2", - "'Original", - ...Array.from({ length: 8 }, (_, index) => `Person ${index}`), - ]; - - await Promise.all( - names.map((name, index) => - storage.append({ - createdAt: `2026-01-01T00:00:0${index}.000Z`, - name, - email: `person${index}@example.test`, - subject: "CSV Test", - message: "Eine Nachricht mit mehr als zehn Zeichen.", - }), - ), - ); - - const records = await storage.readAll(); - assert.equal(records.length, names.length); - assert.deepEqual( - records.map((record) => record.name).sort(), - [...names].sort(), - ); - const csv = await readFile(file, "utf8"); - assert.match(csv, /'=HYPERLINK/); - assert.match(csv, /"Test, ""Person"""/); -}); - -// Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben. -test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => { - const directory = await mkdtemp( - join(tmpdir(), "trihub-contact-csv-invalid-"), - ); - const file = join(directory, "contact-requests.csv"); - const storage = createContactFormStorage(file); - const corrupt = "wrong,header\n1,2\n"; - await writeFile(file, corrupt); - t.after(() => rm(directory, { recursive: true, force: true })); - - await assert.rejects( - storage.append({ ...validInput, createdAt: "2026-01-01" }), - ); - assert.equal(await readFile(file, "utf8"), corrupt); -}); - -// Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt. -test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => { - const config = []; - const messages = []; - const sendContactForm = createContactFormEmailService( - {}, - (transportConfig) => { - config.push(transportConfig); - return { - async sendMail(message) { - messages.push(message); - return { accepted: [message.to] }; - }, - }; - }, - ); - - await sendContactForm({ - ...validInput, - createdAt: "2026-01-01T00:00:00.000Z", - }); - - assert.equal(config[0].host, "127.0.0.1"); - assert.equal(config[0].port, 1025); - assert.deepEqual( - messages.map((message) => message.to).sort(), - [validInput.email, "ernaehrung@tri-hub.de"].sort(), - ); - assert.ok( - messages.every( - (message) => - message.from === "Tri-Hub Ernährung ", - ), - ); - assert.ok( - messages.every((message) => message.text.includes(validInput.message)), - ); - const request = messages.find( - (message) => message.to === "ernaehrung@tri-hub.de", - ); - assert.equal(request.replyTo, validInput.email); -}); - -// SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden. -test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => { - const sendContactForm = createContactFormEmailService({}, () => ({ - async sendMail(message) { - return { - accepted: message.to === validInput.email ? [message.to] : [], - }; - }, - })); - - await assert.rejects( - sendContactForm(validInput), - (error) => - error.status === 502 && error.code === "CONTACT_EMAIL_FAILED", - ); -}); +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../server/index.js"; +import { createContactFormEmailService } from "../server/services/contact-form-email.js"; +import { createContactFormStorage } from "../server/services/contact-form-storage.js"; + +const validInput = { + name: "Test Person", + email: "person@example.test", + subject: "Eine Frage", + message: "Das ist eine ausreichend lange Nachricht.", +}; + +// Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand. +async function fixture(t, options = {}) { + const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-")); + const storage = + options.storage ?? + createContactFormStorage(join(directory, "contact-requests.csv")); + const sent = []; + const app = createApp({ + sendConfirmation: async () => {}, + sendContact: async () => {}, + contactFormStorage: storage, + sendContactForm: async (data) => sent.push(data), + ...options, + }); + await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + app.closeAllConnections(); + await new Promise((resolve) => app.close(resolve)); + await rm(directory, { recursive: true, force: true }); + }); + + const base = `http://127.0.0.1:${app.address().port}`; + async function post(input = validInput, headers = {}) { + const response = await fetch(`${base}/api/nutrition-contact`, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(input), + }); + return { status: response.status, body: await response.json() }; + } + return { app, base, post, sent, storage, directory }; +} + +// Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit. +test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => { + const f = await fixture(t); + const result = await f.post(); + + assert.equal(result.status, 201); + assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" }); + assert.equal(f.sent.length, 1); + assert.deepEqual( + { + name: f.sent[0].name, + email: f.sent[0].email, + subject: f.sent[0].subject, + message: f.sent[0].message, + }, + validInput, + ); + assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt))); + assert.deepEqual(await f.storage.readAll(), f.sent); +}); + +// Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden. +test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => { + const f = await fixture(t); + const invalidInputs = [ + { ...validInput, name: " " }, + { ...validInput, name: "Test\nBcc: fremd" }, + { ...validInput, name: "x".repeat(121) }, + { ...validInput, email: "keine-adresse" }, + { ...validInput, email: "x".repeat(255) }, + { ...validInput, subject: "x".repeat(161) }, + { ...validInput, message: "zu kurz" }, + { ...validInput, message: "x".repeat(5001) }, + { ...validInput, message: "Nachricht\u0000ungültig" }, + { ...validInput, message: undefined }, + null, + [], + ]; + + for (const input of invalidInputs) { + const result = await f.post(input); + assert.equal(result.status, 400, JSON.stringify(input)); + } + assert.deepEqual(await f.storage.readAll(), []); + assert.equal(f.sent.length, 0); +}); + +// Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts. +test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => { + const f = await fixture(t); + assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405); + + const wrongType = await fetch(`${f.base}/api/nutrition-contact`, { + method: "POST", + body: "{}", + }); + assert.equal(wrongType.status, 415); + + const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }); + assert.equal(invalidJson.status, 400); + + const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "x".repeat(9000) }), + }); + assert.equal(tooLarge.status, 413); + assert.deepEqual(await f.storage.readAll(), []); +}); + +// Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden. +test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => { + let sendCount = 0; + const f = await fixture(t, { + storage: { + async append() { + throw new Error("Datenträger nicht verfügbar"); + }, + }, + sendContactForm: async () => sendCount++, + }); + const result = await f.post(); + + assert.equal(result.status, 503); + assert.equal(result.body.error.code, "CONTACT_NOT_SAVED"); + assert.equal(sendCount, 0); +}); + +// Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten. +test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => { + const f = await fixture(t, { + sendContactForm: async () => { + throw new Error("Mailpit nicht erreichbar"); + }, + }); + const result = await f.post(); + + assert.equal(result.status, 502); + assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED"); + assert.equal(result.body.error.message.includes("gespeichert"), true); + assert.equal((await f.storage.readAll()).length, 1); +}); + +// Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen. +test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-")); + const file = join(directory, "contact-requests.csv"); + const storage = createContactFormStorage(file); + t.after(() => rm(directory, { recursive: true, force: true })); + const names = [ + '=HYPERLINK("evil")', + 'Test, "Person"', + "Zeile 1\nZeile 2", + "'Original", + ...Array.from({ length: 8 }, (_, index) => `Person ${index}`), + ]; + + await Promise.all( + names.map((name, index) => + storage.append({ + createdAt: `2026-01-01T00:00:0${index}.000Z`, + name, + email: `person${index}@example.test`, + subject: "CSV Test", + message: "Eine Nachricht mit mehr als zehn Zeichen.", + }), + ), + ); + + const records = await storage.readAll(); + assert.equal(records.length, names.length); + assert.deepEqual( + records.map((record) => record.name).sort(), + [...names].sort(), + ); + const csv = await readFile(file, "utf8"); + assert.match(csv, /'=HYPERLINK/); + assert.match(csv, /"Test, ""Person"""/); +}); + +// Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben. +test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => { + const directory = await mkdtemp( + join(tmpdir(), "trihub-contact-csv-invalid-"), + ); + const file = join(directory, "contact-requests.csv"); + const storage = createContactFormStorage(file); + const corrupt = "wrong,header\n1,2\n"; + await writeFile(file, corrupt); + t.after(() => rm(directory, { recursive: true, force: true })); + + await assert.rejects( + storage.append({ ...validInput, createdAt: "2026-01-01" }), + ); + assert.equal(await readFile(file, "utf8"), corrupt); +}); + +// Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt. +test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => { + const config = []; + const messages = []; + const sendContactForm = createContactFormEmailService( + {}, + (transportConfig) => { + config.push(transportConfig); + return { + async sendMail(message) { + messages.push(message); + return { accepted: [message.to] }; + }, + }; + }, + ); + + await sendContactForm({ + ...validInput, + createdAt: "2026-01-01T00:00:00.000Z", + }); + + assert.equal(config[0].host, "127.0.0.1"); + assert.equal(config[0].port, 1025); + assert.deepEqual( + messages.map((message) => message.to).sort(), + [validInput.email, "ernaehrung@tri-hub.de"].sort(), + ); + assert.ok( + messages.every( + (message) => + message.from === "Tri-Hub Ernährung ", + ), + ); + assert.ok( + messages.every((message) => message.text.includes(validInput.message)), + ); + const request = messages.find( + (message) => message.to === "ernaehrung@tri-hub.de", + ); + assert.equal(request.replyTo, validInput.email); +}); + +// SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden. +test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => { + const sendContactForm = createContactFormEmailService({}, () => ({ + async sendMail(message) { + return { + accepted: message.to === validInput.email ? [message.to] : [], + }; + }, + })); + + await assert.rejects( + sendContactForm(validInput), + (error) => + error.status === 502 && error.code === "CONTACT_EMAIL_FAILED", + ); +}); diff --git a/src/tests/contact.test.js b/src/tests/contact.test.js index ee6bc34..2ae25f4 100644 --- a/src/tests/contact.test.js +++ b/src/tests/contact.test.js @@ -1,189 +1,189 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createApp } from "../server/index.js"; -import { createCsvStorage } from "../server/services/csv-storage.js"; -import { createContactEmailService } from "../server/services/contact-email.js"; -import { contactBookingUrl } from "../shared/contact.js"; - -async function fixture(t, options = {}) { - const dir = await mkdtemp(join(tmpdir(), "trihub-contact-")); - const storage = createCsvStorage(join(dir, "bookings.csv")); - await storage.writeAll([ - { - bookingId: "TH-000001", - email: "kunde@example.test", - name: "Testkunde", - }, - ]); - const sent = []; - const app = createApp({ - storage, - sendConfirmation: async () => {}, - sendContact: async (data) => sent.push(data), - ...options, - }); - await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); - t.after(async () => { - app.closeAllConnections(); - await new Promise((resolve) => app.close(resolve)); - await rm(dir, { recursive: true, force: true }); - }); - const base = `http://127.0.0.1:${app.address().port}`; - const post = async (path, input) => { - const response = await fetch(base + path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - return { status: response.status, body: await response.json() }; - }; - return { post, sent, storage, base }; -} -const input = { - email: "kunde@example.test", - advisorId: "relindis-agethen", - reason: "coaching", - simulationAccepted: true, -}; - -test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redirect", async (t) => { - const f = await fixture(t); - const before = await f.storage.readAll(); - const validation = await f.post("/api/contact/validate", { - email: " KUNDE@example.test ", - }); - assert.equal(validation.status, 200); - assert.equal(validation.body.email, input.email); - const result = await f.post("/api/contact", { - ...input, - advisor: "Manipuliert", - advisorEmail: "falsch@example.test", - }); - assert.equal(result.status, 201); - assert.equal(result.body.redirectUrl, contactBookingUrl); - assert.equal(result.body.simulated, true); - assert.equal(f.sent[0].advisor.email, "relindis.agethen@tri-hub.de"); - assert.equal(f.sent[0].advisor.vorname, "Relindis"); - const second = await f.post("/api/contact", { - ...input, - advisorId: "maren-hoffmann", - }); - assert.equal(second.status, 201); - assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de"); - assert.deepEqual(await f.storage.readAll(), before); -}); - -test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => { - const f = await fixture(t); - for (const [data, status] of [ - [null, 400], - [{ ...input, email: "bad" }, 400], - [{ ...input, email: "unknown@example.test" }, 403], - [{ ...input, advisorId: "unbekannt" }, 400], - [{ ...input, advisorId: null }, 400], - [{ ...input, reason: "__proto__" }, 400], - [{ ...input, simulationAccepted: false }, 400], - ]) - assert.equal((await f.post("/api/contact", data)).status, status); - assert.equal(f.sent.length, 0); -}); - -test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => { - const f = await fixture(t); - assert.equal((await f.post("/api/contact/validate", input)).status, 200); - await f.storage.writeAll([]); - assert.equal((await f.post("/api/contact", input)).status, 403); - const broken = await fixture(t, { - storage: { - readAll() { - throw new Error("unavailable"); - }, - }, - }); - assert.equal((await broken.post("/api/contact", input)).status, 503); - assert.equal(broken.sent.length, 0); -}); - -test("HTTP-Vertrag weist falsche Methode, ungültiges JSON und große Requests zurück", async (t) => { - const f = await fixture(t); - assert.equal((await fetch(f.base + "/api/contact")).status, 405); - assert.equal( - (await fetch(f.base + "/api/contact", { method: "POST", body: "x" })) - .status, - 415, - ); - assert.equal( - ( - await fetch(f.base + "/api/contact", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{", - }) - ).status, - 400, - ); - assert.equal( - (await f.post("/api/contact", { email: "x".repeat(9000) })).status, - 413, - ); -}); - -test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", async (t) => { - const messages = []; - const sender = createContactEmailService((config) => { - assert.equal(config.host, "127.0.0.1"); - assert.equal(config.port, 1025); - return { - async sendMail(message) { - messages.push(message); - return { accepted: [message.to] }; - }, - }; - }); - const f = await fixture(t, { sendContact: sender }); - assert.equal((await f.post("/api/contact", input)).status, 201); - assert.deepEqual( - messages.map((m) => m.to), - [input.email, "relindis.agethen@tri-hub.de"], - ); - for (const message of messages) { - assert.match(message.text, /Simulation/); - assert.match(message.text, /Persönliches Coaching/); - } - const failing = createContactEmailService(() => ({ - async sendMail(message) { - return { accepted: message.to === input.email ? [message.to] : [] }; - }, - })); - const failure = await fixture(t, { sendContact: failing }); - const result = await failure.post("/api/contact", input); - assert.equal(result.status, 502); - assert.equal(result.body.redirectUrl, undefined); -}); - -test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async () => { - const { advisorDisplayName } = await import("../shared/berater.js"); - assert.equal( - advisorDisplayName({ - spitzname: " Lilli ", - vorname: "Relindis", - nachname: "Agethen", - }), - "Lilli", - ); - assert.equal( - advisorDisplayName({ - spitzname: " ", - vorname: "Maren", - nachname: "Hoffmann", - }), - "Maren", - ); - assert.equal( - advisorDisplayName({ vorname: "Maren", nachname: "Hoffmann" }), - "Maren", - ); -}); +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../server/index.js"; +import { createCsvStorage } from "../server/services/csv-storage.js"; +import { createContactEmailService } from "../server/services/contact-email.js"; +import { contactBookingUrl } from "../shared/contact.js"; + +async function fixture(t, options = {}) { + const dir = await mkdtemp(join(tmpdir(), "trihub-contact-")); + const storage = createCsvStorage(join(dir, "bookings.csv")); + await storage.writeAll([ + { + bookingId: "TH-000001", + email: "kunde@example.test", + name: "Testkunde", + }, + ]); + const sent = []; + const app = createApp({ + storage, + sendConfirmation: async () => {}, + sendContact: async (data) => sent.push(data), + ...options, + }); + await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + app.closeAllConnections(); + await new Promise((resolve) => app.close(resolve)); + await rm(dir, { recursive: true, force: true }); + }); + const base = `http://127.0.0.1:${app.address().port}`; + const post = async (path, input) => { + const response = await fetch(base + path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + return { status: response.status, body: await response.json() }; + }; + return { post, sent, storage, base }; +} +const input = { + email: "kunde@example.test", + advisorId: "relindis-agethen", + reason: "coaching", + simulationAccepted: true, +}; + +test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redirect", async (t) => { + const f = await fixture(t); + const before = await f.storage.readAll(); + const validation = await f.post("/api/contact/validate", { + email: " KUNDE@example.test ", + }); + assert.equal(validation.status, 200); + assert.equal(validation.body.email, input.email); + const result = await f.post("/api/contact", { + ...input, + advisor: "Manipuliert", + advisorEmail: "falsch@example.test", + }); + assert.equal(result.status, 201); + assert.equal(result.body.redirectUrl, contactBookingUrl); + assert.equal(result.body.simulated, true); + assert.equal(f.sent[0].advisor.email, "relindis.agethen@tri-hub.de"); + assert.equal(f.sent[0].advisor.vorname, "Relindis"); + const second = await f.post("/api/contact", { + ...input, + advisorId: "maren-hoffmann", + }); + assert.equal(second.status, 201); + assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de"); + assert.deepEqual(await f.storage.readAll(), before); +}); + +test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => { + const f = await fixture(t); + for (const [data, status] of [ + [null, 400], + [{ ...input, email: "bad" }, 400], + [{ ...input, email: "unknown@example.test" }, 403], + [{ ...input, advisorId: "unbekannt" }, 400], + [{ ...input, advisorId: null }, 400], + [{ ...input, reason: "__proto__" }, 400], + [{ ...input, simulationAccepted: false }, 400], + ]) + assert.equal((await f.post("/api/contact", data)).status, status); + assert.equal(f.sent.length, 0); +}); + +test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => { + const f = await fixture(t); + assert.equal((await f.post("/api/contact/validate", input)).status, 200); + await f.storage.writeAll([]); + assert.equal((await f.post("/api/contact", input)).status, 403); + const broken = await fixture(t, { + storage: { + readAll() { + throw new Error("unavailable"); + }, + }, + }); + assert.equal((await broken.post("/api/contact", input)).status, 503); + assert.equal(broken.sent.length, 0); +}); + +test("HTTP-Vertrag weist falsche Methode, ungültiges JSON und große Requests zurück", async (t) => { + const f = await fixture(t); + assert.equal((await fetch(f.base + "/api/contact")).status, 405); + assert.equal( + (await fetch(f.base + "/api/contact", { method: "POST", body: "x" })) + .status, + 415, + ); + assert.equal( + ( + await fetch(f.base + "/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }) + ).status, + 400, + ); + assert.equal( + (await f.post("/api/contact", { email: "x".repeat(9000) })).status, + 413, + ); +}); + +test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", async (t) => { + const messages = []; + const sender = createContactEmailService((config) => { + assert.equal(config.host, "127.0.0.1"); + assert.equal(config.port, 1025); + return { + async sendMail(message) { + messages.push(message); + return { accepted: [message.to] }; + }, + }; + }); + const f = await fixture(t, { sendContact: sender }); + assert.equal((await f.post("/api/contact", input)).status, 201); + assert.deepEqual( + messages.map((m) => m.to), + [input.email, "relindis.agethen@tri-hub.de"], + ); + for (const message of messages) { + assert.match(message.text, /Simulation/); + assert.match(message.text, /Persönliches Coaching/); + } + const failing = createContactEmailService(() => ({ + async sendMail(message) { + return { accepted: message.to === input.email ? [message.to] : [] }; + }, + })); + const failure = await fixture(t, { sendContact: failing }); + const result = await failure.post("/api/contact", input); + assert.equal(result.status, 502); + assert.equal(result.body.redirectUrl, undefined); +}); + +test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async () => { + const { advisorDisplayName } = await import("../shared/berater.js"); + assert.equal( + advisorDisplayName({ + spitzname: " Lilli ", + vorname: "Relindis", + nachname: "Agethen", + }), + "Lilli", + ); + assert.equal( + advisorDisplayName({ + spitzname: " ", + vorname: "Maren", + nachname: "Hoffmann", + }), + "Maren", + ); + assert.equal( + advisorDisplayName({ vorname: "Maren", nachname: "Hoffmann" }), + "Maren", + ); +}); diff --git a/src/tests/helpers/browser-server.js b/src/tests/helpers/browser-server.js index e9805ce..0961e60 100644 --- a/src/tests/helpers/browser-server.js +++ b/src/tests/helpers/browser-server.js @@ -1,24 +1,24 @@ -// Ausschließlich Testserver: temporäre CSV und simulierter Versand. -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createApp } from "../../server/index.js"; -import { createCsvStorage } from "../../server/services/csv-storage.js"; -import { packages } from "../../shared/packages.js"; - -const directory = await mkdtemp(join(tmpdir(), "trihub-browser-")); -const server = createApp({ - catalog: packages, - storage: createCsvStorage(join(directory, "bookings.csv")), - sendConfirmation: async () => {}, - sendContact: async () => {}, -}); -server.listen(3000, "127.0.0.1"); -async function stop() { - server.closeAllConnections(); - await new Promise((resolve) => server.close(resolve)); - await rm(directory, { recursive: true, force: true }); - process.exit(0); -} -process.on("SIGTERM", stop); -process.on("SIGINT", stop); +// Ausschließlich Testserver: temporäre CSV und simulierter Versand. +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../../server/index.js"; +import { createCsvStorage } from "../../server/services/csv-storage.js"; +import { packages } from "../../shared/packages.js"; + +const directory = await mkdtemp(join(tmpdir(), "trihub-browser-")); +const server = createApp({ + catalog: packages, + storage: createCsvStorage(join(directory, "bookings.csv")), + sendConfirmation: async () => {}, + sendContact: async () => {}, +}); +server.listen(3000, "127.0.0.1"); +async function stop() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await rm(directory, { recursive: true, force: true }); + process.exit(0); +} +process.on("SIGTERM", stop); +process.on("SIGINT", stop); diff --git a/vite.config.js b/vite.config.js index 53000c5..731465c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,44 +1,44 @@ -import { defineConfig } from "vite"; -import { fileURLToPath } from "node:url"; - -export default defineConfig({ - server: { - proxy: { "/api": "http://127.0.0.1:3000" }, - fs: { - // Auch Vites direkten Dateizugriff auf das Projekt absichern. - deny: [ - ".env", - ".env.*", - "*.{crt,pem}", - "**/.git/**", - "**/server/**", - "**/tests/**", - ], - }, - }, - build: { - rollupOptions: { - input: { - contactTest: fileURLToPath( - new URL("./contact-test.html", import.meta.url), - ), - angebote: fileURLToPath( - new URL("./angebotsuebersicht.html", import.meta.url), - ), - details: fileURLToPath( - new URL("./paket-details.html", import.meta.url), - ), - main: fileURLToPath(new URL("./index.html", import.meta.url)), - bookingConfirmation: fileURLToPath( - new URL("./booking-confirmation.html", import.meta.url), - ), - booking: fileURLToPath( - new URL("./booking.html", import.meta.url), - ), - berater: fileURLToPath( - new URL("./berater.html", import.meta.url), - ), - }, - }, - }, -}); +import { defineConfig } from "vite"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + server: { + proxy: { "/api": "http://127.0.0.1:3000" }, + fs: { + // Auch Vites direkten Dateizugriff auf das Projekt absichern. + deny: [ + ".env", + ".env.*", + "*.{crt,pem}", + "**/.git/**", + "**/server/**", + "**/tests/**", + ], + }, + }, + build: { + rollupOptions: { + input: { + contactTest: fileURLToPath( + new URL("./contact-test.html", import.meta.url), + ), + angebote: fileURLToPath( + new URL("./angebotsuebersicht.html", import.meta.url), + ), + details: fileURLToPath( + new URL("./paket-details.html", import.meta.url), + ), + main: fileURLToPath(new URL("./index.html", import.meta.url)), + bookingConfirmation: fileURLToPath( + new URL("./booking-confirmation.html", import.meta.url), + ), + booking: fileURLToPath( + new URL("./booking.html", import.meta.url), + ), + berater: fileURLToPath( + new URL("./berater.html", import.meta.url), + ), + }, + }, + }, +});