Paketübergabe korrigiert, Bestellübersicht und Bestätigungsmail erweitert

feature/booking-component
Marvin Gemlin 2026-09-25 20:04:22 +00:00
parent 4abf4aa46c
commit 5fb3f51f8f
24 changed files with 1059 additions and 509 deletions

View File

@ -1,7 +1,6 @@
# Lokal nur einen Mailfänger verwenden. Keine echten Zugangsdaten eintragen.
HOST=127.0.0.1
PORT=3000
BOOKING_DEMO=true
SMTP_HOST=127.0.0.1
SMTP_PORT=1025
SMTP_SECURE=false

View File

@ -1,357 +1,143 @@
# US2.4 – Paket buchen
# Paketbuchung
## Stand und Zusammenarbeit
## Ablauf
Entwicklung auf `feature/paket-buchen-backend`, späteres PR-Ziel: `dev`.
Zu Beginn waren HEAD und frisch abgerufenes `origin/dev` identisch. Kein Merge,
Commit oder Push wurde durchgeführt. Es gab keinen Router, keine Paketdaten und
kein Backend. Die Implementierung verwendet die vereinbarte Startstruktur:
Backend unter `src/server/`, gemeinsame Paketdaten unter `src/shared/` und
Tests unter `src/tests/`. Die früheren Doppelordner im Projektwurzelverzeichnis
wurden in diese vorhandenen Ordner überführt.
Die Angebotsübersicht führt zur Detailseite eines Pakets. Der Buchungsbutton
übergibt die Paket-ID und gegebenenfalls die gewählte Variante als URL-Parameter
an `booking.html`. Bestehende Links mit `#/buchen/:packageId` funktionieren weiterhin.
## So funktioniert der Ablauf
Booking lädt die Bestellübersicht vom Server. Sie enthält Paket und Variante,
Laufzeit, Leistungen, Abrechnung, Nettopreis, MwSt. und Gesamtbetrag. Die Preise
stammen aus `src/shared/packagesdetails.js`; Preisangaben des Browsers werden
nicht übernommen. Ohne Varianten-ID wird die erste Variante des Pakets gewählt.
1. Der Browser lädt die verfügbaren Pakete über `GET /api/angebote`.
2. Das Formular sendet Paket-ID, Name und E-Mail an `POST /api/bookings`.
Es merkt sich dafür einen zufälligen Anfrageschlüssel (Idempotency-Key).
3. Der Server prüft die Angaben und liest den Paketnamen aus `src/shared/packages.js`.
4. Er speichert Buchungsnummer, Zeitpunkt, Angaben und Schlüssel in der CSV.
5. Erst danach übergibt er die Bestätigung an SMTP und aktualisiert den Versandstatus.
6. Bei einem erneuten Versuch mit denselben Daten und demselben Schlüssel gibt
er die vorhandene Buchung zurück. Es entsteht keine zweite Bestellung.
Beim Absenden speichert der Server Kundendaten und den vollständigen Bestellstand
in der CSV. Anschließend sendet er eine Bestätigung als Text und HTML mit
Buchungsnummer, Datum, gebuchten Leistungen und Preisen. Änderungen am Katalog
verändern bereits gespeicherte Bestellungen nicht.
Der Zahlungsvorgang ist weiterhin simuliert. Es erfolgt keine Abbuchung und
keine verbindliche Beratungsbuchung. Darauf weisen Formular und E-Mail hin.
## Lokal starten
Node.js 24 ist im vorhandenen Dev Container vorgesehen. Alle Befehle laufen im
Projektverzeichnis. Vite und Backend benötigen jeweils ein eigenes Terminal.
Voraussetzung ist Node.js 24. Abhängigkeiten mit `npm ci` installieren und die
Werte aus `.env.example` in eine lokale `.env` übernehmen. Eine vorhandene
Konfiguration dabei erhalten.
```bash
npm ci
cp .env.example .env
```
In separaten Terminals starten:
Eine vorhandene `.env` vorher prüfen und nicht überschreiben. Die Beispielwerte
aktivieren das klar gekennzeichnete Testpaket und verwenden nur einen Mailfänger.
Ohne `.env` bleibt der Demo-Modus ausgeschaltet; die drei regulären Pakete bleiben verfügbar.
- `npm run dev:mail`: Mailpit mit SMTP auf Port 1025 und Weboberfläche auf 8025.
- `npm run dev:server`: Buchungs-API auf Port 3000.
- `npm run dev`: Frontend auf Port 5173; `/api` wird an Node weitergeleitet.
Mailpit ist über `.devcontainer/Dockerfile` bereits Bestandteil neuer Dev Container.
Bestehende Container einmal neu bauen. Außerhalb davon einmalig
[Mailpit installieren](https://mailpit.axllent.org/docs/install/).
Es läuft als einzelne Binärdatei auch direkt im Dev Container. Kein Docker im
Container erforderlich. Mailpit ohne Weiterleitung an echte SMTP-Server starten:
Die Angebotsübersicht liegt unter <http://localhost:5173/angebotsuebersicht.html>,
der Mailfänger unter <http://localhost:8025>. Mailpit ist im Dev Container enthalten.
```bash
npm run dev:mail
```
In Terminal 2 das Backend starten:
```bash
npm run dev:server
```
In Terminal 3 das Frontend starten:
```bash
npm run dev
```
Öffnen: <http://localhost:5173/booking.html#/buchen/demo-booking>.
Mailfänger: <http://localhost:8025>. Im Dev Container sind die Ports 8025 und 5173
für die Weiterleitung vorkonfiguriert.
Port 3000 muss für den Browser nicht weitergeleitet werden: Vite vermittelt `/api`.
Diese Beschreibung setzt voraus, dass Mailpit im selben Container läuft.
`npm run start:server` startet das Backend ohne automatischen Neustart.
Bestehende Befehle `dev`, `build`, `preview`, `format` und `format:check` bleiben erhalten.
`npm run preview` zeigt ausschließlich den gebauten Frontend-Stand. Für eine
vollständige Buchung im Deployment muss der Webserver `/api` separat an Node
weiterleiten; der Vite-Entwicklungsproxy ist kein Produktionsserver.
`npm run build` erzeugt die Seiten in `dist/`, einschließlich der Paketdetailseite.
Für ein Deployment muss der Webserver `/api` an den Node-Server weiterleiten.
`npm run start:server` startet die API ohne automatischen Neustart.
## Konfiguration
Alle SMTP-Einstellungen werden ausschließlich in Node geladen. Niemals mit einem
`VITE_`-Präfix versehen: Solche Variablen könnten im Browser landen.
| Variable | Standardwert | Bedeutung |
| ------------------------ | -------------------------------- | --------------------------------------------------- |
| `HOST` | `127.0.0.1` | Bind-Adresse der API |
| `PORT` | `3000` | API-Port; bei Änderung auch den Vite-Proxy anpassen |
| `SMTP_HOST` | `127.0.0.1` | SMTP-Server |
| `SMTP_PORT` | `1025` lokal, sonst `587` | SMTP-Port |
| `SMTP_SECURE` | `false` | Direktes TLS aktivieren |
| `SMTP_USER`, `SMTP_PASS` | leer | SMTP-Zugangsdaten; beide gemeinsam setzen |
| `SMTP_FROM` | `Tri-Hub <buchung@example.test>` | Absender |
| `SMTP_ALLOW_EXTERNAL` | `false` | Versand über externe SMTP-Server aktivieren |
| Variable | Lokaler Beispielwert | Bedeutung |
| ------------------------- | -------------------------------- | --------------------------------------------------------------------- |
| `HOST` | `127.0.0.1` | Bind-Adresse des Backends |
| `PORT` | `3000` | Backend-Port; bei Änderung auch Vite-Proxy anpassen |
| `BOOKING_DEMO` | `true` | Fügt ausschließlich lokal das Testpaket hinzu; in Produktion gesperrt |
| `SMTP_HOST` | `127.0.0.1` | Mailfänger im selben Container |
| `SMTP_PORT` | `1025` | SMTP-Port des Mailfängers |
| `SMTP_SECURE` | `false` | Bei echtem SMTP mit direktem TLS üblicherweise `true` auf 465 |
| `SMTP_USER` / `SMTP_PASS` | leer | Beide gemeinsam setzen, falls Authentifizierung benötigt wird |
| `SMTP_FROM` | `Tri-Hub <buchung@example.test>` | Absender; später abgestimmten echten Absender verwenden |
| `SMTP_ALLOW_EXTERNAL` | `false` | Externes SMTP gesperrt; erst nach ausdrücklicher Freigabe aktivieren |
Externe SMTP-Verbindungen erfordern TLS. Zugangsdaten gehören ausschließlich
in die Serverkonfiguration und dürfen kein `VITE_`-Präfix erhalten.
Für externe SMTP-Hosts wird TLS verlangt; Zertifikatsprüfung bleibt aktiv.
Für STARTTLS (typischerweise Port 587) bleibt `SMTP_SECURE=false`.
Die `.env.example` enthält nur Testwerte und keine Geheimnisse.
[SMTP-Optionen bei Nodemailer](https://nodemailer.com/smtp).
## API
## Paketdaten und spätere Router-Anbindung
Der vollständige Vertrag steht in `src/server/swagger.json`.
Person 3 pflegt `packages` in **`src/shared/packages.js`**.
Das Array enthält die drei Angebote Starter, Standard und Premium. Übersicht und
Buchung verwenden gemeinsam `id`, `name` und `summary`; zusätzliche Felder
beschreiben die Darstellung der Angebotskarten.
| Endpunkt | Funktion |
| ---------------------------- | ----------------------------------------------------------------------------------- |
| `GET /api/angebote` | Bestehende Kurzübersicht mit `id`, `name` und `summary` |
| `GET /api/packages` | Kurzübersicht im Swagger-Format |
| `GET /api/packages/{id}` | Paketdetails einschließlich Varianten und Leistungen |
| `POST /api/bookings/preview` | Bestellübersicht für `packageId` und optionale `variantId`; speichert keine Buchung |
| `POST /api/bookings` | Buchung speichern und Bestätigung versenden |
```js
// Nur Schema-Beispiel, kein verbindliches Angebot:
{ id: "stabile-paket-id", name: "Abgestimmter Paketname", summary: "Kurze Beschreibung" }
```
POST-Anfragen benötigen `Content-Type: application/json`. Beim Buchen ist
zusätzlich ein `Idempotency-Key` als UUID v4 erforderlich. Das Formular sendet
`packageId`, gegebenenfalls `variantId`, `name` und `email`.
IDs: eindeutig, maximal 80 Zeichen, Kleinbuchstaben/Ziffern mit einzelnen
Bindestrichen. `name` und `summary` sind nicht leere Strings. IDs nach Freigabe
nicht für andere Angebote wiederverwenden. Das Backend prüft den Vertrag beim Start.
Das separate `demoPackages`-Array in derselben Datei enthält nur `demo-booking`,
explizit als Test markiert. Es wird mit `BOOKING_DEMO=true` zugeschaltet.
Keine Preise wurden erfunden. Sobald verbindliche Preisfelder vereinbart sind,
müssen Anzeige und serverseitiger Buchungssnapshot gemeinsam ergänzt werden.
Angaben wie `packageName` oder `price` aus API-Anfragen werden nicht übernommen.
Alternativ akzeptiert die API das Swagger-Objekt `customer` mit `firstName`,
`lastName`, `email` sowie optional `phone`, `ageGroup` und `notes`. Dabei muss
`acceptedTerms` den Wert `true` haben. Diese Angaben werden ebenfalls gespeichert.
Die Angebotsseite kann vorerst auf die eigenständige Ansicht verlinken:
Die Antwort enthält die Buchungsnummer, den Erstellungszeitpunkt, das gebuchte
Paket unter `bookedPackage` und den Versandstatus. Eine neue Buchung mit
SMTP-Annahme erhält HTTP 201, eine Wiederholung HTTP 200. Bei ausstehendem,
fehlgeschlagenem oder unklarem Versand wird HTTP 202 zurückgegeben.
```js
link.href = `/booking.html#/buchen/${encodeURIComponent(paket.id)}`;
```
Ungültige Angaben, Pakete und Varianten ergeben HTTP 400; ein unbekanntes Paket
am Detail-Endpunkt HTTP 404. Derselbe Anfrageschlüssel mit anderen Daten ergibt
HTTP 409. JSON-Anfragen sind auf 8 KiB begrenzt. Fehlerantworten enthalten
`error.code`, `error.message` und gegebenenfalls `error.fields`.
Es wurde kein Router in die gemeinsame Startseite eingebaut. Wenn Person 2 den
Router ergänzt, kann sie bei `/#/buchen/:packageId` diese Funktion aufrufen:
## Speicherung und Wiederholungen
```js
import { mountBookingPage } from "./features/booking/booking-page.js";
const cleanup = mountBookingPage(container, { packageId });
// Beim Verlassen der Route:
cleanup();
```
Die Buchungen liegen in `src/server/data/bookings.csv`. Neue Buchungsnummern
werden fortlaufend als `TH-000001`, `TH-000002` usw. vergeben. Der höchste
vorhandene Wert bestimmt die nächste Nummer. Die CSV muss deshalb vollständig
erhalten bleiben; ältere UUID-Buchungsnummern werden weiterhin unterstützt.
Das Modul bringt seine begrenzten `.booking`-Styles mit. Farben und Schrift
orientieren sich am vorhandenen Basis-CSS. Gemeinsame Designvariablen und eine
Button-Komponente gibt es bisher nicht. Layout und Navigation bleiben Aufgabe
der anderen Teammitglieder. „Zur Angebotsseite“ führt zu `/angebotsuebersicht.html`.
`bookedPackage` speichert den Bestellstand als JSON, `customer` die optionalen
strukturierten Kundendaten und `acceptedTerms` die zugehörige Zustimmung.
CSV-Dateien mit dem früheren Spaltensatz bleiben lesbar und werden beim nächsten
Speichern erweitert. Für alte Buchungen ohne Bestellstand liefert die API
`bookedPackage: null`.
## API-Beispiele
Lesen, Schreiben und Versand werden innerhalb einer Warteschlange abgearbeitet.
Die Datei wird über eine temporäre Datei atomar ersetzt. Unterstützt wird ein
Backend-Prozess auf einem lokalen Dateisystem. Mehrere Serverinstanzen benötigen
einen gemeinsam abgesicherten Speicher. CSV-Zellen werden gegen die Auswertung
als Tabellenformeln geschützt; Dateien erhalten den Zugriffsmodus 0600.
`GET /api/angebote` liefert `{ "packages": [...] }` mit `id`, `name`, `summary`
und `testOnly`. Keine personenbezogenen Daten oder SMTP-Einstellungen.
Wiederholte Anfragen mit demselben Schlüssel und denselben Daten liefern die
vorhandene Buchung. Es wird keine zweite Bestellung angelegt. Die Auswahl einer
anderen Variante mit demselben Schlüssel wird abgelehnt.
Für `POST /api/bookings` sind `Content-Type: application/json` und ein
`Idempotency-Key` als UUID v4 erforderlich. Ein Schlüssel gehört genau zu einer
Buchung. Bei Wiederholung denselben Schlüssel und dieselben Angaben verwenden.
Das Formular merkt sich Anfrage und Bestellübersicht je Paket und Variante im
`sessionStorage`. Nach einem Verbindungsfehler bleiben die Angaben gesperrt,
damit dieselbe Anfrage erneut geprüft werden kann. Ein neuer Tab oder gelöschter
Sitzungsspeicher liegt außerhalb dieser Absicherung.
```http
POST /api/bookings
Content-Type: application/json
Idempotency-Key: 36c45d7f-585f-47e9-bf5c-7ea1b7a162ed
## Versandstatus
{"packageId":"demo-booking","name":"Test Person","email":"person@example.test"}
```
| Status | Bedeutung |
| ---------- | ------------------------------------------------------------------------------------------------ |
| `pending` | Buchung gespeichert; Versand noch nicht gestartet. Dieselbe Anfrage kann den Versand fortsetzen. |
| `sending` | Versandabsicht gespeichert. Nach einem Prozessabbruch liefert die API `unknown`. |
| `accepted` | SMTP hat die Nachricht angenommen; die Zustellung ist noch nicht bestätigt. |
| `failed` | Versand fehlgeschlagen oder ausdrücklich abgelehnt. |
| `unknown` | SMTP-Annahme oder anschließende Statusspeicherung blieb unklar. |
Neue Buchung mit SMTP-Annahme: HTTP `201`; gespeicherte Wiederholung: `200`.
Bei `failed` und `unknown` wird nicht automatisch erneut versendet. Vor einem
manuellen Neuversand muss der Mailserver anhand der Buchungsnummer geprüft werden.
CSV und SMTP bilden keine gemeinsame Transaktion.
```json
{
"bookingId": "TH-000001",
"createdAt": "2026-09-24T12:00:00.000Z",
"packageId": "demo-booking",
"packageName": "1:1-Ernährungsberatung",
"saved": true,
"emailStatus": "accepted",
"replayed": false
}
```
## Tests
Gespeichert, aber E-Mail nicht bestätigt: HTTP `202` mit derselben Struktur und
`emailStatus: "failed"`, `"pending"` oder `"unknown"`. Das bedeutet ausdrücklich
nicht, dass die E-Mail zugestellt wurde. Die Buchungsnummer bleibt gültig.
`npm test` prüft Validierung, Preisberechnung, Varianten, CSV-Kompatibilität,
Wiederholungen und Versandfehler. `npm run test:browser` prüft den Weg von der
Angebotsübersicht über die Paketdetails bis zur Bestätigung, einschließlich
Premium-Jahresvariante, Tastaturbedienung und schmalem Bildschirm.
| Status/Code | Bedeutung und Verhalten |
| ------------------------------ | ----------------------------------------------------------------------- |
| `400 INVALID_INPUT` | Angaben korrigieren; Feldhinweise stehen in `error.fields` |
| `400 UNKNOWN_PACKAGE` | Paket nicht verfügbar; verfügbares Paket auswählen |
| `400 INVALID_KEY` | UUID-v4-Anfrageschlüssel fehlt oder ist ungültig |
| `400 INVALID_JSON` | Anfrage ist kein gültiges JSON |
| `409 IDEMPOTENCY_CONFLICT` | Derselbe Schlüssel wurde mit anderen Angaben verwendet |
| `413 BODY_TOO_LARGE` | Mehr als 8 KiB Anfrageinhalt |
| `415 UNSUPPORTED_CONTENT_TYPE` | JSON-Content-Type erforderlich |
| `503 BOOKING_NOT_SAVED` | Speicherung fehlgeschlagen; mit gleichem Schlüssel wiederholen |
| `503 STORAGE_UNAVAILABLE` | Vorhandener Status nicht lesbar; mit gleichem Schlüssel wiederholen |
| `500 INTERNAL_ERROR` | Status unklar; keine neue Buchung erzeugen, gleiche Anfrage wiederholen |
Die Tests verwenden die regulären Paketdaten, temporäre CSV-Dateien und einen
simulierten Mailversand. Playwright benötigt Chromium; die Installation erfolgt
mit `npx playwright install --with-deps chromium`. Die Ports 3000 und 5173 müssen
für die automatisch gestarteten Testserver frei sein.
Die E-Mail-Prüfung unterstützt übliche unquotierte ASCII-Adressen. Internationale
Domains können in Punycode angegeben werden; SMTPUTF8-Adressen sind nicht Teil
dieses Sprints. Eine Formatprüfung beweist nicht, dass das Postfach existiert.
Beispiel für einen Validierungsfehler:
```json
{
"error": {
"code": "INVALID_INPUT",
"message": "Bitte die markierten Angaben prüfen.",
"fields": { "email": "Bitte eine gültige E-Mail-Adresse eingeben." }
}
}
```
## Simulation und spätere Zahlung
Das Demonstrationspaket heißt in der Oberfläche „1:1-Ernährungsberatung“.
Die ID `demo-booking` und die Freigabe über `BOOKING_DEMO` bleiben unverändert;
es wurden keine verbindlichen Angebote, Leistungsumfänge oder Preise ergänzt.
Ein gemeinsamer Hinweis aus `src/shared/booking-copy.js` erklärt vor dem Absenden
und in der E-Mail, dass der Ablauf simuliert ist: Der Zahlungsvorgang wird
übersprungen, es wird nichts abgebucht und keine verbindliche Beratung gebucht.
Bei vollständiger Integration kann der Zahlungsschritt ergänzt werden. Dafür
müssen tatsächliche Zahlungsbestätigung und Buchungsstatus serverseitig verbunden
werden; das Entfernen des Hinweises allein aktiviert keine Zahlung.
## Fortlaufende Buchungsnummern
Neue Buchungen erhalten `TH-000001`, `TH-000002` usw. Dieselbe Nummer steht in der
Antwort, CSV und E-Mail. Sie wird innerhalb der Warteschlange aus dem höchsten
vorhandenen TH-Wert berechnet und mit der Buchung gespeichert. Ein Neustart setzt
sie nicht zurück; Wiederholungen behalten dieselbe Nummer. Sechs Stellen sind
die Mindestbreite, nach `TH-999999` folgt `TH-1000000`. Es gibt keinen Jahresreset.
Bestehende UUID-Buchungen und gespeicherte Browserbestätigungen behalten ihre
bisherige Nummer, damit bereits ausgegebene Referenzen gültig bleiben. Nur neue
Buchungen verwenden das neue Format. Der technische Idempotency-Key bleibt eine
zufällige UUID; die lesbare Buchungsnummer ist kein Geheimnis oder Zugriffstoken.
Der Zähler setzt voraus, dass die CSV vollständig erhalten bleibt. Löschen von
Zeilen, Entfernen der Datei oder Zurückspielen eines älteren Backups kann Nummern
wiederverwendbar machen. Vor einer Archivierungs-/Löschfunktion ist ein separat
persistierter, transaktionaler Zähler nötig. Weiterhin nur ein Backend-Prozess.
## CSV, Wiederholungen und Fehlergrenzen
Pfad: `src/server/data/bookings.csv`. Spalten: `bookingId`, `createdAt`, `packageId`,
`packageName`, `name`, `email`, `emailStatus`, `idempotencyKey`, `requestHash`.
`requestHash` verknüpft den Schlüssel mit den normalisierten Anfragedaten, ohne
sie in einer zweiten Datenquelle zu duplizieren. Bereits gespeicherte Buchungen
können auch nach dem Entfernen eines Pakets weiterhin wiederholt abgefragt werden.
CSV-Bibliotheken behandeln Kommas, Anführungszeichen und Zeilenumbrüche. Zellen,
die wie Tabellenformeln beginnen, erhalten ein Apostroph. Bereits führende
Apostrophe werden ebenfalls maskiert, damit internes Lesen die Originalwerte
wiederherstellen kann. Persönliche Namen dürfen in der API keine Steuerzeichen
enthalten; die Speicherschicht unterstützt Zeilenumbrüche für andere CSV-Felder.
Dateien werden mit Zugriffsmodus `0600` geschrieben.
Eine Warteschlange führt komplette Buchungen nacheinander aus. Schreiben erfolgt
über eine temporäre Datei und atomisches Umbenennen. **Nur ein laufender
Backend-Prozess auf einem lokalen Dateisystem wird unterstützt.** Keine Cluster,
mehreren Containerinstanzen, manuelle Dateiedits während des Betriebs oder
Netzlaufwerke. Die gesamte CSV wird bei Änderungen gelesen/neu geschrieben;
geeignet für den ersten Sprint mit kleinem Volumen. Es gibt keine verteilte
Transaktion oder vollständige Garantie gegen Hardware-/Stromausfall.
Versandzustände:
- `pending`: Buchung gespeichert, Versand noch nicht gestartet. Derselbe Schlüssel
darf den Versand nach einem Schreibfehler fortsetzen.
- `sending`: Versandabsicht dauerhaft gespeichert. Bei Prozessabbruch zeigt die
API diesen Zustand als `unknown`; kein automatischer Neuversand.
- `accepted`: SMTP hat den Empfänger und die Nachricht angenommen.
- `failed`: Der Versand ist fehlgeschlagen bzw. wurde ausdrücklich abgelehnt.
- `unknown`: Verbindung oder Statusspeicherung brach zu einem unklaren Zeitpunkt ab.
Für `failed` und `unknown` gibt es bewusst keinen automatischen erneuten Versand.
Erst CSV und Mailserver/Mailfänger anhand der Buchungsnummer prüfen. Eine
administrative Wiederholungsfunktion ist offen; keinesfalls eine neue Buchung
anlegen oder `sending` blind zurücksetzen. SMTP und CSV können nicht gemeinsam
atomar abgeschlossen werden, weshalb ein exakt einmaliger Versand nicht garantiert
werden kann. Ein Verbindungsabbruch nach SMTP-Annahme kann doppelte Mails verursachen,
wenn später ohne Prüfung manuell erneut gesendet wird.
Das Formular speichert Schlüssel und Angaben je Paket in `sessionStorage`, also
für den aktuellen Browser-Tab, auch über Neuladen hinweg. Bei unklarem Ergebnis
bleiben die Angaben schreibgeschützt und können mit demselben Schlüssel erneut
geprüft werden. Nach gespeicherter Buchung bleibt das Formular gesperrt. Ein
bewusst neuer Auftrag benötigt einen neuen Tab bzw. eine neue Sitzung. Das
Schließen des Tabs, gelöschter Sitzungsspeicher, ein anderes Gerät oder ein neuer
Schlüssel liegen außerhalb der Doppelbuchungsabsicherung. Bei unklarem Ausgang
zuerst den bestehenden Status prüfen. Es gibt keine globale Erkennung anhand
von Name/E-Mail, weil zwei bewusst getrennte Buchungen möglich sein müssen.
`.gitignore` schließt Buchungsdaten einschließlich temporärer Dateien und `.env`
aus. Das Backend liefert ausschließlich API-Antworten aus. Vite blockiert
Serververzeichnis und Umgebungsdateien auch beim direkten Dateizugriff. Im
Deployment nur `dist/` öffentlich bereitstellen, nie den gesamten Projektordner.
## Prüfen
Automatisierte Tests erzeugen temporäre CSV-Dateien und simulieren E-Mail-Versand.
Sie schreiben nicht in `src/server/data/bookings.csv` und kontaktieren kein echtes SMTP.
Vor Browsertests lokale Server auf 3000/5173 stoppen; Playwright startet eigene
Testserver und übernimmt keine bereits laufenden Instanzen.
```bash
npm test
npx playwright install --with-deps chromium
npm run test:browser
npm run format:check
npm run build
```
Die einmalige Chromium-Installation benötigt Netzwerk und unter Linux eventuell
Systempakete. Der Dev Container wurde dafür nicht global umkonfiguriert.
Manuelle Abnahme im lokalen Mailfänger:
1. Demo-Modus, Mailpit, Backend und Vite wie oben starten.
2. Testpaket mit `person@example.test` buchen. Buchungsnummer notieren.
3. CSV im Editor öffnen: genau eine passende Zeile, Paket, Zeitpunkt und Versandstatus prüfen.
4. In Mailpit die Nachricht öffnen: Empfänger, Paket und Buchungsnummer vergleichen.
5. Seite neu laden: gleiche Buchungsnummer, keine neue CSV-Zeile und keine zweite Mail.
6. Mit Tab/Shift+Tab und Enter bedienen, Feldfehler und sichtbaren Fokus prüfen.
Auf 320 Pixel Breite und mit Browser-Zoom prüfen; ergänzend Screenreader verwenden.
7. Für den Versandfehler einen neuen Test in neuer Sitzung bei gestopptem Mailpit
ausführen: Buchung bleibt gespeichert, Anzeige meldet keine erfolgreiche Zustellung.
**Das Akzeptanzkriterium „E-Mail zugestellt“ ist mit SMTP-Annahme oder Mailpit allein
noch nicht für einen echten Empfänger nachgewiesen.** Erst nach ausdrücklicher
Freigabe echtes SMTP konfigurieren und an eine vereinbarte Testadresse senden.
Dann im tatsächlichen Empfängerpostfach (auch Spam) Eingang, Buchungsnummer und
Paket kontrollieren und Zeitpunkt sowie Ergebnis im Sprint-Nachweis festhalten.
Es gibt bisher keine automatische Bounce-/Zustellverfolgung.
## Durchgeführte Prüfungen
- 17 Backend-Tests: Validierung, CSV-Sonderzeichen/Formelschutz, parallele
Anfragen, Wiederholung mit neuer Serviceinstanz, Speicher- und SMTP-Fehler.
- 6 Chromium-Prüfungen: Tastaturbedienung/Fokus, 320-Pixel-Ansicht, verlorene
Antwort mit Neuladen, Ladezustand, Versandfehler und private Dateipfade.
- Lokaler SMTP-Test mit Mailpit und temporärer CSV: Nachricht tatsächlich im
Mailfänger empfangen, Paket/Buchungsnummer geprüft, wiederholte Anfrage ohne
zusätzliche CSV-Zeile oder E-Mail. Ausschließlich `example.test`-Empfänger.
- Projektweite Formatprüfung, Produktionsbuild und Git-Whitespace-Prüfung.
Die automatisierten Prüfungen ersetzen keine Screenreader-Abnahme oder Prüfung
in weiteren Browsern. Keine echte E-Mail-Zustellung wurde getestet. Zum Ausführen
der Browserprüfungen wurden Chromium und die benötigten Systembibliotheken in
dieser Arbeitsumgebung installiert; die Dev-Container-Konfigurationsdatei wurde
nicht verändert. Der Mailpit-Test verwendete eine temporär heruntergeladene
Binärdatei außerhalb des Repositorys.
## Offene Punkte und gemeinsame Dateien
Die Angebotsübersicht ist als eigene HTML-Seite angebunden. Offen: Anschluss an einen gemeinsamen Router, abschließendes
Teamdesign, reale Zustellprüfung und Screenreader-Abnahme. Vor öffentlichem
Betrieb sind außerdem Betriebsfragen wie Zugriff auf CSV/Backups, Aufbewahrung
und Schutz des öffentlichen Buchungsendpunkts vor automatisiertem Massenversand
zu klären. Keine Zahlungen, Benutzerkonten oder Datenbank implementiert.
Gemeinsame Änderungen: `package.json` und Lockdatei (Abhängigkeiten/Startbefehle),
`src/main.js` (bestehenden falschen CSS-Import korrigiert), `.gitignore` und
`.prettierignore` (private Daten/Testergebnisse ausschließen). Neu hinzugefügt:
`vite.config.js` (Proxy, Dateizugriffsschutz, zwei HTML-Einstiegspunkte),
`src/shared/packages.js` (gemeinsamer Paketvertrag), `.env.example`, `booking.html`
und `playwright.config.js`. Die Dev-Container-Konfiguration installiert Mailpit und leitet dessen Web-Port weiter.
Technische Referenzen: [CSV-Parser](https://csv.js.org/parse/api/sync/),
[Playwright-Testserver](https://playwright.dev/docs/test-webserver).
Vor einem Commit außerdem `npm run format:check`, `npm run build` und
`git diff --check` ausführen. Für eine manuelle Mailprüfung eine Buchung mit einer
Adresse unter `example.test` anlegen und die Bestätigung in Mailpit kontrollieren.

View File

@ -1,15 +1,15 @@
<!doctype html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paket-Details – Tri-Hub Ernährungsberatung</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:wght@600;700&display=swap"
rel="stylesheet">
rel="stylesheet"
/>
<link rel="stylesheet" href="/src/styles/packages.css" />
</head>
@ -24,7 +24,9 @@
</div>
</main>
<script type="module" src="/src/features/angebote/paket-details.js"></script>
<script
type="module"
src="/src/features/angebote/paket-details.js"
></script>
</body>
</html>

View File

@ -1,21 +1,16 @@
import { packages } from "../../shared/packages.js";
export function renderAngebotsuebersicht() {
// Wir suchen den Container, der in der angebotsuebersicht.html definiert wurde
const container = document.getElementById("packages-container");
// Sicherheitscheck: Wenn der Container nicht da ist, brechen wir ab
if (!container) {
console.warn("Container für Angebotsübersicht nicht gefunden.");
return;
}
// Container leeren (falls die Funktion mehrfach aufgerufen wird)
container.innerHTML = "";
// Durch jedes Paket iterieren und das HTML generieren
packages.forEach((pkg) => {
// Highlight-Badge nur anzeigen, wenn es eins gibt
const highlightHtml = pkg.highlight
? `<span class="package-highlight">${pkg.highlight}</span>`
: "";
@ -46,13 +41,11 @@ export function renderAngebotsuebersicht() {
</div>
</div>
<!-- HIER ist der wichtige Link für deinen Kollegen: -->
<a href="paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText}</a>
</div>
`;
// Das generierte HTML dem Container hinzufügen
container.innerHTML += cardHtml;
});
}

View File

@ -1,16 +1,12 @@
// src/features/angebote/paket-details.js
import { packagesDetails } from "../../shared/packagesdetails.js";
document.addEventListener("DOMContentLoaded", async () => {
document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("package-detail-container");
if (!container) return;
// 1. ID aus der URL lesen (z.B. ?id=ernaehrung-standard)
const params = new URLSearchParams(window.location.search);
const packageId = params.get("id");
// 2. Passendes Paket in packagesdetails.js suchen
// (Später kann hier alternativ: await fetch(`/api/packages/${packageId}`) genutzt werden)
const pkg = packagesDetails.find((item) => item.id === packageId);
if (!pkg) {
@ -24,7 +20,6 @@ document.addEventListener("DOMContentLoaded", async () => {
return;
}
// 3. Optional: Varianten-Auswahl rendern (für das Premium-Paket)
const variantsHtml = pkg.variants
? `<div class="detail-box">
<h3>Wähle deine Variante</h3>
@ -34,7 +29,6 @@ document.addEventListener("DOMContentLoaded", async () => {
</div>`
: "";
// 4. Detailseite im Tri-Hub-Stil rendern
container.innerHTML = `
<div class="package-card detail-view">
<div class="package-type-container">
@ -87,7 +81,6 @@ document.addEventListener("DOMContentLoaded", async () => {
</div>
`;
// 5. Preis aktualisieren, falls eine andere Variante (24 vs. 52 Wochen) gewählt wird
const variantSelect = document.getElementById("variant-select");
const displayedPrice = document.getElementById("displayed-price");
if (variantSelect && displayedPrice) {
@ -100,33 +93,10 @@ document.addEventListener("DOMContentLoaded", async () => {
});
}
// 6. Klick auf "Jetzt buchen" -> Daten für bookingService bereitstellen & weiterleiten
const bookingBtn = document.getElementById("proceed-to-booking-btn");
bookingBtn.addEventListener("click", () => {
const selectedVariantId = variantSelect ? variantSelect.value : pkg.id;
const selectedVariant = pkg.variants
? pkg.variants.find((v) => v.variantId === selectedVariantId)
: null;
const bookingPayload = {
packageId: pkg.id,
variantId: selectedVariantId,
title: pkg.title,
type: pkg.type,
durationWeeks: selectedVariant
? selectedVariant.durationWeeks
: pkg.durationWeeks,
price: selectedVariant ? selectedVariant.price : pkg.price,
currency: pkg.currency,
taxRate: pkg.taxRate,
summaryForBooking: pkg.summaryForBooking,
};
// Im sessionStorage zwischenspeichern + per URL an die Buchungsseite übergeben
sessionStorage.setItem(
"selectedPackageForBooking",
JSON.stringify(bookingPayload),
);
window.location.href = `booking.html?id=${pkg.id}&variant=${selectedVariantId}`;
const params = new URLSearchParams({ id: pkg.id });
if (variantSelect) params.set("variant", variantSelect.value);
window.location.href = `booking.html?${params}`;
});
});

View File

@ -33,10 +33,6 @@ async function request(path, options = {}) {
return body;
}
export function loadPackages(signal) {
return request("/api/angebote", { signal });
}
export function submitBooking(payload, idempotencyKey) {
return request("/api/bookings", {
method: "POST",
@ -47,3 +43,12 @@ export function submitBooking(payload, idempotencyKey) {
body: JSON.stringify(payload),
});
}
export function previewBooking(packageId, variantId, signal) {
return request("/api/bookings/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ packageId, variantId }),
signal,
});
}

View File

@ -5,9 +5,12 @@ const root = document.querySelector("#booking-root");
let cleanup;
function render() {
cleanup?.();
// Eigenständige Testseite, bis Person 2 den gemeinsamen Router bereitstellt.
const match = location.hash.match(/^#\/buchen\/([a-z0-9-]+)$/);
cleanup = mountBookingPage(root, { packageId: match?.[1] });
const params = new URLSearchParams(location.search);
cleanup = mountBookingPage(root, {
packageId: params.get("id") || match?.[1],
variantId: params.get("variant") || undefined,
});
}
window.addEventListener("hashchange", render);
render();

View File

@ -1,10 +1,9 @@
import { loadPackages, submitBooking } from "./booking-api.js";
import { previewBooking, submitBooking } from "./booking-api.js";
import "./booking.css";
import { orderSummaryRows } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js";
// Kann später vom gemeinsamen Router aufgerufen werden. cleanup entfernt
// Listener und bricht das Laden beim Verlassen der Ansicht ab.
export function mountBookingPage(root, { packageId } = {}) {
export function mountBookingPage(root, { packageId, variantId } = {}) {
const controller = new AbortController();
const { signal } = controller;
root.innerHTML = `
@ -24,20 +23,22 @@ export function mountBookingPage(root, { packageId } = {}) {
return;
}
try {
const data = await loadPackages(signal);
const selected = await previewBooking(packageId, variantId, signal);
if (signal.aborted) return;
const selected = data.packages.find(
(entry) => entry.id === packageId,
);
if (!selected) {
intro.textContent =
"Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.";
return;
}
intro.remove();
renderForm(selected);
} catch {
} catch (error) {
if (signal.aborted) return;
if (
[
"UNKNOWN_PACKAGE",
"INVALID_VARIANT",
"INVALID_INPUT",
].includes(error.code)
) {
intro.textContent = error.message;
return;
}
intro.textContent =
"Das Paket konnte nicht geladen werden. Bitte prüfe deine Verbindung und versuche es erneut.";
const retry = document.createElement("button");
@ -61,6 +62,11 @@ export function mountBookingPage(root, { packageId } = {}) {
<div class="booking__package">
<h2 class="booking__package-name"></h2>
<p class="booking__summary"></p>
<p class="booking__description"></p>
<dl class="booking__totals"></dl>
<h3>Enthaltene Leistungen</h3>
<ul class="booking__features"></ul>
<div class="booking__steps"></div>
</div>
<aside class="booking__payment" aria-labelledby="booking-payment-heading">
<h2 id="booking-payment-heading">Hinweis zum Buchungsablauf</h2>
@ -84,10 +90,44 @@ export function mountBookingPage(root, { packageId } = {}) {
<p class="booking__progress" role="status"></p>
</form>
<div class="booking__result" role="status" tabindex="-1" hidden></div>`;
function renderOrder(order) {
content.querySelector(".booking__package-name").textContent =
selected.name;
order.title;
content.querySelector(".booking__summary").textContent =
selected.summary;
order.summaryForBooking;
content.querySelector(".booking__description").textContent =
order.subtitle;
const totals = content.querySelector(".booking__totals");
totals.replaceChildren();
for (const [label, value] of orderSummaryRows(order)) {
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = label;
description.textContent = value;
totals.append(term, description);
}
const features = content.querySelector(".booking__features");
features.replaceChildren(
...order.includedFeatures.map((text) => {
const item = document.createElement("li");
item.textContent = text;
return item;
}),
);
const steps = content.querySelector(".booking__steps");
steps.replaceChildren();
if (order.processSteps?.length) {
const heading = document.createElement("h3");
heading.textContent = "So geht es weiter";
steps.append(heading);
for (const entry of order.processSteps) {
const paragraph = document.createElement("p");
paragraph.textContent = `${entry.step}: ${entry.text}`;
steps.append(paragraph);
}
}
}
renderOrder(selected);
content.querySelector(".booking__payment-notice").textContent =
paymentNotice;
const form = content.querySelector("form");
@ -97,7 +137,7 @@ export function mountBookingPage(root, { packageId } = {}) {
const errors = form.querySelector(".booking__errors");
const progress = form.querySelector(".booking__progress");
const result = content.querySelector(".booking__result");
const storageKey = `trihub.booking.${selected.id}`;
const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`;
let attempt = null;
let busy = false;
let finished = false;
@ -128,6 +168,7 @@ export function mountBookingPage(root, { packageId } = {}) {
}
}
function showResult(body, focus = true) {
if (body.bookedPackage) renderOrder(body.bookedPackage);
const messages = {
accepted:
"Buchung gespeichert. Die Bestätigung wurde vom Mailserver zum Versand angenommen. Die Zustellung ist noch nicht bestätigt.",
@ -150,8 +191,12 @@ export function mountBookingPage(root, { packageId } = {}) {
try {
const stored = JSON.parse(sessionStorage.getItem(storageKey));
if (stored?.key && stored.payload?.packageId === selected.id) {
if (
stored?.key &&
stored.payload?.packageId === selected.packageId
) {
attempt = stored;
if (stored.order) renderOrder(stored.order);
name.value = stored.payload.name;
email.value = stored.payload.email;
lockFields(true);
@ -194,8 +239,12 @@ export function mountBookingPage(root, { packageId } = {}) {
}
const nextAttempt = {
key: crypto.randomUUID(),
order: selected,
payload: {
packageId: selected.id,
packageId: selected.packageId,
...(selected.variantId
? { variantId: selected.variantId }
: {}),
name: name.value,
email: email.value,
},
@ -242,6 +291,7 @@ export function mountBookingPage(root, { packageId } = {}) {
[
"INVALID_INPUT",
"UNKNOWN_PACKAGE",
"INVALID_VARIANT",
"INVALID_KEY",
].includes(error.code)
) {

View File

@ -94,3 +94,26 @@
width: 100%;
}
}
.booking__totals {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem 1rem;
padding-block: 1rem;
border-block: 1px solid #d4ddd7;
}
.booking__totals dd {
margin: 0;
text-align: right;
}
.booking__totals dt:last-of-type,
.booking__totals dd:last-of-type {
font-weight: 700;
font-size: 1.15rem;
}
.booking__features {
padding-left: 1.25rem;
}
.booking__features li {
margin-block: 0.5rem;
}

View File

@ -1,7 +1,10 @@
import { createServer } from "node:http";
import { fileURLToPath, pathToFileURL } from "node:url";
import { packages, demoPackages } from "../shared/packages.js";
import { createBookingService } from "./services/booking-service.js";
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";
@ -11,6 +14,7 @@ export function createApp({
storage = createCsvStorage(
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
),
details,
sendConfirmation,
} = {}) {
const ids = new Set();
@ -34,6 +38,7 @@ export function createApp({
}
const service = createBookingService({
storage,
details,
packages: catalog,
sendConfirmation: sendConfirmation ?? createEmailService(),
});
@ -41,15 +46,43 @@ export function createApp({
const path = new URL(request.url, "http://localhost").pathname;
if (path === "/api/angebote" && request.method === "GET") {
return json(response, 200, {
packages: catalog.map(({ id, name, summary, testOnly }) => ({
packages: catalog.map(({ id, name, summary }) => ({
id,
name,
summary,
testOnly: Boolean(testOnly),
})),
});
}
if (path === "/api/bookings") {
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, {
@ -59,7 +92,9 @@ export function createApp({
},
});
}
return handleBooking(request, response, service);
return handleBooking(request, response, service, {
preview: path.endsWith("/preview"),
});
}
// Ausschließlich API; keine Auslieferung von CSV oder Serverdateien.
return json(response, 404, {
@ -72,16 +107,9 @@ if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
const demo = process.env.BOOKING_DEMO === "true";
if (demo && process.env.NODE_ENV === "production")
throw new Error("Demo-Modus ist in Produktion gesperrt.");
const port = Number(process.env.PORT || 3000);
const server = createApp({
catalog: demo ? [...packages, ...demoPackages] : packages,
});
const server = createApp();
server.listen(port, process.env.HOST || "127.0.0.1", () => {
console.log(
`Buchungs-API auf Port ${port}${demo ? " (Testpaket aktiviert)" : ""}`,
);
console.log(`Buchungs-API auf Port ${port}`);
});
}

View File

@ -43,9 +43,15 @@ async function readJson(request) {
}
}
export async function handleBooking(request, response, service) {
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"],

View File

@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { packagesDetails } from "../../shared/packagesdetails.js";
export class BookingError extends Error {
constructor(status, code, message, fields = {}) {
@ -30,6 +31,52 @@ function validateInput(input, key) {
"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 [
@ -73,6 +120,24 @@ function validateInput(input, key) {
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;
}
@ -101,6 +166,9 @@ function responseFor(record, replayed) {
createdAt: record.createdAt,
packageId: record.packageId,
packageName: record.packageName,
bookedPackage: record.bookedPackage || null,
customerEmail: record.email,
status: "CONFIRMED",
saved: true,
emailStatus,
replayed,
@ -108,8 +176,85 @@ function responseFor(record, replayed) {
};
}
export function createBookingService({ storage, sendConfirmation, packages }) {
// Eine Warteschlange umfasst Lesen, Schreiben UND Versand. Nur ein Prozess!
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);
@ -149,11 +294,15 @@ export function createBookingService({ storage, sendConfirmation, packages }) {
{ 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,
emailStatus: "pending",
@ -194,6 +343,8 @@ export function createBookingService({ storage, sendConfirmation, packages }) {
return responseFor(record, replayed);
}
return {
getPackage,
preview,
book(input, key) {
const task = queue.then(() => processBooking(input, key));
queue = task.catch(() => {});

View File

@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
import { parse } from "csv-parse/sync";
import { stringify } from "csv-stringify/sync";
export const columns = [
const legacyColumns = [
"bookingId",
"createdAt",
"packageId",
@ -16,6 +16,10 @@ export const columns = [
"requestHash",
];
const orderColumns = [...legacyColumns, "bookedPackage"];
export const columns = [...orderColumns, "customer", "acceptedTerms"];
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms"];
// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
function protectCell(value) {
@ -41,7 +45,11 @@ export function createCsvStorage(filePath) {
return parse(content, {
bom: true,
columns(header) {
if (header.join(",") !== columns.join(",")) {
if (
header.join(",") !== columns.join(",") &&
header.join(",") !== legacyColumns.join(",") &&
header.join(",") !== orderColumns.join(",")
) {
throw new Error("Unbekanntes CSV-Format");
}
return header;
@ -51,7 +59,11 @@ export function createCsvStorage(filePath) {
Object.fromEntries(
Object.entries(row).map(([key, value]) => [
key,
restoreCell(value),
jsonColumns.includes(key)
? value
? JSON.parse(restoreCell(value))
: null
: restoreCell(value),
]),
),
);
@ -64,7 +76,12 @@ export function createCsvStorage(filePath) {
Object.fromEntries(
columns.map((column) => [
column,
protectCell(row[column]),
protectCell(
jsonColumns.includes(column) &&
row[column] != null
? JSON.stringify(row[column])
: row[column],
),
]),
),
),

View File

@ -1,4 +1,5 @@
import nodemailer from "nodemailer";
import { orderSummaryRows } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js";
export function createEmailService(
@ -34,23 +35,73 @@ export function createEmailService(
});
const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>";
return async function sendConfirmation(booking) {
const order = booking.bookedPackage;
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}`,
...(booking.customer?.phone
? [`Telefon: ${booking.customer.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) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char],
);
let info;
try {
info = await transport.sendMail({
from,
to: { address: booking.email, name: booking.name },
subject: `Buchungsbestätigung ${booking.bookingId}`,
text: [
`Hallo ${booking.name},`,
"",
"vielen Dank für deine Buchung. Wir haben deine Angaben erhalten.",
`Buchungsnummer: ${booking.bookingId}`,
`Paket: ${booking.packageName}`,
"",
paymentNotice,
"",
"Dein Tri-Hub-Team",
].join("\n"),
subject: `Bestellbestätigung ${booking.bookingId} – Tri-Hub`,
text: lines.join("\n"),
html: `<html lang="de"><body><h1>Deine Bestellbestätigung</h1>${lines.map((line) => (line ? `<p>${escapeHtml(line)}</p>` : "")).join("")}</body></html>`,
});
} catch (error) {
// Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen

View File

@ -3,7 +3,7 @@
"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).",
"version": "1.0.0"
"version": "1.1.0"
},
"servers": [
{
@ -78,7 +78,7 @@
"/bookings/preview": {
"post": {
"summary": "Preis- und Leistungsübersicht für den Bestellprozess berechnen",
"description": "Wird vom bookingService aufgerufen, um vor Abschluss der Buchung die relevanten Bestelldaten (Nettopreis, MwSt., Bruttopreis, gewählte Variante) validiert anzuzeigen.",
"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,
@ -117,7 +117,7 @@
"/bookings": {
"post": {
"summary": "Paket verbindlich buchen (US2.4)",
"description": "Nimmt die ausgewählten Paketdetails und die Kundendaten entgegen, speichert die Buchung im bookingService und gibt eine Bestellbestätigung zurück.",
"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,
@ -149,9 +149,81 @@
}
}
}
},
"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."
}
]
}
}
},
"components": {
@ -222,7 +294,8 @@
"price": {
"type": "number",
"format": "float",
"example": 799.0
"example": 799.0,
"description": "Bruttopreis der Variante inklusive MwSt."
}
}
},
@ -270,7 +343,8 @@
"price": {
"type": "number",
"format": "float",
"example": 449.0
"example": 449.0,
"description": "Bruttopreis inklusive der angegebenen MwSt."
},
"currency": {
"type": "string",
@ -351,7 +425,8 @@
},
"variantId": {
"type": "string",
"example": "ernaehrung-premium-52"
"example": "ernaehrung-premium-52",
"nullable": true
},
"title": {
"type": "string",
@ -382,12 +457,77 @@
"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", "customer", "acceptedTerms"],
"required": ["packageId"],
"properties": {
"packageId": {
"type": "string",
@ -396,7 +536,19 @@
"variantId": {
"type": "string",
"nullable": true,
"example": "ernaehrung-standard"
"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"
},
"customer": {
"type": "object",
@ -431,16 +583,26 @@
},
"acceptedTerms": {
"type": "boolean",
"example": true
"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 übergeben."
},
"BookingConfirmation": {
"type": "object",
"properties": {
"bookingId": {
"type": "string",
"example": "BK-2026-84920"
"example": "TH-000001"
},
"status": {
"type": "string",
@ -451,28 +613,59 @@
"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"
},
"confirmationMessage": {
"packageId": {
"type": "string"
},
"packageName": {
"type": "string"
},
"saved": {
"type": "boolean"
},
"emailStatus": {
"type": "string",
"example": "Vielen Dank für deine Buchung! Deine Bestellbestätigung wurde per E-Mail versendet."
"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": "string",
"example": "Paket nicht gefunden"
},
"type": "object",
"required": ["code", "message"],
"properties": {
"code": {
"type": "integer",
"example": 404
"type": "string",
"example": "INVALID_VARIANT"
},
"message": {
"type": "string"
},
"fields": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}

View File

@ -1,4 +1,2 @@
// Gemeinsamer Hinweis für Buchungsseite und Bestätigungs-E-Mail.
// Bei der späteren Integration echter Zahlungen zusammen mit dem Ablauf ändern.
export const paymentNotice =
"Dieser Buchungsablauf ist eine Simulation. Der Zahlungsvorgang wird übersprungen: Es erfolgt keine Abbuchung und keine verbindliche Beratungsbuchung. Bei der vollständigen Integration des Bestellprozesses kann die Zahlung ergänzt werden.";
"Dieser Buchungsablauf ist eine Simulation. Der Zahlungsvorgang wird übersprungen: Es erfolgt keine Abbuchung und keine verbindliche Beratungsbuchung.";

View File

@ -0,0 +1,28 @@
export function formatMoney(amount, currency) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
}).format(amount);
}
export function orderSummaryRows(order) {
return [
["Paket", order.type],
["Paket-ID", order.packageId],
...(order.variantId
? [
["Variante", order.variantLabel],
["Varianten-ID", order.variantId],
]
: []),
["Laufzeit", order.durationLabel || `${order.durationWeeks} Wochen`],
["Menge", String(order.quantity)],
["Abrechnung", order.billingInterval],
["Nettopreis", formatMoney(order.netPrice, order.currency)],
[
`MwSt. (${order.taxRate} %)`,
formatMoney(order.taxAmount, order.currency),
],
["Gesamtbetrag", formatMoney(order.grossPrice, order.currency)],
];
}

View File

@ -42,13 +42,3 @@ export const packages = [
highlight: null,
},
];
// Nur für lokale Demonstrationen und isolierte Tests.
export const demoPackages = [
{
id: "demo-booking",
name: "1:1-Ernährungsberatung",
summary: "Testpaket für den simulierten Buchungsablauf.",
testOnly: true,
},
];

View File

@ -24,7 +24,6 @@ test("API liefert für jedes angezeigte Angebot dieselbe ID, Namen und Beschreib
id,
name,
summary,
testOnly: false,
})),
);
});

View File

@ -8,10 +8,10 @@ import { createApp } from "../server/index.js";
import { createCsvStorage } from "../server/services/csv-storage.js";
import { createBookingService } from "../server/services/booking-service.js";
import { createEmailService } from "../server/services/email-service.js";
import { demoPackages } from "../shared/packages.js";
import { packages } from "../shared/packages.js";
const input = {
packageId: demoPackages[0].id,
packageId: packages[0].id,
name: 'Test, "Person"',
email: "person@example.test",
};
@ -27,7 +27,7 @@ async function setup(t, options = {}) {
sent.push({ ...booking });
});
const dependencies = {
catalog: demoPackages,
catalog: packages,
storage,
sendConfirmation,
...options,
@ -63,7 +63,7 @@ test("Buchung steht vor Versand in CSV; Paketname kommt vom Server", async (t) =
});
assert.equal(result.status, 201);
assert.equal(result.body.emailStatus, "accepted");
assert.equal(result.body.packageName, demoPackages[0].name);
assert.equal(result.body.packageName, packages[0].name);
assert.equal(result.body.bookingId, "TH-000001");
assert.ok(!Number.isNaN(Date.parse(result.body.createdAt)));
const records = await fixture.storage.readAll();
@ -230,7 +230,7 @@ test("Ausstehender Versand kann nach Schreibfehler sicher fortgesetzt werden", a
let writes = 0;
let sends = 0;
const service = createBookingService({
packages: demoPackages,
packages: packages,
storage: {
readAll: () => storage.readAll(),
writeAll: async (rows) => {
@ -257,7 +257,7 @@ test("Status-Schreibfehler nach SMTP bleibt unklar und löst keinen Doppelversan
let writes = 0;
let sends = 0;
const service = createBookingService({
packages: demoPackages,
packages: packages,
storage: {
readAll: () => storage.readAll(),
writeAll: async (rows) => {
@ -274,7 +274,7 @@ test("Status-Schreibfehler nach SMTP bleibt unklar und löst keinen Doppelversan
assert.equal((await storage.readAll())[0].emailStatus, "sending");
const restarted = createBookingService({
storage,
packages: demoPackages,
packages: packages,
sendConfirmation: async () => {
sends++;
},
@ -297,10 +297,10 @@ test("SMTP-Nachricht enthält Buchungsnummer und Paket; externe Hosts sind stand
await send({
...input,
bookingId: "TH-000001",
packageName: demoPackages[0].name,
packageName: packages[0].name,
});
assert.ok(message.text.includes("TH-000001"));
assert.ok(message.text.includes(demoPackages[0].name));
assert.ok(message.text.includes(packages[0].name));
assert.equal(message.to.address, input.email);
assert.ok(message.text.includes("Zahlungsvorgang wird übersprungen"));
assert.ok(message.text.includes("keine Abbuchung"));
@ -368,16 +368,19 @@ test("HTTP-Vertrag: JSON, Größenlimit, Methoden und private Dateien", async (t
413,
);
const catalog = await (await fetch(`${url}/api/angebote`)).json();
assert.deepEqual(catalog.packages, demoPackages);
assert.equal((await fetch(`${url}/api/packages`)).status, 404);
assert.deepEqual(
catalog.packages,
packages.map(({ id, name, summary }) => ({ id, name, summary })),
);
assert.equal((await fetch(`${url}/api/packages`)).status, 200);
});
test("Paketvertrag lehnt fehlende, numerische und doppelte IDs ab", () => {
for (const catalog of [
[null],
[{ ...demoPackages[0], id: 123 }],
[{ ...demoPackages[0], id: undefined }],
[demoPackages[0], demoPackages[0]],
[{ ...packages[0], id: 123 }],
[{ ...packages[0], id: undefined }],
[packages[0], packages[0]],
]) {
assert.throws(
() => createApp({ catalog, sendConfirmation: async () => {} }),
@ -393,7 +396,7 @@ test("Fortlaufende Nummern bleiben nach Neustart erhalten und Wiederholungen ver
assert.equal((await post(input, key)).body.bookingId, "TH-000001");
const restarted = createBookingService({
storage,
packages: demoPackages,
packages: packages,
sendConfirmation: async () => {},
});
assert.equal(
@ -425,3 +428,194 @@ test("Nummernvergabe verwendet den höchsten CSV-Wert und wächst über sechs St
await storage.writeAll(records);
assert.equal((await post()).body.bookingId, "TH-1000000");
});
test("Detail-API und Vorschau liefern die gewählte Variante mit centgenauen Preisen", async (t) => {
const { url, storage, sent } = await setup(t, { catalog: packages });
const details = await (
await fetch(`${url}/api/packages/ernaehrung-premium`)
).json();
assert.equal(details.variants.length, 2);
assert.ok(details.includedFeatures.length > 0);
assert.equal((await fetch(`${url}/api/packages/unbekannt`)).status, 404);
const response = await fetch(`${url}/api/bookings/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
packageId: details.id,
variantId: "ernaehrung-premium-52",
price: 1,
}),
});
assert.equal(response.status, 200);
const preview = await response.json();
assert.equal(preview.durationWeeks, 52);
assert.equal(preview.grossPrice, 1399);
assert.equal(preview.netPrice, 1175.63);
assert.equal(preview.taxAmount, 223.37);
assert.equal(preview.taxRate, 19);
assert.equal(preview.currency, "EUR");
assert.deepEqual(preview.includedFeatures, details.includedFeatures);
assert.deepEqual(await storage.readAll(), []);
assert.equal(sent.length, 0);
});
test("Bestelldaten bleiben nach Katalogänderung und Neustart unverändert", async (t) => {
const fixture = await setup(t, { catalog: packages });
const key = randomUUID();
const data = {
...input,
packageId: "ernaehrung-premium",
variantId: "ernaehrung-premium-52",
price: 1,
includedFeatures: ["Manipuliert"],
};
const result = await fixture.post(data, key);
assert.equal(result.status, 201);
const order = result.body.bookedPackage;
assert.equal(order.grossPrice, 1399);
assert.equal(order.durationWeeks, 52);
assert.equal(order.variantId, data.variantId);
assert.ok(!order.includedFeatures.includes("Manipuliert"));
assert.deepEqual((await fixture.storage.readAll())[0].bookedPackage, order);
assert.deepEqual(fixture.sent[0].bookedPackage, order);
const restarted = createBookingService({
storage: fixture.storage,
packages: [],
details: [],
sendConfirmation: async () => assert.fail("Doppelversand"),
});
assert.deepEqual(
(await restarted.book(data, key)).body.bookedPackage,
order,
);
assert.equal(
(
await fixture.post(
{ ...data, variantId: "ernaehrung-premium-24" },
key,
)
).status,
409,
);
});
test("Unbekannte und paketfremde Varianten werden vor dem Speichern abgelehnt", async (t) => {
const { post, storage, sent, url } = await setup(t, { catalog: packages });
for (const data of [
{ packageId: "ernaehrung-premium", variantId: "unbekannt" },
{ packageId: "ernaehrung-starter", variantId: "ernaehrung-premium-52" },
{ packageId: "ernaehrung-premium", variantId: 52 },
]) {
assert.equal((await post({ ...input, ...data })).status, 400);
assert.equal(
(
await fetch(`${url}/api/bookings/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
).status,
400,
);
}
assert.deepEqual(await storage.readAll(), []);
assert.equal(sent.length, 0);
});
test("Bestehende CSV ohne Bestelldaten bleibt lesbar und wird verlustfrei erweitert", async (t) => {
const { post, file, storage } = await setup(t);
const key = randomUUID();
await post(input, key);
const [record] = await storage.readAll();
await writeFile(
file,
`bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash\nTH-000001,${record.createdAt},ernaehrung-starter,Altes Paket,Alte Person,person@example.test,accepted,alter-key,alter-hash\n`,
);
assert.equal((await post()).status, 201);
const records = await storage.readAll();
assert.equal(records.length, 2);
assert.equal(records[0].packageName, "Altes Paket");
assert.equal(records[0].bookedPackage, null);
assert.equal(records[1].bookedPackage.packageId, "ernaehrung-starter");
});
test("Bestätigungsmail enthält gespeicherte Leistungen, Variante, Preise und maskiertes HTML", async (t) => {
const { post } = await setup(t, { catalog: packages });
const result = await post({
...input,
packageId: "ernaehrung-premium",
variantId: "ernaehrung-premium-52",
});
let message;
const send = createEmailService({}, () => ({
sendMail: async (mail) => {
message = mail;
return { accepted: [input.email] };
},
}));
await send({ ...input, name: "<img src=x>", ...result.body });
assert.match(message.subject, /Bestellbestätigung TH-/);
for (const text of [
"52-Wochen-Variante",
"52 Wochen",
"1.399,00",
"1.175,63",
"223,37",
"19 %",
"einmalig",
"Bestelldatum:",
...result.body.bookedPackage.includedFeatures,
]) {
assert.ok(message.text.includes(text), text);
}
assert.ok(message.html.includes("&lt;img src=x&gt;"));
assert.ok(!message.html.includes("<img src=x>"));
});
test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeichert", async (t) => {
const { post, storage, sent } = await setup(t, { catalog: packages });
const customer = {
firstName: "Thomas",
lastName: "Müller",
email: "thomas@example.test",
phone: "+49 170 1234567",
ageGroup: "50-59",
notes: "Vorbereitung auf die Mitteldistanz.",
};
const request = {
packageId: "ernaehrung-standard",
customer,
acceptedTerms: true,
};
for (const body of [
{ ...request, acceptedTerms: false },
{ ...request, acceptedTerms: "true" },
{ ...request, customer: [] },
{ ...request, customer: { ...customer, firstName: "" } },
{ ...request, customer: { ...customer, email: "ungültig" } },
{ ...request, customer: { ...customer, phone: 123 } },
])
assert.equal((await post(body)).status, 400);
const key = randomUUID();
const result = await post(request, key);
assert.equal(result.status, 201);
assert.equal(result.body.customerEmail, customer.email);
const [record] = await storage.readAll();
assert.equal(record.name, "Thomas Müller");
assert.deepEqual(record.customer, customer);
assert.equal(record.acceptedTerms, true);
assert.deepEqual(sent[0].customer, customer);
assert.equal((await post(request, key)).status, 200);
assert.equal(
(
await post(
{
...request,
customer: { ...customer, notes: "Anderer Auftrag" },
},
key,
)
).status,
409,
);
});

View File

@ -1,8 +1,9 @@
import { test, expect } from "@playwright/test";
import { packages } from "../../shared/packages.js";
import { packagesDetails } from "../../shared/packagesdetails.js";
const pageUrl = "/booking.html#/buchen/demo-booking";
const pageUrl = "/booking.html#/buchen/ernaehrung-starter";
async function fill(page) {
await page
.getByLabel("Name (Pflichtfeld)", { exact: true })
@ -17,7 +18,7 @@ test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy"
}) => {
await page.goto(pageUrl);
await expect(
page.getByRole("heading", { name: "1:1-Ernährungsberatung" }),
page.getByRole("heading", { name: packagesDetails[0].title }),
).toBeVisible();
await expect(page.locator(".booking__payment-notice")).toContainText(
"Zahlungsvorgang wird übersprungen",
@ -191,12 +192,22 @@ for (const pkg of packages) {
);
await page.getByRole("link", { name: pkg.buttonText }).click();
await expect(page).toHaveURL(
new RegExp(`booking\\.html#/buchen/${pkg.id}$`),
new RegExp(`paket-details\\.html\\?id=${pkg.id}$`),
);
await page.locator("#proceed-to-booking-btn").click();
await expect(page).toHaveURL(
new RegExp(`booking\\.html\\?id=${pkg.id}`),
);
const details = packagesDetails.find((entry) => entry.id === pkg.id);
await expect(
page.getByRole("heading", { name: pkg.name, exact: true }),
page.getByRole("heading", { name: details.title, exact: true }),
).toBeVisible();
await expect(page.locator(".booking__summary")).toHaveText(pkg.summary);
await expect(page.locator(".booking__summary")).toHaveText(
details.summaryForBooking,
);
await expect(page.locator(".booking__features li")).toHaveCount(
details.includedFeatures.length,
);
await fill(page);
const responsePromise = page.waitForResponse(
(response) =>
@ -231,3 +242,53 @@ test("Angebotsübersicht passt auf ein schmales Display", async ({ page }) => {
),
).toBe(true);
});
test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung übernommen", async ({
page,
}) => {
await page.goto("/paket-details.html?id=ernaehrung-premium");
await page.locator("#variant-select").selectOption("ernaehrung-premium-52");
await page.locator("#proceed-to-booking-btn").click();
await expect(page).toHaveURL(
/booking\.html\?id=ernaehrung-premium&variant=ernaehrung-premium-52$/,
);
await expect(page.locator(".booking__totals")).toContainText(
"52 Wochen Begleitung",
);
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
await page.reload();
await fill(page);
const responsePromise = page.waitForResponse(
(response) =>
response.url().endsWith("/api/bookings") &&
response.request().method() === "POST",
);
await page
.getByRole("button", { name: "Paket buchen", exact: true })
.click();
const response = await responsePromise;
expect(response.request().postDataJSON().variantId).toBe(
"ernaehrung-premium-52",
);
const booking = await response.json();
expect(booking.bookedPackage.grossPrice).toBe(1399);
expect(booking.bookedPackage.durationWeeks).toBe(52);
await expect(page.locator(".booking__result")).toContainText(
booking.bookingId,
);
await page.reload();
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
await expect(page.locator(".booking__result")).toContainText(
booking.bookingId,
);
});
test("Paketfremde Variante zeigt kein Buchungsformular", async ({ page }) => {
await page.goto(
"/booking.html?id=ernaehrung-starter&variant=ernaehrung-premium-52",
);
await expect(
page.getByText("Diese Variante gehört nicht zum ausgewählten Paket."),
).toBeVisible();
await expect(page.locator("form")).toHaveCount(0);
});

View File

@ -4,11 +4,11 @@ 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, demoPackages } from "../../shared/packages.js";
import { packages } from "../../shared/packages.js";
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
const server = createApp({
catalog: [...packages, ...demoPackages],
catalog: packages,
storage: createCsvStorage(join(directory, "bookings.csv")),
sendConfirmation: async () => {},
});

View File

@ -1,13 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { packages, demoPackages } from "../shared/packages.js";
import { packages } from "../shared/packages.js";
import { createApp } from "../server/index.js";
test("Angebote erfüllen den gemeinsamen Vertrag von Übersicht und Buchung", () => {
assert.ok(packages.length > 0);
assert.doesNotThrow(() =>
createApp({
catalog: [...packages, ...demoPackages],
catalog: packages,
sendConfirmation: async () => {},
}),
);

View File

@ -22,6 +22,9 @@ export default defineConfig({
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)),
booking: fileURLToPath(
new URL("./booking.html", import.meta.url),