diff --git a/README.md b/README.md index 0ca7144..77a7c5b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ angezeigt. Der Ablauf bleibt eine Simulation ohne Zahlung oder verbindliche Beratung. Buchungen liegen lokal in `src/server/data/bookings.csv`. Diese Datei nicht vorab leer anlegen: Das Backend erzeugt sie bei der ersten Buchung selbst. +## Beraterkontakt testen + +Die [Kontakt-Testseite](http://localhost:5173/contact-test.html?berater-id=anna-mueller) +öffnet das neue Popup. Kundenprüfung, Mailpit-Versand, Profil-Einbindung und +Checkliste sind in [docs/contact.md](docs/contact.md) dokumentiert. + ## Prüfen ```bash diff --git a/contact-test.html b/contact-test.html new file mode 100644 index 0000000..9705d86 --- /dev/null +++ b/contact-test.html @@ -0,0 +1,26 @@ + + + + + + Berater kontaktieren – Tri-Hub Testseite + + +
+

TRI-HUB · KONTAKT-DEMO

+

Dein direkter Draht zur Beratung

+

+ +

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

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

DEIN NÄCHSTER SCHRITT

+

Berater kontaktieren

+

+
+
+ +

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

+
+
+ +
+ +

+ +
`; + dialog.querySelector(".contact-advisor").textContent = advisor + ? `Deine Anfrage an ${advisor.name}` + : "Die Berater-ID fehlt oder ist unbekannt. Bitte öffne das Fenster über ein Beraterprofil."; + dialog.querySelector(".contact-consent span").textContent = contactNotice; + const form = dialog.querySelector("form"); + const email = form.elements.email; + const reason = form.elements.reason; + for (const [value, label] of Object.entries(contactReasons)) { + reason.add(new Option(label, value)); + } + const status = dialog.querySelector(".contact-status"); + const submit = dialog.querySelector(".contact-submit"); + const close = dialog.querySelector(".contact-close"); + let busy = false; + let sent = false; + function update() { + submit.disabled = busy || sent || !advisor; + close.disabled = busy; + email.disabled = busy || sent; + reason.disabled = busy || sent; + form.elements.simulationAccepted.disabled = busy || sent; + } + async function post(path, input) { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const result = await response.json(); + if (!response.ok) + throw new Error( + result.error?.message || "Die Anfrage ist fehlgeschlagen.", + ); + return result; + } + email.addEventListener("input", () => { + status.textContent = ""; + }); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submit.disabled || !form.reportValidity()) return; + const input = { + email: email.value, + advisorId, + reason: reason.value, + simulationAccepted: form.elements.simulationAccepted.checked, + }; + busy = true; + update(); + status.textContent = + "E-Mail wird geprüft und simulierte Anfrage gesendet …"; + try { + const result = await post("/api/contact", input); + if (result.redirectUrl !== contactBookingUrl) + throw new Error("Die Weiterleitungsadresse ist ungültig."); + sent = true; + status.textContent = + "Beide Bestätigungen wurden in Mailpit angenommen. Microsoft Bookings wird geöffnet. "; + const link = document.createElement("a"); + link.href = contactBookingUrl; + link.textContent = "Weiter zur Terminwahl"; + status.append(link); + navigate(contactBookingUrl); + } catch (error) { + status.textContent = `${error.message} Bei einem Verbindungsfehler bitte vor erneutem Senden Mailpit prüfen.`; + } finally { + busy = false; + update(); + } + }); + close.addEventListener("click", () => dialog.close()); + dialog.addEventListener("cancel", (event) => { + if (busy) event.preventDefault(); + }); + const previousOverflow = document.body.style.overflow; + dialog.addEventListener( + "close", + () => { + document.body.style.overflow = previousOverflow; + dialog.remove(); + returnFocus?.focus(); + }, + { once: true }, + ); + document.body.append(dialog); + document.body.style.overflow = "hidden"; + update(); + dialog.showModal(); + email.focus(); + return dialog; +} diff --git a/src/features/contact/contact-entry.js b/src/features/contact/contact-entry.js new file mode 100644 index 0000000..c8b4da8 --- /dev/null +++ b/src/features/contact/contact-entry.js @@ -0,0 +1,13 @@ +import { openContactDialog } from "./contact-dialog.js"; +import { findAdvisor } from "../../shared/berater.js"; +import "./contact-test.css"; + +const advisorId = + new URLSearchParams(location.search).get("berater-id") ?? "anna-mueller"; +const advisor = findAdvisor(advisorId); +document.querySelector("#advisor-name").textContent = advisor + ? `Dein Berater: ${advisor.name}` + : "Unbekannte Berater-ID"; +document + .querySelector("#open-contact") + .addEventListener("click", () => openContactDialog({ advisorId })); diff --git a/src/features/contact/contact-test.css b/src/features/contact/contact-test.css new file mode 100644 index 0000000..18607f8 --- /dev/null +++ b/src/features/contact/contact-test.css @@ -0,0 +1,29 @@ +body { + margin: 0; + background: oklch(13% 0.022 223); + color: oklch(96% 0.008 210); + font: + 16px/1.5 "DM Sans", + sans-serif; +} +.contact-test { + max-width: 680px; + margin: 12vh auto; + padding: 24px; +} +.contact-test > p { + color: oklch(72% 0.015 214); +} +.contact-test button { + padding: 14px 24px; + border: 0; + border-radius: 6px; + background: white; + color: oklch(20% 0.03 220); + font: inherit; + cursor: pointer; +} +.contact-test button:focus-visible { + outline: 3px solid oklch(70% 0.12 205 / 0.45); + outline-offset: 4px; +} diff --git a/src/features/contact/contact.css b/src/features/contact/contact.css new file mode 100644 index 0000000..1e53103 --- /dev/null +++ b/src/features/contact/contact.css @@ -0,0 +1,152 @@ +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/fonts/dm-sans-latin-400-normal.woff2") format("woff2"); +} +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("/fonts/dm-sans-latin-500-normal.woff2") format("woff2"); +} +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/fonts/dm-sans-latin-700-normal.woff2") format("woff2"); +} +.contact-dialog { + --contact-foreground: oklch(96% 0.008 210); + --contact-accent: oklch(70% 0.12 205); + box-sizing: border-box; + width: min(586px, calc(100% - 32px)); + max-height: calc(100dvh - 32px); + margin: auto; + padding: 32px; + border: 1px solid oklch(100% 0 0 / 0.1); + border-radius: 1.1rem; + background: oklch(17% 0.024 221); + color: var(--contact-foreground); + font: + 400 16px/1.5 "DM Sans", + sans-serif; + -webkit-font-smoothing: antialiased; + box-shadow: 0 24px 80px #0008; +} +.contact-dialog::backdrop { + background: #000a; + backdrop-filter: blur(4px); +} +.contact-dialog * { + box-sizing: border-box; +} +.contact-dialog h2 { + margin: 8px 32px 8px 0; + font-size: 28px; + line-height: 1.25; +} +.contact-dialog p { + margin: 0; +} +.contact-dialog .contact-eyebrow { + color: var(--contact-accent); + font-size: 12px; + letter-spacing: 0.12em; +} +.contact-advisor, +.contact-help, +.contact-consent { + color: oklch(72% 0.015 214); +} +.contact-form { + margin-top: 32px; + display: grid; + gap: 24px; +} +.contact-form label { + display: block; + margin-bottom: 8px; +} +.contact-form input:not([type="checkbox"]), +.contact-form select { + width: 100%; + min-height: 48px; + padding: 10px 12px; + border: 1px solid oklch(100% 0 0 / 0.12); + border-radius: 6px; + background: oklch(13% 0.022 223); + color: inherit; + font: inherit; + color-scheme: dark; +} +.contact-dialog button { + min-height: 44px; + padding: 10px 16px; + border: 0; + border-radius: 6px; + font: + 500 14px/1.5 "DM Sans", + sans-serif; + cursor: pointer; +} +.contact-dialog .contact-close { + float: right; + padding: 0; + width: 44px; + background: transparent; + color: inherit; + font-size: 28px; +} +.contact-submit { + width: 100%; + background: white; + color: oklch(20% 0.03 220); +} +.contact-submit:hover:not(:disabled) { + background: var(--contact-accent); +} + +.contact-dialog :disabled { + opacity: 0.5; + cursor: not-allowed; +} +.contact-dialog :focus-visible { + outline: 3px solid oklch(70% 0.12 205 / 0.45); + outline-offset: 3px; +} +.contact-dialog .contact-help { + margin-top: 8px; + font-size: 14px; +} +.contact-form .contact-consent { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0; + font-size: 14px; +} +.contact-consent input { + flex-shrink: 0; + width: 20px; + height: 20px; + margin: 2px 0 0; + accent-color: var(--contact-accent); +} +.contact-status { + overflow-wrap: anywhere; +} +.contact-status:empty { + display: none; +} +.contact-status a { + color: var(--contact-accent); +} +@media (max-width: 480px) { + .contact-dialog { + padding: 24px 20px; + } +} diff --git a/src/server/index.js b/src/server/index.js index b9abd1c..f3ead04 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1,3 +1,6 @@ +import { createContactService } from "./services/contact-service.js"; +import { createContactEmailService } from "./services/contact-email.js"; +import { handleContact } from "./routes/contact.js"; import { createServer } from "node:http"; import { fileURLToPath, pathToFileURL } from "node:url"; import { packages } from "../shared/packages.js"; @@ -16,6 +19,7 @@ export function createApp({ ), details, sendConfirmation, + sendContact, } = {}) { const ids = new Set(); for (const entry of catalog) { @@ -42,8 +46,20 @@ export function createApp({ packages: catalog, sendConfirmation: sendConfirmation ?? createEmailService(), }); + const contact = createContactService({ + storage, + sendContact: sendContact ?? createContactEmailService(), + }); return createServer(async (request, response) => { const path = new URL(request.url, "http://localhost").pathname; + if (path === "/api/contact" || path === "/api/contact/validate") { + return handleContact( + request, + response, + contact, + path.endsWith("/validate"), + ); + } if (path === "/api/angebote" && request.method === "GET") { return json(response, 200, { packages: catalog.map(({ id, name, summary }) => ({ diff --git a/src/server/routes/bookings.js b/src/server/routes/bookings.js index 9209502..5618720 100644 --- a/src/server/routes/bookings.js +++ b/src/server/routes/bookings.js @@ -9,7 +9,7 @@ export function json(response, status, body) { response.end(JSON.stringify(body)); } -async function readJson(request) { +export async function readJson(request) { if ( request.headers["content-type"]?.split(";")[0].trim() !== "application/json" diff --git a/src/server/routes/contact.js b/src/server/routes/contact.js new file mode 100644 index 0000000..464e240 --- /dev/null +++ b/src/server/routes/contact.js @@ -0,0 +1,38 @@ +import { BookingError } from "../services/booking-service.js"; +import { json, readJson } from "./bookings.js"; + +export async function handleContact(request, response, service, validateOnly) { + if (request.method !== "POST") { + response.setHeader("Allow", "POST"); + return json(response, 405, { + error: { + code: "METHOD_NOT_ALLOWED", + message: "Bitte POST verwenden.", + }, + }); + } + try { + const input = await readJson(request); + const result = await (validateOnly + ? service.validate(input) + : service.submit(input)); + return json(response, validateOnly ? 200 : 201, result); + } catch (error) { + return json( + response, + error instanceof BookingError ? error.status : 500, + { + error: { + code: + error instanceof BookingError + ? error.code + : "INTERNAL_ERROR", + message: + error instanceof BookingError + ? error.message + : "Die Kontaktanfrage konnte nicht verarbeitet werden.", + }, + }, + ); + } +} diff --git a/src/server/services/booking-service.js b/src/server/services/booking-service.js index 5ecf2e2..4d04ec0 100644 --- a/src/server/services/booking-service.js +++ b/src/server/services/booking-service.js @@ -17,7 +17,7 @@ export class BookingError extends Error { const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; // Übliche unquotierte E-Mail-Adressen; einzelne Domainlabels maximal 63 Zeichen. -const emailPattern = +export const emailPattern = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; function validateInput(input, key) { diff --git a/src/server/services/contact-email.js b/src/server/services/contact-email.js new file mode 100644 index 0000000..6186150 --- /dev/null +++ b/src/server/services/contact-email.js @@ -0,0 +1,51 @@ +import { createMailTransport } from "./email-service.js"; +import { BookingError } from "./booking-service.js"; +import { contactNotice } from "../../shared/contact.js"; + +export function createContactEmailService(makeTransport) { + // Dieser Ablauf bleibt auch bei extern konfiguriertem Buchungs-SMTP lokal. + const { transport, from } = createMailTransport( + { + SMTP_HOST: "127.0.0.1", + SMTP_PORT: "1025", + SMTP_FROM: "Tri-Hub Simulation ", + }, + makeTransport, + ); + return async ({ email, advisor, reason }) => { + const results = await Promise.allSettled( + [ + { + to: email, + text: `Deine Kontaktanfrage an ${advisor.name} ist in der Simulation eingegangen.`, + }, + { + to: advisor.email, + text: `Hallo ${advisor.name}, eine simulierte Kontaktanfrage von ${email} ist eingegangen.`, + }, + ].map(async ({ to, text }) => { + const result = await transport.sendMail({ + from, + to, + subject: + "Eingangsbestätigung – Tri-Hub Kontakt (Simulation)", + text: `${text}\n\nKontaktgrund: ${reason}\n\n${contactNotice}`, + }); + if ( + !result.accepted?.some( + (address) => address.toLowerCase() === to.toLowerCase(), + ) + ) { + throw new Error("SMTP hat den Empfänger nicht angenommen."); + } + }), + ); + if (results.some((result) => result.status === "rejected")) { + throw new BookingError( + 502, + "CONTACT_MAIL_FAILED", + "Nicht beide Bestätigungen konnten bestätigt werden. Bitte Mailpit prüfen, bevor du erneut sendest; eine Nachricht kann bereits eingegangen sein.", + ); + } + }; +} diff --git a/src/server/services/contact-service.js b/src/server/services/contact-service.js new file mode 100644 index 0000000..ea8ec82 --- /dev/null +++ b/src/server/services/contact-service.js @@ -0,0 +1,108 @@ +import { findAdvisor } from "../../shared/berater.js"; +import { BookingError, emailPattern } from "./booking-service.js"; +import { contactReasons, contactBookingUrl } from "../../shared/contact.js"; + +function advisorDetails(advisorId) { + const advisor = findAdvisor(advisorId); + if (!advisor) { + throw new BookingError( + 400, + "INVALID_ADVISOR", + "Bitte eine gültige Berater-ID übergeben.", + ); + } + if ( + typeof advisor.name !== "string" || + !advisor.name.trim() || + typeof advisor.email !== "string" || + advisor.email.length > 254 || + !emailPattern.test(advisor.email) || + advisor.email.split("@")[0].length > 64 || + !advisor.email.toLowerCase().endsWith("@tri-hub.de") + ) { + throw new BookingError( + 503, + "ADVISOR_UNAVAILABLE", + "Die Kontaktdaten dieses Beraters sind nicht verfügbar.", + ); + } + return { ...advisor }; +} + +export function createContactService({ storage, sendContact }) { + async function validate(input) { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) || + typeof input.email !== "string" || + input.email.length > 254 + ) { + throw new BookingError( + 400, + "INVALID_EMAIL", + "Bitte eine gültige E-Mail-Adresse eingeben.", + ); + } + const email = input.email.trim().toLowerCase(); + if (!emailPattern.test(email) || email.split("@")[0].length > 64) { + throw new BookingError( + 400, + "INVALID_EMAIL", + "Bitte eine gültige E-Mail-Adresse eingeben.", + ); + } + let records; + try { + records = await storage.readAll(); + } catch { + throw new BookingError( + 503, + "STORAGE_UNAVAILABLE", + "Die Kundendaten können gerade nicht geprüft werden.", + ); + } + if (!records.some((row) => row.email?.trim().toLowerCase() === email)) { + throw new BookingError( + 403, + "CUSTOMER_NOT_FOUND", + "Zu dieser E-Mail-Adresse liegt keine Buchung vor. Bitte verwende die Adresse deiner Buchung.", + ); + } + return { email, valid: true }; + } + return { + validate, + async submit(input) { + const { email } = await validate(input); + const advisor = advisorDetails(input.advisorId); + if ( + typeof input.reason !== "string" || + !Object.hasOwn(contactReasons, input.reason) + ) { + throw new BookingError( + 400, + "INVALID_REASON", + "Bitte einen Kontaktgrund auswählen.", + ); + } + if (input.simulationAccepted !== true) { + throw new BookingError( + 400, + "SIMULATION_REQUIRED", + "Bitte den Simulationshinweis bestätigen.", + ); + } + await sendContact({ + email, + advisor, + reason: contactReasons[input.reason], + }); + return { + simulated: true, + emailStatus: "accepted", + redirectUrl: contactBookingUrl, + }; + }, + }; +} diff --git a/src/server/services/email-service.js b/src/server/services/email-service.js index a3415ec..be715c8 100644 --- a/src/server/services/email-service.js +++ b/src/server/services/email-service.js @@ -2,7 +2,7 @@ import nodemailer from "nodemailer"; import { orderSummaryRows } from "../../shared/order-summary.js"; import { paymentNotice } from "../../shared/booking-copy.js"; -export function createEmailService( +export function createMailTransport( env = process.env, makeTransport = nodemailer.createTransport, ) { @@ -34,6 +34,14 @@ export function createEmailService( disableUrlAccess: true, }); const from = env.SMTP_FROM || "Tri-Hub "; + return { transport, from }; +} + +export function createEmailService( + env = process.env, + makeTransport = nodemailer.createTransport, +) { + const { transport, from } = createMailTransport(env, makeTransport); return async function sendConfirmation(booking) { const order = booking.bookedPackage; const phone = booking.phone || booking.customer?.phone; diff --git a/src/server/swagger.json b/src/server/swagger.json index 7ba05eb..f703ecf 100644 --- a/src/server/swagger.json +++ b/src/server/swagger.json @@ -2,8 +2,8 @@ "openapi": "3.0.3", "info": { "title": "Tri-Hub Ernährungsberatung & Booking API", - "description": "API-Spezifikation für die Angebotsübersicht (US2.1), die Paket-Detailansicht und den Buchungsservice (US2.4 Paket buchen).", - "version": "1.1.0" + "description": "API-Spezifikation für die Angebotsübersicht (US2.1), die Paket-Detailansicht und den Buchungsservice (US2.4 Paket buchen). Kontakt-Simulation mit CSV-Kundenvalidierung und Mailpit.", + "version": "1.2.0" }, "servers": [ { @@ -224,6 +224,288 @@ } ] } + }, + "/contact/validate": { + "post": { + "tags": ["Contact"], + "summary": "Kunden-E-Mail prüfen", + "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Kunde in CSV gefunden", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["valid", "email"], + "properties": { + "valid": { + "type": "boolean", + "enum": [true] + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + } + } + } + } + } + }, + "400": { + "description": "Ungültige Eingabe oder JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "403": { + "description": "Keine Buchung zur E-Mail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "405": { + "description": "Nur POST erlaubt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "413": { + "description": "Mehr als 8 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "415": { + "description": "Content-Type muss application/json sein", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "503": { + "description": "CSV nicht lesbar", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "500": { + "description": "Unerwarteter Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + } + } + } + }, + "/contact": { + "post": { + "tags": ["Contact"], + "summary": "Beraterkontakt simulieren", + "description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes. Prüft CSV bei jedem Absenden erneut. Versand ausschließlich an lokales Mailpit (127.0.0.1:1025), zwei getrennte Bestätigungen. Kein automatischer Neuversand, keine persistente Kontaktablage.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "advisorId", + "reason", + "simulationAccepted" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "maxLength": 254 + }, + "reason": { + "type": "string", + "enum": [ + "coaching", + "nutrition", + "competition", + "recovery", + "package", + "appointment", + "other" + ] + }, + "simulationAccepted": { + "type": "boolean", + "enum": [true] + }, + "advisorId": { + "type": "string", + "example": "anna-mueller", + "description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Beide simulierten E-Mails von Mailpit angenommen; Weiterleitung möglich", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "simulated", + "emailStatus", + "redirectUrl" + ], + "properties": { + "simulated": { + "type": "boolean", + "enum": [true] + }, + "emailStatus": { + "type": "string", + "enum": ["accepted"] + }, + "redirectUrl": { + "type": "string", + "format": "uri", + "description": "Feste Microsoft-Bookings-Adresse; keine benutzerdefinierten Redirects." + } + } + } + } + } + }, + "400": { + "description": "Ungültige Eingabe oder JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "403": { + "description": "Keine Buchung zur E-Mail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "405": { + "description": "Nur POST erlaubt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "413": { + "description": "Mehr als 8 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "415": { + "description": "Content-Type muss application/json sein", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "503": { + "description": "CSV nicht lesbar oder Beraterkontaktdaten ungültig", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "500": { + "description": "Unerwarteter Fehler", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + }, + "502": { + "description": "Mindestens eine Mailannahme fehlgeschlagen oder unklar; vor Wiederholung Mailpit prüfen", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactError" + } + } + } + } + } + } } }, "components": { @@ -676,6 +958,24 @@ } } } + }, + "ContactError": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } } } } diff --git a/src/shared/berater-daten.json b/src/shared/berater-daten.json new file mode 100644 index 0000000..7e7a860 --- /dev/null +++ b/src/shared/berater-daten.json @@ -0,0 +1,12 @@ +[ + { + "id": "anna-mueller", + "name": "Anna Müller", + "email": "anna.mueller@tri-hub.de" + }, + { + "id": "max-mustermann", + "name": "Max Mustermann", + "email": "max.mustermann@tri-hub.de" + } +] diff --git a/src/shared/berater.js b/src/shared/berater.js new file mode 100644 index 0000000..606e86f --- /dev/null +++ b/src/shared/berater.js @@ -0,0 +1,7 @@ +import berater from "./berater-daten.json" with { type: "json" }; + +export function findAdvisor(advisorId) { + return typeof advisorId === "string" + ? berater.find((entry) => entry.id === advisorId) + : undefined; +} diff --git a/src/shared/contact.js b/src/shared/contact.js new file mode 100644 index 0000000..41ae552 --- /dev/null +++ b/src/shared/contact.js @@ -0,0 +1,15 @@ +export const contactReasons = { + coaching: "Persönliches Coaching und Erstgespräch", + nutrition: "Ernährungsplan und Anpassungen", + competition: "Wettkampfverpflegung und Hydration", + recovery: "Regeneration und Verträglichkeit", + package: "Fragen zum gebuchten Paket", + appointment: "Termin vereinbaren oder ändern", + other: "Sonstiges Anliegen", +}; + +export const contactNotice = + "Dies ist eine Simulation des Kontaktprozesses. Die Bestätigungen werden nur im lokalen Mailpit gesammelt. Es wird kein Berater tatsächlich kontaktiert. Die Weiterleitung öffnet die externe Microsoft-Bookings-Seite; dort wird hierdurch kein Termin gebucht."; + +export const contactBookingUrl = + "https://bookings.cloud.microsoft/bookwithme/user/b055625c7e5b4bf3866230ffa066536b%40Tri-hub.de/meetingtype/qkJP99nqg0a_Na5Z-gY9WQ2?anonymous&ismsaljsauthenabled"; diff --git a/src/tests/browser/contact.spec.js b/src/tests/browser/contact.spec.js new file mode 100644 index 0000000..e869afe --- /dev/null +++ b/src/tests/browser/contact.spec.js @@ -0,0 +1,98 @@ +import { test, expect } from "@playwright/test"; +import { randomUUID } from "node:crypto"; +import { contactBookingUrl } from "../../shared/contact.js"; + +async function open(page) { + await page.goto("/contact-test.html?berater-id=max-mustermann"); + await page + .getByRole("button", { name: "Berater kontaktieren", exact: true }) + .click(); +} + +test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter", async ({ + page, + request, +}) => { + const email = `kontakt-${randomUUID()}@example.test`; + const booking = await request.post("/api/bookings", { + headers: { "Idempotency-Key": randomUUID() }, + data: { packageId: "ernaehrung-starter", name: "Testkunde", email }, + }); + expect(booking.ok()).toBeTruthy(); + await open(page); + await expect(page.getByRole("dialog")).toContainText("Max Mustermann"); + const submit = page.getByRole("button", { name: "Anfrage senden" }); + await expect( + page.getByRole("button", { name: "E-Mail prüfen" }), + ).toHaveCount(0); + await expect(submit).toBeEnabled(); + await page + .getByLabel("Deine Buchungs-E-Mail") + .fill("unbekannt@example.test"); + await page.getByLabel("Grund für den Kontakt").selectOption("nutrition"); + await page.getByRole("checkbox").check(); + await submit.click(); + await expect(page.getByRole("status")).toContainText("keine Buchung"); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.getByLabel("Deine Buchungs-E-Mail").fill(email); + await page.route("https://bookings.cloud.microsoft/**", (route) => + route.fulfill({ body: "Microsoft Bookings (Test)" }), + ); + const sentRequest = page.waitForRequest((request) => + request.url().endsWith("/api/contact"), + ); + await submit.click(); + expect((await sentRequest).postDataJSON().advisorId).toBe("max-mustermann"); + await expect(page).toHaveURL(contactBookingUrl); +}); + +test("Mobiles Popup: Fokus, Escape, Rückkehr zum Auslöser und keine Überbreite", async ({ + page, +}) => { + await page.setViewportSize({ width: 375, height: 667 }); + await open(page); + await expect(page.getByLabel("Deine Buchungs-E-Mail")).toBeFocused(); + const box = await page.getByRole("dialog").boundingBox(); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(375); + await page.keyboard.press("Shift+Tab"); + await expect( + page.getByRole("button", { name: "Kontaktfenster schließen" }), + ).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Berater kontaktieren", exact: true }), + ).toBeFocused(); +}); + +test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => { + await page.route("**/api/contact", (route) => + route.fulfill({ + status: 502, + json: { error: { message: "Mailpit nicht erreichbar." } }, + }), + ); + await open(page); + await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test"); + await page.getByLabel("Grund für den Kontakt").selectOption("package"); + await page.getByRole("checkbox").check(); + await page.getByRole("button", { name: "Anfrage senden" }).click(); + await expect(page.getByRole("status")).toContainText( + "Mailpit nicht erreichbar", + ); + await expect(page.getByRole("dialog")).toBeVisible(); +}); + +test("Unbekannte Berater-ID sperrt das Absenden", async ({ page }) => { + await page.goto("/contact-test.html?berater-id=unbekannt"); + await page + .getByRole("button", { name: "Berater kontaktieren", exact: true }) + .click(); + await expect(page.getByRole("dialog")).toContainText( + "Berater-ID fehlt oder ist unbekannt", + ); + await expect( + page.getByRole("button", { name: "Anfrage senden" }), + ).toBeDisabled(); +}); diff --git a/src/tests/contact.test.js b/src/tests/contact.test.js new file mode 100644 index 0000000..63e3a97 --- /dev/null +++ b/src/tests/contact.test.js @@ -0,0 +1,165 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../server/index.js"; +import { createCsvStorage } from "../server/services/csv-storage.js"; +import { createContactEmailService } from "../server/services/contact-email.js"; +import { contactBookingUrl } from "../shared/contact.js"; + +async function fixture(t, options = {}) { + const dir = await mkdtemp(join(tmpdir(), "trihub-contact-")); + const storage = createCsvStorage(join(dir, "bookings.csv")); + await storage.writeAll([ + { + bookingId: "TH-000001", + email: "kunde@example.test", + name: "Testkunde", + }, + ]); + const sent = []; + const app = createApp({ + storage, + sendConfirmation: async () => {}, + sendContact: async (data) => sent.push(data), + ...options, + }); + await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + app.closeAllConnections(); + await new Promise((resolve) => app.close(resolve)); + await rm(dir, { recursive: true, force: true }); + }); + const base = `http://127.0.0.1:${app.address().port}`; + const post = async (path, input) => { + const response = await fetch(base + path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + return { status: response.status, body: await response.json() }; + }; + return { post, sent, storage, base }; +} +const input = { + email: "kunde@example.test", + advisorId: "anna-mueller", + reason: "coaching", + simulationAccepted: true, +}; + +test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redirect", async (t) => { + const f = await fixture(t); + const before = await f.storage.readAll(); + const validation = await f.post("/api/contact/validate", { + email: " KUNDE@example.test ", + }); + assert.equal(validation.status, 200); + assert.equal(validation.body.email, input.email); + const result = await f.post("/api/contact", { + ...input, + advisor: "Manipuliert", + advisorEmail: "falsch@example.test", + }); + assert.equal(result.status, 201); + assert.equal(result.body.redirectUrl, contactBookingUrl); + assert.equal(result.body.simulated, true); + assert.equal(f.sent[0].advisor.email, "anna.mueller@tri-hub.de"); + assert.equal(f.sent[0].advisor.name, "Anna Müller"); + const second = await f.post("/api/contact", { + ...input, + advisorId: "max-mustermann", + }); + assert.equal(second.status, 201); + assert.equal(f.sent[1].advisor.email, "max.mustermann@tri-hub.de"); + assert.deepEqual(await f.storage.readAll(), before); +}); + +test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => { + const f = await fixture(t); + for (const [data, status] of [ + [null, 400], + [{ ...input, email: "bad" }, 400], + [{ ...input, email: "unknown@example.test" }, 403], + [{ ...input, advisorId: "unbekannt" }, 400], + [{ ...input, advisorId: null }, 400], + [{ ...input, reason: "__proto__" }, 400], + [{ ...input, simulationAccepted: false }, 400], + ]) + assert.equal((await f.post("/api/contact", data)).status, status); + assert.equal(f.sent.length, 0); +}); + +test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => { + const f = await fixture(t); + assert.equal((await f.post("/api/contact/validate", input)).status, 200); + await f.storage.writeAll([]); + assert.equal((await f.post("/api/contact", input)).status, 403); + const broken = await fixture(t, { + storage: { + readAll() { + throw new Error("unavailable"); + }, + }, + }); + assert.equal((await broken.post("/api/contact", input)).status, 503); + assert.equal(broken.sent.length, 0); +}); + +test("HTTP-Vertrag weist falsche Methode, ungültiges JSON und große Requests zurück", async (t) => { + const f = await fixture(t); + assert.equal((await fetch(f.base + "/api/contact")).status, 405); + assert.equal( + (await fetch(f.base + "/api/contact", { method: "POST", body: "x" })) + .status, + 415, + ); + assert.equal( + ( + await fetch(f.base + "/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }) + ).status, + 400, + ); + assert.equal( + (await f.post("/api/contact", { email: "x".repeat(9000) })).status, + 413, + ); +}); + +test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", async (t) => { + const messages = []; + const sender = createContactEmailService((config) => { + assert.equal(config.host, "127.0.0.1"); + assert.equal(config.port, 1025); + return { + async sendMail(message) { + messages.push(message); + return { accepted: [message.to] }; + }, + }; + }); + const f = await fixture(t, { sendContact: sender }); + assert.equal((await f.post("/api/contact", input)).status, 201); + assert.deepEqual( + messages.map((m) => m.to), + [input.email, "anna.mueller@tri-hub.de"], + ); + for (const message of messages) { + assert.match(message.text, /Simulation/); + assert.match(message.text, /Persönliches Coaching/); + } + const failing = createContactEmailService(() => ({ + async sendMail(message) { + return { accepted: message.to === input.email ? [message.to] : [] }; + }, + })); + const failure = await fixture(t, { sendContact: failing }); + const result = await failure.post("/api/contact", input); + assert.equal(result.status, 502); + assert.equal(result.body.redirectUrl, undefined); +}); diff --git a/src/tests/helpers/browser-server.js b/src/tests/helpers/browser-server.js index f83d189..e9805ce 100644 --- a/src/tests/helpers/browser-server.js +++ b/src/tests/helpers/browser-server.js @@ -11,6 +11,7 @@ const server = createApp({ catalog: packages, storage: createCsvStorage(join(directory, "bookings.csv")), sendConfirmation: async () => {}, + sendContact: async () => {}, }); server.listen(3000, "127.0.0.1"); async function stop() { diff --git a/vite.config.js b/vite.config.js index a849d45..a0e5234 100644 --- a/vite.config.js +++ b/vite.config.js @@ -19,6 +19,9 @@ export default defineConfig({ build: { rollupOptions: { input: { + contactTest: fileURLToPath( + new URL("./contact-test.html", import.meta.url), + ), angebote: fileURLToPath( new URL("./angebotsuebersicht.html", import.meta.url), ),