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. # Lokal nur einen Mailfänger verwenden. Keine echten Zugangsdaten eintragen.
HOST=127.0.0.1 HOST=127.0.0.1
PORT=3000 PORT=3000
BOOKING_DEMO=true
SMTP_HOST=127.0.0.1 SMTP_HOST=127.0.0.1
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_SECURE=false 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`. Die Angebotsübersicht führt zur Detailseite eines Pakets. Der Buchungsbutton
Zu Beginn waren HEAD und frisch abgerufenes `origin/dev` identisch. Kein Merge, übergibt die Paket-ID und gegebenenfalls die gewählte Variante als URL-Parameter
Commit oder Push wurde durchgeführt. Es gab keinen Router, keine Paketdaten und an `booking.html`. Bestehende Links mit `#/buchen/:packageId` funktionieren weiterhin.
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.
## 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`. Beim Absenden speichert der Server Kundendaten und den vollständigen Bestellstand
2. Das Formular sendet Paket-ID, Name und E-Mail an `POST /api/bookings`. in der CSV. Anschließend sendet er eine Bestätigung als Text und HTML mit
Es merkt sich dafür einen zufälligen Anfrageschlüssel (Idempotency-Key). Buchungsnummer, Datum, gebuchten Leistungen und Preisen. Änderungen am Katalog
3. Der Server prüft die Angaben und liest den Paketnamen aus `src/shared/packages.js`. verändern bereits gespeicherte Bestellungen nicht.
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. Der Zahlungsvorgang ist weiterhin simuliert. Es erfolgt keine Abbuchung und
6. Bei einem erneuten Versuch mit denselben Daten und demselben Schlüssel gibt keine verbindliche Beratungsbuchung. Darauf weisen Formular und E-Mail hin.
er die vorhandene Buchung zurück. Es entsteht keine zweite Bestellung.
## Lokal starten ## Lokal starten
Node.js 24 ist im vorhandenen Dev Container vorgesehen. Alle Befehle laufen im Voraussetzung ist Node.js 24. Abhängigkeiten mit `npm ci` installieren und die
Projektverzeichnis. Vite und Backend benötigen jeweils ein eigenes Terminal. Werte aus `.env.example` in eine lokale `.env` übernehmen. Eine vorhandene
Konfiguration dabei erhalten.
```bash In separaten Terminals starten:
npm ci
cp .env.example .env
```
Eine vorhandene `.env` vorher prüfen und nicht überschreiben. Die Beispielwerte - `npm run dev:mail`: Mailpit mit SMTP auf Port 1025 und Weboberfläche auf 8025.
aktivieren das klar gekennzeichnete Testpaket und verwenden nur einen Mailfänger. - `npm run dev:server`: Buchungs-API auf Port 3000.
Ohne `.env` bleibt der Demo-Modus ausgeschaltet; die drei regulären Pakete bleiben verfügbar. - `npm run dev`: Frontend auf Port 5173; `/api` wird an Node weitergeleitet.
Mailpit ist über `.devcontainer/Dockerfile` bereits Bestandteil neuer Dev Container. Die Angebotsübersicht liegt unter <http://localhost:5173/angebotsuebersicht.html>,
Bestehende Container einmal neu bauen. Außerhalb davon einmalig der Mailfänger unter <http://localhost:8025>. Mailpit ist im Dev Container enthalten.
[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:
```bash `npm run build` erzeugt die Seiten in `dist/`, einschließlich der Paketdetailseite.
npm run dev:mail Für ein Deployment muss der Webserver `/api` an den Node-Server weiterleiten.
``` `npm run start:server` startet die API ohne automatischen Neustart.
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.
## Konfiguration ## Konfiguration
Alle SMTP-Einstellungen werden ausschließlich in Node geladen. Niemals mit einem | Variable | Standardwert | Bedeutung |
`VITE_`-Präfix versehen: Solche Variablen könnten im Browser landen. | ------------------------ | -------------------------------- | --------------------------------------------------- |
| `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 | Externe SMTP-Verbindungen erfordern TLS. Zugangsdaten gehören ausschließlich
| ------------------------- | -------------------------------- | --------------------------------------------------------------------- | in die Serverkonfiguration und dürfen kein `VITE_`-Präfix erhalten.
| `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 |
Für externe SMTP-Hosts wird TLS verlangt; Zertifikatsprüfung bleibt aktiv. ## API
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).
## 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`**. | Endpunkt | Funktion |
Das Array enthält die drei Angebote Starter, Standard und Premium. Übersicht und | ---------------------------- | ----------------------------------------------------------------------------------- |
Buchung verwenden gemeinsam `id`, `name` und `summary`; zusätzliche Felder | `GET /api/angebote` | Bestehende Kurzübersicht mit `id`, `name` und `summary` |
beschreiben die Darstellung der Angebotskarten. | `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 POST-Anfragen benötigen `Content-Type: application/json`. Beim Buchen ist
// Nur Schema-Beispiel, kein verbindliches Angebot: zusätzlich ein `Idempotency-Key` als UUID v4 erforderlich. Das Formular sendet
{ id: "stabile-paket-id", name: "Abgestimmter Paketname", summary: "Kurze Beschreibung" } `packageId`, gegebenenfalls `variantId`, `name` und `email`.
```
IDs: eindeutig, maximal 80 Zeichen, Kleinbuchstaben/Ziffern mit einzelnen Alternativ akzeptiert die API das Swagger-Objekt `customer` mit `firstName`,
Bindestrichen. `name` und `summary` sind nicht leere Strings. IDs nach Freigabe `lastName`, `email` sowie optional `phone`, `ageGroup` und `notes`. Dabei muss
nicht für andere Angebote wiederverwenden. Das Backend prüft den Vertrag beim Start. `acceptedTerms` den Wert `true` haben. Diese Angaben werden ebenfalls gespeichert.
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.
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 Ungültige Angaben, Pakete und Varianten ergeben HTTP 400; ein unbekanntes Paket
link.href = `/booking.html#/buchen/${encodeURIComponent(paket.id)}`; 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 ## Speicherung und Wiederholungen
Router ergänzt, kann sie bei `/#/buchen/:packageId` diese Funktion aufrufen:
```js Die Buchungen liegen in `src/server/data/bookings.csv`. Neue Buchungsnummern
import { mountBookingPage } from "./features/booking/booking-page.js"; werden fortlaufend als `TH-000001`, `TH-000002` usw. vergeben. Der höchste
const cleanup = mountBookingPage(container, { packageId }); vorhandene Wert bestimmt die nächste Nummer. Die CSV muss deshalb vollständig
// Beim Verlassen der Route: erhalten bleiben; ältere UUID-Buchungsnummern werden weiterhin unterstützt.
cleanup();
```
Das Modul bringt seine begrenzten `.booking`-Styles mit. Farben und Schrift `bookedPackage` speichert den Bestellstand als JSON, `customer` die optionalen
orientieren sich am vorhandenen Basis-CSS. Gemeinsame Designvariablen und eine strukturierten Kundendaten und `acceptedTerms` die zugehörige Zustimmung.
Button-Komponente gibt es bisher nicht. Layout und Navigation bleiben Aufgabe CSV-Dateien mit dem früheren Spaltensatz bleiben lesbar und werden beim nächsten
der anderen Teammitglieder. „Zur Angebotsseite“ führt zu `/angebotsuebersicht.html`. 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` Wiederholte Anfragen mit demselben Schlüssel und denselben Daten liefern die
und `testOnly`. Keine personenbezogenen Daten oder SMTP-Einstellungen. 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 Das Formular merkt sich Anfrage und Bestellübersicht je Paket und Variante im
`Idempotency-Key` als UUID v4 erforderlich. Ein Schlüssel gehört genau zu einer `sessionStorage`. Nach einem Verbindungsfehler bleiben die Angaben gesperrt,
Buchung. Bei Wiederholung denselben Schlüssel und dieselben Angaben verwenden. damit dieselbe Anfrage erneut geprüft werden kann. Ein neuer Tab oder gelöschter
Sitzungsspeicher liegt außerhalb dieser Absicherung.
```http ## Versandstatus
POST /api/bookings
Content-Type: application/json
Idempotency-Key: 36c45d7f-585f-47e9-bf5c-7ea1b7a162ed
{"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 ## Tests
{
"bookingId": "TH-000001",
"createdAt": "2026-09-24T12:00:00.000Z",
"packageId": "demo-booking",
"packageName": "1:1-Ernährungsberatung",
"saved": true,
"emailStatus": "accepted",
"replayed": false
}
```
Gespeichert, aber E-Mail nicht bestätigt: HTTP `202` mit derselben Struktur und `npm test` prüft Validierung, Preisberechnung, Varianten, CSV-Kompatibilität,
`emailStatus: "failed"`, `"pending"` oder `"unknown"`. Das bedeutet ausdrücklich Wiederholungen und Versandfehler. `npm run test:browser` prüft den Weg von der
nicht, dass die E-Mail zugestellt wurde. Die Buchungsnummer bleibt gültig. Angebotsübersicht über die Paketdetails bis zur Bestätigung, einschließlich
Premium-Jahresvariante, Tastaturbedienung und schmalem Bildschirm.
| Status/Code | Bedeutung und Verhalten | Die Tests verwenden die regulären Paketdaten, temporäre CSV-Dateien und einen
| ------------------------------ | ----------------------------------------------------------------------- | simulierten Mailversand. Playwright benötigt Chromium; die Installation erfolgt
| `400 INVALID_INPUT` | Angaben korrigieren; Feldhinweise stehen in `error.fields` | mit `npx playwright install --with-deps chromium`. Die Ports 3000 und 5173 müssen
| `400 UNKNOWN_PACKAGE` | Paket nicht verfügbar; verfügbares Paket auswählen | für die automatisch gestarteten Testserver frei sein.
| `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 E-Mail-Prüfung unterstützt übliche unquotierte ASCII-Adressen. Internationale Vor einem Commit außerdem `npm run format:check`, `npm run build` und
Domains können in Punycode angegeben werden; SMTPUTF8-Adressen sind nicht Teil `git diff --check` ausführen. Für eine manuelle Mailprüfung eine Buchung mit einer
dieses Sprints. Eine Formatprüfung beweist nicht, dass das Postfach existiert. Adresse unter `example.test` anlegen und die Bestätigung in Mailpit kontrollieren.
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).

View File

@ -1,30 +1,32 @@
<!doctype html> <!doctype html>
<html lang="de" class="dark"> <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
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:wght@600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/src/styles/packages.css" />
</head>
<head> <body>
<meta charset="UTF-8" /> <header class="container">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <a href="angebotsuebersicht.html">← Zurück zur Angebotsübersicht</a>
<title>Paket-Details – Tri-Hub Ernährungsberatung</title> </header>
<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">
<link rel="stylesheet" href="/src/styles/packages.css" />
</head>
<body> <main class="packages-section">
<header class="container"> <div id="package-detail-container" class="detail-page-container">
<a href="angebotsuebersicht.html">← Zurück zur Angebotsübersicht</a> <!-- Wird dynamisch durch paket-details.js gefüllt -->
</header> </div>
</main>
<main class="packages-section">
<div id="package-detail-container" class="detail-page-container">
<!-- Wird dynamisch durch paket-details.js gefüllt -->
</div>
</main>
<script type="module" src="/src/features/angebote/paket-details.js"></script>
</body>
<script
type="module"
src="/src/features/angebote/paket-details.js"
></script>
</body>
</html> </html>

View File

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

View File

@ -1,16 +1,12 @@
// src/features/angebote/paket-details.js
import { packagesDetails } from "../../shared/packagesdetails.js"; import { packagesDetails } from "../../shared/packagesdetails.js";
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("package-detail-container"); const container = document.getElementById("package-detail-container");
if (!container) return; if (!container) return;
// 1. ID aus der URL lesen (z.B. ?id=ernaehrung-standard)
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const packageId = params.get("id"); 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); const pkg = packagesDetails.find((item) => item.id === packageId);
if (!pkg) { if (!pkg) {
@ -24,7 +20,6 @@ document.addEventListener("DOMContentLoaded", async () => {
return; return;
} }
// 3. Optional: Varianten-Auswahl rendern (für das Premium-Paket)
const variantsHtml = pkg.variants const variantsHtml = pkg.variants
? `<div class="detail-box"> ? `<div class="detail-box">
<h3>Wähle deine Variante</h3> <h3>Wähle deine Variante</h3>
@ -34,7 +29,6 @@ document.addEventListener("DOMContentLoaded", async () => {
</div>` </div>`
: ""; : "";
// 4. Detailseite im Tri-Hub-Stil rendern
container.innerHTML = ` container.innerHTML = `
<div class="package-card detail-view"> <div class="package-card detail-view">
<div class="package-type-container"> <div class="package-type-container">
@ -87,7 +81,6 @@ document.addEventListener("DOMContentLoaded", async () => {
</div> </div>
`; `;
// 5. Preis aktualisieren, falls eine andere Variante (24 vs. 52 Wochen) gewählt wird
const variantSelect = document.getElementById("variant-select"); const variantSelect = document.getElementById("variant-select");
const displayedPrice = document.getElementById("displayed-price"); const displayedPrice = document.getElementById("displayed-price");
if (variantSelect && displayedPrice) { 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"); const bookingBtn = document.getElementById("proceed-to-booking-btn");
bookingBtn.addEventListener("click", () => { bookingBtn.addEventListener("click", () => {
const selectedVariantId = variantSelect ? variantSelect.value : pkg.id; const params = new URLSearchParams({ id: pkg.id });
const selectedVariant = pkg.variants if (variantSelect) params.set("variant", variantSelect.value);
? pkg.variants.find((v) => v.variantId === selectedVariantId) window.location.href = `booking.html?${params}`;
: 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}`;
}); });
}); });

View File

@ -33,10 +33,6 @@ async function request(path, options = {}) {
return body; return body;
} }
export function loadPackages(signal) {
return request("/api/angebote", { signal });
}
export function submitBooking(payload, idempotencyKey) { export function submitBooking(payload, idempotencyKey) {
return request("/api/bookings", { return request("/api/bookings", {
method: "POST", method: "POST",
@ -47,3 +43,12 @@ export function submitBooking(payload, idempotencyKey) {
body: JSON.stringify(payload), 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; let cleanup;
function render() { function render() {
cleanup?.(); cleanup?.();
// Eigenständige Testseite, bis Person 2 den gemeinsamen Router bereitstellt.
const match = location.hash.match(/^#\/buchen\/([a-z0-9-]+)$/); 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); window.addEventListener("hashchange", render);
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 "./booking.css";
import { orderSummaryRows } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js"; import { paymentNotice } from "../../shared/booking-copy.js";
// Kann später vom gemeinsamen Router aufgerufen werden. cleanup entfernt export function mountBookingPage(root, { packageId, variantId } = {}) {
// Listener und bricht das Laden beim Verlassen der Ansicht ab.
export function mountBookingPage(root, { packageId } = {}) {
const controller = new AbortController(); const controller = new AbortController();
const { signal } = controller; const { signal } = controller;
root.innerHTML = ` root.innerHTML = `
@ -24,20 +23,22 @@ export function mountBookingPage(root, { packageId } = {}) {
return; return;
} }
try { try {
const data = await loadPackages(signal); const selected = await previewBooking(packageId, variantId, signal);
if (signal.aborted) return; 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(); intro.remove();
renderForm(selected); renderForm(selected);
} catch { } catch (error) {
if (signal.aborted) return; if (signal.aborted) return;
if (
[
"UNKNOWN_PACKAGE",
"INVALID_VARIANT",
"INVALID_INPUT",
].includes(error.code)
) {
intro.textContent = error.message;
return;
}
intro.textContent = intro.textContent =
"Das Paket konnte nicht geladen werden. Bitte prüfe deine Verbindung und versuche es erneut."; "Das Paket konnte nicht geladen werden. Bitte prüfe deine Verbindung und versuche es erneut.";
const retry = document.createElement("button"); const retry = document.createElement("button");
@ -61,6 +62,11 @@ export function mountBookingPage(root, { packageId } = {}) {
<div class="booking__package"> <div class="booking__package">
<h2 class="booking__package-name"></h2> <h2 class="booking__package-name"></h2>
<p class="booking__summary"></p> <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> </div>
<aside class="booking__payment" aria-labelledby="booking-payment-heading"> <aside class="booking__payment" aria-labelledby="booking-payment-heading">
<h2 id="booking-payment-heading">Hinweis zum Buchungsablauf</h2> <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> <p class="booking__progress" role="status"></p>
</form> </form>
<div class="booking__result" role="status" tabindex="-1" hidden></div>`; <div class="booking__result" role="status" tabindex="-1" hidden></div>`;
content.querySelector(".booking__package-name").textContent = function renderOrder(order) {
selected.name; content.querySelector(".booking__package-name").textContent =
content.querySelector(".booking__summary").textContent = order.title;
selected.summary; content.querySelector(".booking__summary").textContent =
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 = content.querySelector(".booking__payment-notice").textContent =
paymentNotice; paymentNotice;
const form = content.querySelector("form"); const form = content.querySelector("form");
@ -97,7 +137,7 @@ export function mountBookingPage(root, { packageId } = {}) {
const errors = form.querySelector(".booking__errors"); const errors = form.querySelector(".booking__errors");
const progress = form.querySelector(".booking__progress"); const progress = form.querySelector(".booking__progress");
const result = content.querySelector(".booking__result"); 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 attempt = null;
let busy = false; let busy = false;
let finished = false; let finished = false;
@ -128,6 +168,7 @@ export function mountBookingPage(root, { packageId } = {}) {
} }
} }
function showResult(body, focus = true) { function showResult(body, focus = true) {
if (body.bookedPackage) renderOrder(body.bookedPackage);
const messages = { const messages = {
accepted: accepted:
"Buchung gespeichert. Die Bestätigung wurde vom Mailserver zum Versand angenommen. Die Zustellung ist noch nicht bestätigt.", "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 { try {
const stored = JSON.parse(sessionStorage.getItem(storageKey)); 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; attempt = stored;
if (stored.order) renderOrder(stored.order);
name.value = stored.payload.name; name.value = stored.payload.name;
email.value = stored.payload.email; email.value = stored.payload.email;
lockFields(true); lockFields(true);
@ -194,8 +239,12 @@ export function mountBookingPage(root, { packageId } = {}) {
} }
const nextAttempt = { const nextAttempt = {
key: crypto.randomUUID(), key: crypto.randomUUID(),
order: selected,
payload: { payload: {
packageId: selected.id, packageId: selected.packageId,
...(selected.variantId
? { variantId: selected.variantId }
: {}),
name: name.value, name: name.value,
email: email.value, email: email.value,
}, },
@ -242,6 +291,7 @@ export function mountBookingPage(root, { packageId } = {}) {
[ [
"INVALID_INPUT", "INVALID_INPUT",
"UNKNOWN_PACKAGE", "UNKNOWN_PACKAGE",
"INVALID_VARIANT",
"INVALID_KEY", "INVALID_KEY",
].includes(error.code) ].includes(error.code)
) { ) {

View File

@ -94,3 +94,26 @@
width: 100%; 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 { createServer } from "node:http";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { packages, demoPackages } from "../shared/packages.js"; import { packages } from "../shared/packages.js";
import { createBookingService } from "./services/booking-service.js"; import {
BookingError,
createBookingService,
} from "./services/booking-service.js";
import { createCsvStorage } from "./services/csv-storage.js"; import { createCsvStorage } from "./services/csv-storage.js";
import { createEmailService } from "./services/email-service.js"; import { createEmailService } from "./services/email-service.js";
import { handleBooking, json } from "./routes/bookings.js"; import { handleBooking, json } from "./routes/bookings.js";
@ -11,6 +14,7 @@ export function createApp({
storage = createCsvStorage( storage = createCsvStorage(
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)), fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
), ),
details,
sendConfirmation, sendConfirmation,
} = {}) { } = {}) {
const ids = new Set(); const ids = new Set();
@ -34,6 +38,7 @@ export function createApp({
} }
const service = createBookingService({ const service = createBookingService({
storage, storage,
details,
packages: catalog, packages: catalog,
sendConfirmation: sendConfirmation ?? createEmailService(), sendConfirmation: sendConfirmation ?? createEmailService(),
}); });
@ -41,15 +46,43 @@ export function createApp({
const path = new URL(request.url, "http://localhost").pathname; const path = new URL(request.url, "http://localhost").pathname;
if (path === "/api/angebote" && request.method === "GET") { if (path === "/api/angebote" && request.method === "GET") {
return json(response, 200, { return json(response, 200, {
packages: catalog.map(({ id, name, summary, testOnly }) => ({ packages: catalog.map(({ id, name, summary }) => ({
id, id,
name, name,
summary, 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") { if (request.method !== "POST") {
response.setHeader("Allow", "POST"); response.setHeader("Allow", "POST");
return json(response, 405, { 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. // Ausschließlich API; keine Auslieferung von CSV oder Serverdateien.
return json(response, 404, { return json(response, 404, {
@ -72,16 +107,9 @@ if (
process.argv[1] && process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href 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 port = Number(process.env.PORT || 3000);
const server = createApp({ const server = createApp();
catalog: demo ? [...packages, ...demoPackages] : packages,
});
server.listen(port, process.env.HOST || "127.0.0.1", () => { server.listen(port, process.env.HOST || "127.0.0.1", () => {
console.log( console.log(`Buchungs-API auf Port ${port}`);
`Buchungs-API auf Port ${port}${demo ? " (Testpaket aktiviert)" : ""}`,
);
}); });
} }

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 { try {
const input = await readJson(request); const input = await readJson(request);
if (preview) return json(response, 200, service.preview(input));
const result = await service.book( const result = await service.book(
input, input,
request.headers["idempotency-key"], request.headers["idempotency-key"],

View File

@ -1,4 +1,5 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { packagesDetails } from "../../shared/packagesdetails.js";
export class BookingError extends Error { export class BookingError extends Error {
constructor(status, code, message, fields = {}) { constructor(status, code, message, fields = {}) {
@ -30,6 +31,52 @@ function validateInput(input, key) {
"Erwartet wird ein JSON-Objekt.", "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 fields = {};
const result = {}; const result = {};
for (const [field, max, message] of [ for (const [field, max, message] of [
@ -73,6 +120,24 @@ function validateInput(input, key) {
fields, 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; return result;
} }
@ -101,6 +166,9 @@ function responseFor(record, replayed) {
createdAt: record.createdAt, createdAt: record.createdAt,
packageId: record.packageId, packageId: record.packageId,
packageName: record.packageName, packageName: record.packageName,
bookedPackage: record.bookedPackage || null,
customerEmail: record.email,
status: "CONFIRMED",
saved: true, saved: true,
emailStatus, emailStatus,
replayed, replayed,
@ -108,8 +176,85 @@ function responseFor(record, replayed) {
}; };
} }
export function createBookingService({ storage, sendConfirmation, packages }) { export function createBookingService({
// Eine Warteschlange umfasst Lesen, Schreiben UND Versand. Nur ein Prozess! 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(); let queue = Promise.resolve();
async function processBooking(input, key) { async function processBooking(input, key) {
const data = validateInput(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." }, { packageId: "Bitte ein verfügbares Paket auswählen." },
); );
} }
const bookedPackage = preview(data);
record = { record = {
bookingId: nextBookingId(records), bookingId: nextBookingId(records),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
packageId: selected.id, packageId: selected.id,
packageName: selected.name, packageName: selected.name,
bookedPackage,
customer: data.customer ?? null,
acceptedTerms: data.acceptedTerms ?? null,
name: data.name, name: data.name,
email: data.email, email: data.email,
emailStatus: "pending", emailStatus: "pending",
@ -194,6 +343,8 @@ export function createBookingService({ storage, sendConfirmation, packages }) {
return responseFor(record, replayed); return responseFor(record, replayed);
} }
return { return {
getPackage,
preview,
book(input, key) { book(input, key) {
const task = queue.then(() => processBooking(input, key)); const task = queue.then(() => processBooking(input, key));
queue = task.catch(() => {}); queue = task.catch(() => {});

View File

@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
import { parse } from "csv-parse/sync"; import { parse } from "csv-parse/sync";
import { stringify } from "csv-stringify/sync"; import { stringify } from "csv-stringify/sync";
export const columns = [ const legacyColumns = [
"bookingId", "bookingId",
"createdAt", "createdAt",
"packageId", "packageId",
@ -16,6 +16,10 @@ export const columns = [
"requestHash", "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 // Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält. // ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
function protectCell(value) { function protectCell(value) {
@ -41,7 +45,11 @@ export function createCsvStorage(filePath) {
return parse(content, { return parse(content, {
bom: true, bom: true,
columns(header) { 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"); throw new Error("Unbekanntes CSV-Format");
} }
return header; return header;
@ -51,7 +59,11 @@ export function createCsvStorage(filePath) {
Object.fromEntries( Object.fromEntries(
Object.entries(row).map(([key, value]) => [ Object.entries(row).map(([key, value]) => [
key, key,
restoreCell(value), jsonColumns.includes(key)
? value
? JSON.parse(restoreCell(value))
: null
: restoreCell(value),
]), ]),
), ),
); );
@ -64,7 +76,12 @@ export function createCsvStorage(filePath) {
Object.fromEntries( Object.fromEntries(
columns.map((column) => [ columns.map((column) => [
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 nodemailer from "nodemailer";
import { orderSummaryRows } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js"; import { paymentNotice } from "../../shared/booking-copy.js";
export function createEmailService( export function createEmailService(
@ -34,23 +35,73 @@ export function createEmailService(
}); });
const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>"; const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>";
return async function sendConfirmation(booking) { 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; let info;
try { try {
info = await transport.sendMail({ info = await transport.sendMail({
from, from,
to: { address: booking.email, name: booking.name }, to: { address: booking.email, name: booking.name },
subject: `Buchungsbestätigung ${booking.bookingId}`, subject: `Bestellbestätigung ${booking.bookingId} – Tri-Hub`,
text: [ text: lines.join("\n"),
`Hallo ${booking.name},`, html: `<html lang="de"><body><h1>Deine Bestellbestätigung</h1>${lines.map((line) => (line ? `<p>${escapeHtml(line)}</p>` : "")).join("")}</body></html>`,
"",
"vielen Dank für deine Buchung. Wir haben deine Angaben erhalten.",
`Buchungsnummer: ${booking.bookingId}`,
`Paket: ${booking.packageName}`,
"",
paymentNotice,
"",
"Dein Tri-Hub-Team",
].join("\n"),
}); });
} catch (error) { } catch (error) {
// Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen // Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen

View File

@ -3,7 +3,7 @@
"info": { "info": {
"title": "Tri-Hub Ernährungsberatung & Booking API", "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).", "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": [ "servers": [
{ {
@ -78,7 +78,7 @@
"/bookings/preview": { "/bookings/preview": {
"post": { "post": {
"summary": "Preis- und Leistungsübersicht für den Bestellprozess berechnen", "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"], "tags": ["Booking"],
"requestBody": { "requestBody": {
"required": true, "required": true,
@ -117,7 +117,7 @@
"/bookings": { "/bookings": {
"post": { "post": {
"summary": "Paket verbindlich buchen (US2.4)", "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"], "tags": ["Booking"],
"requestBody": { "requestBody": {
"required": true, "required": true,
@ -149,8 +149,80 @@
} }
} }
} }
},
"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."
}
]
} }
} }
}, },
@ -222,7 +294,8 @@
"price": { "price": {
"type": "number", "type": "number",
"format": "float", "format": "float",
"example": 799.0 "example": 799.0,
"description": "Bruttopreis der Variante inklusive MwSt."
} }
} }
}, },
@ -270,7 +343,8 @@
"price": { "price": {
"type": "number", "type": "number",
"format": "float", "format": "float",
"example": 449.0 "example": 449.0,
"description": "Bruttopreis inklusive der angegebenen MwSt."
}, },
"currency": { "currency": {
"type": "string", "type": "string",
@ -351,7 +425,8 @@
}, },
"variantId": { "variantId": {
"type": "string", "type": "string",
"example": "ernaehrung-premium-52" "example": "ernaehrung-premium-52",
"nullable": true
}, },
"title": { "title": {
"type": "string", "type": "string",
@ -382,12 +457,77 @@
"currency": { "currency": {
"type": "string", "type": "string",
"example": "EUR" "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": { "BookingCreateRequest": {
"type": "object", "type": "object",
"required": ["packageId", "customer", "acceptedTerms"], "required": ["packageId"],
"properties": { "properties": {
"packageId": { "packageId": {
"type": "string", "type": "string",
@ -396,7 +536,19 @@
"variantId": { "variantId": {
"type": "string", "type": "string",
"nullable": true, "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": { "customer": {
"type": "object", "type": "object",
@ -431,16 +583,26 @@
}, },
"acceptedTerms": { "acceptedTerms": {
"type": "boolean", "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": { "BookingConfirmation": {
"type": "object", "type": "object",
"properties": { "properties": {
"bookingId": { "bookingId": {
"type": "string", "type": "string",
"example": "BK-2026-84920" "example": "TH-000001"
}, },
"status": { "status": {
"type": "string", "type": "string",
@ -451,28 +613,59 @@
"format": "date-time" "format": "date-time"
}, },
"bookedPackage": { "bookedPackage": {
"$ref": "#/components/schemas/BookingPreviewResponse" "allOf": [
{
"$ref": "#/components/schemas/BookingPreviewResponse"
}
],
"nullable": true,
"description": "Gespeicherter Bestellstand; bei älteren Buchungen ohne diese Daten null."
}, },
"customerEmail": { "customerEmail": {
"type": "string", "type": "string",
"example": "thomas.mueller@example.de" "example": "thomas.mueller@example.de"
}, },
"confirmationMessage": { "packageId": {
"type": "string"
},
"packageName": {
"type": "string"
},
"saved": {
"type": "boolean"
},
"emailStatus": {
"type": "string", "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": { "ErrorResponse": {
"type": "object", "type": "object",
"required": ["error"],
"properties": { "properties": {
"error": { "error": {
"type": "string", "type": "object",
"example": "Paket nicht gefunden" "required": ["code", "message"],
}, "properties": {
"code": { "code": {
"type": "integer", "type": "string",
"example": 404 "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 = 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, 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, id,
name, name,
summary, summary,
testOnly: false,
})), })),
); );
}); });

View File

@ -8,10 +8,10 @@ import { createApp } from "../server/index.js";
import { createCsvStorage } from "../server/services/csv-storage.js"; import { createCsvStorage } from "../server/services/csv-storage.js";
import { createBookingService } from "../server/services/booking-service.js"; import { createBookingService } from "../server/services/booking-service.js";
import { createEmailService } from "../server/services/email-service.js"; import { createEmailService } from "../server/services/email-service.js";
import { demoPackages } from "../shared/packages.js"; import { packages } from "../shared/packages.js";
const input = { const input = {
packageId: demoPackages[0].id, packageId: packages[0].id,
name: 'Test, "Person"', name: 'Test, "Person"',
email: "person@example.test", email: "person@example.test",
}; };
@ -27,7 +27,7 @@ async function setup(t, options = {}) {
sent.push({ ...booking }); sent.push({ ...booking });
}); });
const dependencies = { const dependencies = {
catalog: demoPackages, catalog: packages,
storage, storage,
sendConfirmation, sendConfirmation,
...options, ...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.status, 201);
assert.equal(result.body.emailStatus, "accepted"); 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.equal(result.body.bookingId, "TH-000001");
assert.ok(!Number.isNaN(Date.parse(result.body.createdAt))); assert.ok(!Number.isNaN(Date.parse(result.body.createdAt)));
const records = await fixture.storage.readAll(); const records = await fixture.storage.readAll();
@ -230,7 +230,7 @@ test("Ausstehender Versand kann nach Schreibfehler sicher fortgesetzt werden", a
let writes = 0; let writes = 0;
let sends = 0; let sends = 0;
const service = createBookingService({ const service = createBookingService({
packages: demoPackages, packages: packages,
storage: { storage: {
readAll: () => storage.readAll(), readAll: () => storage.readAll(),
writeAll: async (rows) => { writeAll: async (rows) => {
@ -257,7 +257,7 @@ test("Status-Schreibfehler nach SMTP bleibt unklar und löst keinen Doppelversan
let writes = 0; let writes = 0;
let sends = 0; let sends = 0;
const service = createBookingService({ const service = createBookingService({
packages: demoPackages, packages: packages,
storage: { storage: {
readAll: () => storage.readAll(), readAll: () => storage.readAll(),
writeAll: async (rows) => { 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"); assert.equal((await storage.readAll())[0].emailStatus, "sending");
const restarted = createBookingService({ const restarted = createBookingService({
storage, storage,
packages: demoPackages, packages: packages,
sendConfirmation: async () => { sendConfirmation: async () => {
sends++; sends++;
}, },
@ -297,10 +297,10 @@ test("SMTP-Nachricht enthält Buchungsnummer und Paket; externe Hosts sind stand
await send({ await send({
...input, ...input,
bookingId: "TH-000001", bookingId: "TH-000001",
packageName: demoPackages[0].name, packageName: packages[0].name,
}); });
assert.ok(message.text.includes("TH-000001")); 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.equal(message.to.address, input.email);
assert.ok(message.text.includes("Zahlungsvorgang wird übersprungen")); assert.ok(message.text.includes("Zahlungsvorgang wird übersprungen"));
assert.ok(message.text.includes("keine Abbuchung")); assert.ok(message.text.includes("keine Abbuchung"));
@ -368,16 +368,19 @@ test("HTTP-Vertrag: JSON, Größenlimit, Methoden und private Dateien", async (t
413, 413,
); );
const catalog = await (await fetch(`${url}/api/angebote`)).json(); const catalog = await (await fetch(`${url}/api/angebote`)).json();
assert.deepEqual(catalog.packages, demoPackages); assert.deepEqual(
assert.equal((await fetch(`${url}/api/packages`)).status, 404); 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", () => { test("Paketvertrag lehnt fehlende, numerische und doppelte IDs ab", () => {
for (const catalog of [ for (const catalog of [
[null], [null],
[{ ...demoPackages[0], id: 123 }], [{ ...packages[0], id: 123 }],
[{ ...demoPackages[0], id: undefined }], [{ ...packages[0], id: undefined }],
[demoPackages[0], demoPackages[0]], [packages[0], packages[0]],
]) { ]) {
assert.throws( assert.throws(
() => createApp({ catalog, sendConfirmation: async () => {} }), () => 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"); assert.equal((await post(input, key)).body.bookingId, "TH-000001");
const restarted = createBookingService({ const restarted = createBookingService({
storage, storage,
packages: demoPackages, packages: packages,
sendConfirmation: async () => {}, sendConfirmation: async () => {},
}); });
assert.equal( assert.equal(
@ -425,3 +428,194 @@ test("Nummernvergabe verwendet den höchsten CSV-Wert und wächst über sechs St
await storage.writeAll(records); await storage.writeAll(records);
assert.equal((await post()).body.bookingId, "TH-1000000"); 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 { test, expect } from "@playwright/test";
import { packages } from "../../shared/packages.js"; 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) { async function fill(page) {
await page await page
.getByLabel("Name (Pflichtfeld)", { exact: true }) .getByLabel("Name (Pflichtfeld)", { exact: true })
@ -17,7 +18,7 @@ test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy"
}) => { }) => {
await page.goto(pageUrl); await page.goto(pageUrl);
await expect( await expect(
page.getByRole("heading", { name: "1:1-Ernährungsberatung" }), page.getByRole("heading", { name: packagesDetails[0].title }),
).toBeVisible(); ).toBeVisible();
await expect(page.locator(".booking__payment-notice")).toContainText( await expect(page.locator(".booking__payment-notice")).toContainText(
"Zahlungsvorgang wird übersprungen", "Zahlungsvorgang wird übersprungen",
@ -191,12 +192,22 @@ for (const pkg of packages) {
); );
await page.getByRole("link", { name: pkg.buttonText }).click(); await page.getByRole("link", { name: pkg.buttonText }).click();
await expect(page).toHaveURL( 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( await expect(
page.getByRole("heading", { name: pkg.name, exact: true }), page.getByRole("heading", { name: details.title, exact: true }),
).toBeVisible(); ).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); await fill(page);
const responsePromise = page.waitForResponse( const responsePromise = page.waitForResponse(
(response) => (response) =>
@ -231,3 +242,53 @@ test("Angebotsübersicht passt auf ein schmales Display", async ({ page }) => {
), ),
).toBe(true); ).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 { join } from "node:path";
import { createApp } from "../../server/index.js"; import { createApp } from "../../server/index.js";
import { createCsvStorage } from "../../server/services/csv-storage.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 directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
const server = createApp({ const server = createApp({
catalog: [...packages, ...demoPackages], catalog: packages,
storage: createCsvStorage(join(directory, "bookings.csv")), storage: createCsvStorage(join(directory, "bookings.csv")),
sendConfirmation: async () => {}, sendConfirmation: async () => {},
}); });

View File

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

View File

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