From 5553d117d6f201b520eaa91b7b5955bfe0eeb4fa Mon Sep 17 00:00:00 2001 From: Marvin Gemlin Date: Tue, 29 Sep 2026 14:09:20 +0000 Subject: [PATCH] feat(contact): Beraterkontakt mit ID-Zuordnung und Kundenvalidierung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kontakt-Popup mit serverseitigem CSV-Abgleich, Mailpit-Bestätigungen und Weiterleitung zu Microsoft Bookings ergänzen. Berater über gemeinsame JSON-Daten zuordnen sowie Swagger, Integration, Testseite und automatisierte Tests dokumentieren. --- README.md | 6 + contact-test.html | 26 ++ docs/contact-integration.md | 98 +++++++ docs/contact.md | 156 ++++++++++ public/fonts/DM-Sans-LICENSE.txt | 93 ++++++ public/fonts/dm-sans-latin-400-normal.woff2 | Bin 0 -> 14200 bytes public/fonts/dm-sans-latin-500-normal.woff2 | Bin 0 -> 14304 bytes public/fonts/dm-sans-latin-700-normal.woff2 | Bin 0 -> 14348 bytes src/features/contact/contact-dialog.js | 126 ++++++++ src/features/contact/contact-entry.js | 13 + src/features/contact/contact-test.css | 29 ++ src/features/contact/contact.css | 152 ++++++++++ src/server/index.js | 16 ++ src/server/routes/bookings.js | 2 +- src/server/routes/contact.js | 38 +++ src/server/services/booking-service.js | 2 +- src/server/services/contact-email.js | 51 ++++ src/server/services/contact-service.js | 108 +++++++ src/server/services/email-service.js | 10 +- src/server/swagger.json | 304 +++++++++++++++++++- src/shared/berater-daten.json | 12 + src/shared/berater.js | 7 + src/shared/contact.js | 15 + src/tests/browser/contact.spec.js | 98 +++++++ src/tests/contact.test.js | 165 +++++++++++ src/tests/helpers/browser-server.js | 1 + vite.config.js | 3 + 27 files changed, 1526 insertions(+), 5 deletions(-) create mode 100644 contact-test.html create mode 100644 docs/contact-integration.md create mode 100644 docs/contact.md create mode 100644 public/fonts/DM-Sans-LICENSE.txt create mode 100644 public/fonts/dm-sans-latin-400-normal.woff2 create mode 100644 public/fonts/dm-sans-latin-500-normal.woff2 create mode 100644 public/fonts/dm-sans-latin-700-normal.woff2 create mode 100644 src/features/contact/contact-dialog.js create mode 100644 src/features/contact/contact-entry.js create mode 100644 src/features/contact/contact-test.css create mode 100644 src/features/contact/contact.css create mode 100644 src/server/routes/contact.js create mode 100644 src/server/services/contact-email.js create mode 100644 src/server/services/contact-service.js create mode 100644 src/shared/berater-daten.json create mode 100644 src/shared/berater.js create mode 100644 src/shared/contact.js create mode 100644 src/tests/browser/contact.spec.js create mode 100644 src/tests/contact.test.js 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 0000000000000000000000000000000000000000..0b8bc556cf4b4cbd5c82418947a159f9aaa3c6cc GIT binary patch literal 14200 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 DJVHD( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..48e161289603aa2d4c9b83f294dc73af969b339a GIT binary patch literal 14304 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

cXva|Hg)2S=yXKRi7!R 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..26edc56c122261f14290330af50bbff92e0a17b4 GIT binary patch literal 14348 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 KF@gpH`u_m@HIb44 literal 0 HcmV?d00001 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), ),