Merge branch 'feature/Information' into dev
commit
56e241c387
|
|
@ -10,5 +10,6 @@ Thumbs.db
|
||||||
# Private Buchungsdaten und lokale Testergebnisse
|
# Private Buchungsdaten und lokale Testergebnisse
|
||||||
src/server/data/*
|
src/server/data/*
|
||||||
!src/server/data/.gitkeep
|
!src/server/data/.gitkeep
|
||||||
|
!src/server/data/contact-requests.csv
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Kontaktformular der Ernährungsseite
|
||||||
|
|
||||||
|
Das Formular in `index.html` sendet seine Eingaben als JSON an `POST /api/nutrition-contact`.
|
||||||
|
Der Server validiert Name, E-Mail, Betreff und Nachricht, speichert die Anfrage in
|
||||||
|
`src/server/data/contact-requests.csv` und sendet anschließend zwei E-Mails über
|
||||||
|
Mailpit auf `127.0.0.1:1025`:
|
||||||
|
|
||||||
|
- eine Eingangsbestätigung an die eingegebene E-Mail-Adresse;
|
||||||
|
- beide Nachrichten mit dem Absender `Tri-Hub Ernährung <noreply@tri-hub.de>`;
|
||||||
|
- die vollständige Kontaktanfrage an die Mailpit-Testadresse
|
||||||
|
`ernaehrung@tri-hub.de`; die SMTP-Verbindung ist fest auf den lokalen
|
||||||
|
Mailpit-Server gesetzt, daher wird diese Nachricht nur simuliert und nicht
|
||||||
|
an den echten Mailserver zugestellt.
|
||||||
|
|
||||||
|
Die Mailpit-Weboberfläche ist lokal unter `http://127.0.0.1:8025` erreichbar.
|
||||||
|
Zum Entwickeln Mailpit mit `npm run dev:mail`, den API-Server mit
|
||||||
|
`npm run dev:server` und Vite mit `npm run dev` starten.
|
||||||
|
|
||||||
|
Die CSV-Datei wird beim ersten erfolgreichen Absenden angelegt. Bei einem
|
||||||
|
Mailfehler bleibt die Anfrage gespeichert; die API meldet, dass der Versand
|
||||||
|
nicht vollständig bestätigt werden konnte.
|
||||||
132
index.html
132
index.html
|
|
@ -470,6 +470,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -485,6 +486,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
select {
|
select {
|
||||||
height: 48px;
|
height: 48px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
|
|
@ -557,6 +559,21 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#contactMessageHint.is-valid {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
display: block;
|
||||||
|
min-height: 0;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status.is-error {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
.form-status {
|
.form-status {
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
|
@ -1102,6 +1119,117 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Kontaktaufnahme -->
|
||||||
|
<section class="section" id="kontakt">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">KONTAKT AUFNEHMEN</div>
|
||||||
|
<h2
|
||||||
|
style="
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Schreib uns.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-intro">
|
||||||
|
Du hast Fragen oder möchtest einen Termin
|
||||||
|
vereinbaren? Schreib uns einfach. Wir melden uns
|
||||||
|
schnellstmöglich bei dir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="review-form" id="contactForm">
|
||||||
|
<div class="field" style="margin-top: 0">
|
||||||
|
<label for="contactName">Dein Name*</label>
|
||||||
|
<input
|
||||||
|
id="contactName"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
autocomplete="name"
|
||||||
|
placeholder="z. B. Thomas Müller"
|
||||||
|
maxlength="120"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small
|
||||||
|
class="field-error"
|
||||||
|
data-error-for="name"
|
||||||
|
></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="contactEmail"
|
||||||
|
>Deine E-Mail-Adresse*</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="contactEmail"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="z. B. thomas@example.de"
|
||||||
|
maxlength="254"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small
|
||||||
|
class="field-error"
|
||||||
|
data-error-for="email"
|
||||||
|
></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="contactSubject">Betreff*</label>
|
||||||
|
<input
|
||||||
|
id="contactSubject"
|
||||||
|
name="subject"
|
||||||
|
type="text"
|
||||||
|
placeholder="Worum geht es?"
|
||||||
|
maxlength="160"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small
|
||||||
|
class="field-error"
|
||||||
|
data-error-for="subject"
|
||||||
|
></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="message">Deine Nachricht*</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
placeholder="Wie können wir dir helfen?"
|
||||||
|
minlength="10"
|
||||||
|
maxlength="5000"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<small id="contactMessageHint"
|
||||||
|
>Mindestens 10 Zeichen erforderlich.</small
|
||||||
|
>
|
||||||
|
<small
|
||||||
|
class="field-error"
|
||||||
|
data-error-for="message"
|
||||||
|
></small>
|
||||||
|
<br />
|
||||||
|
<small>*Pflichtfelder</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="button button-primary" type="submit">
|
||||||
|
Nachricht senden
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="form-status"
|
||||||
|
id="contactFormStatus"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
|
|
@ -1118,6 +1246,10 @@
|
||||||
type="module"
|
type="module"
|
||||||
src="/src/components/navigationsbar/navigation.js"
|
src="/src/components/navigationsbar/navigation.js"
|
||||||
></script>
|
></script>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
src="/src/features/contact/contact-form.js"
|
||||||
|
></script>
|
||||||
<script>
|
<script>
|
||||||
const form = document.getElementById("reviewForm");
|
const form = document.getElementById("reviewForm");
|
||||||
const status = document.getElementById("formStatus");
|
const status = document.getElementById("formStatus");
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
<a href="#erfahrungen" class="navbar__link">Rezensionen</a>
|
<a href="#erfahrungen" class="navbar__link">Rezensionen</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/index.html" class="navbar__link">Kontakt</a>
|
<a href="#kontakt" class="navbar__link">Kontakt</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
const form = document.querySelector("#contactForm");
|
||||||
|
const status = document.querySelector("#contactFormStatus");
|
||||||
|
const message = form?.elements.message;
|
||||||
|
const messageHint = document.querySelector("#contactMessageHint");
|
||||||
|
|
||||||
|
function updateMessageHint() {
|
||||||
|
if (!message || !messageHint) return;
|
||||||
|
const valid = [...message.value.trim()].length >= 10;
|
||||||
|
messageHint.classList.toggle("is-valid", valid);
|
||||||
|
messageHint.textContent = valid
|
||||||
|
? "Mindestens 10 Zeichen erreicht."
|
||||||
|
: "Mindestens 10 Zeichen erforderlich.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrors(fields = {}) {
|
||||||
|
for (const input of form.elements) {
|
||||||
|
if (!input.name) continue;
|
||||||
|
input.removeAttribute("aria-invalid");
|
||||||
|
const error = form.querySelector(`[data-error-for="${input.name}"]`);
|
||||||
|
if (error) error.textContent = fields[input.name] ?? "";
|
||||||
|
if (fields[input.name]) input.setAttribute("aria-invalid", "true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message?.addEventListener("input", updateMessageHint);
|
||||||
|
updateMessageHint();
|
||||||
|
|
||||||
|
form?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
status.textContent = "";
|
||||||
|
status.classList.remove("is-error");
|
||||||
|
showErrors();
|
||||||
|
|
||||||
|
if (!form.reportValidity()) {
|
||||||
|
status.textContent = "Bitte prüfe die markierten Eingaben.";
|
||||||
|
status.classList.add("is-error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = form.querySelector('[type="submit"]');
|
||||||
|
submit.disabled = true;
|
||||||
|
submit.textContent = "Wird gesendet …";
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/nutrition-contact", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
showErrors(result.error?.fields);
|
||||||
|
throw new Error(
|
||||||
|
result.error?.message ??
|
||||||
|
"Die Nachricht konnte nicht gesendet werden.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
form.reset();
|
||||||
|
updateMessageHint();
|
||||||
|
status.textContent =
|
||||||
|
"Danke für deine Nachricht. Wir melden uns schnellstmöglich bei dir.";
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent =
|
||||||
|
error.message ||
|
||||||
|
"Der Server ist gerade nicht erreichbar. Bitte versuche es erneut.";
|
||||||
|
status.classList.add("is-error");
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
submit.textContent = "Nachricht senden";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
createdAt,name,email,subject,message
|
||||||
|
2026-09-29T18:51:35.694Z,Max Muster,test10@web.de,Erstgespräch für eine Ernährungsberatung,"Guten Tag,
|
||||||
|
|
||||||
|
ich möchte einen termin vereinbaren, können sie sich bei mir melden.
|
||||||
|
|
||||||
|
Mfg
|
||||||
|
Max Muster"
|
||||||
|
2026-09-29T19:27:34.639Z,Test Test,test11@web.de,Erstgespräch Ernährung,"Guten Tag,
|
||||||
|
|
||||||
|
können sie sich bei mir melden.
|
||||||
|
|
||||||
|
Mfg
|
||||||
|
Test"
|
||||||
|
|
|
@ -11,6 +11,10 @@ import {
|
||||||
import { createCsvStorage } from "./services/csv-storage.js";
|
import { createCsvStorage } from "./services/csv-storage.js";
|
||||||
import { createEmailService } from "./services/email-service.js";
|
import { createEmailService } from "./services/email-service.js";
|
||||||
import { handleBooking, json } from "./routes/bookings.js";
|
import { handleBooking, json } from "./routes/bookings.js";
|
||||||
|
import { handleContactForm } from "./routes/contact-form.js";
|
||||||
|
import { createContactFormService } from "./services/contact-form-service.js";
|
||||||
|
import { createContactFormStorage } from "./services/contact-form-storage.js";
|
||||||
|
import { createContactFormEmailService } from "./services/contact-form-email.js";
|
||||||
|
|
||||||
export function createApp({
|
export function createApp({
|
||||||
catalog = packages,
|
catalog = packages,
|
||||||
|
|
@ -20,6 +24,10 @@ export function createApp({
|
||||||
details,
|
details,
|
||||||
sendConfirmation,
|
sendConfirmation,
|
||||||
sendContact,
|
sendContact,
|
||||||
|
contactFormStorage = createContactFormStorage(
|
||||||
|
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
|
||||||
|
),
|
||||||
|
sendContactForm,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
for (const entry of catalog) {
|
for (const entry of catalog) {
|
||||||
|
|
@ -50,8 +58,17 @@ export function createApp({
|
||||||
storage,
|
storage,
|
||||||
sendContact: sendContact ?? createContactEmailService(),
|
sendContact: sendContact ?? createContactEmailService(),
|
||||||
});
|
});
|
||||||
|
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
|
||||||
|
const contactForm = createContactFormService({
|
||||||
|
storage: contactFormStorage,
|
||||||
|
sendContactForm: sendContactForm ?? createContactFormEmailService(),
|
||||||
|
});
|
||||||
return createServer(async (request, response) => {
|
return createServer(async (request, response) => {
|
||||||
const path = new URL(request.url, "http://localhost").pathname;
|
const path = new URL(request.url, "http://localhost").pathname;
|
||||||
|
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
|
||||||
|
if (path === "/api/nutrition-contact") {
|
||||||
|
return handleContactForm(request, response, contactForm);
|
||||||
|
}
|
||||||
if (path === "/api/contact" || path === "/api/contact/validate") {
|
if (path === "/api/contact" || path === "/api/contact/validate") {
|
||||||
return handleContact(
|
return handleContact(
|
||||||
request,
|
request,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { BookingError } from "../services/booking-service.js";
|
||||||
|
import { json, readJson } from "./bookings.js";
|
||||||
|
|
||||||
|
// Nimmt HTTP-Anfragen des Ernährungs-Kontaktformulars entgegen und übersetzt Service-Ergebnisse in JSON.
|
||||||
|
export async function handleContactForm(request, response, service) {
|
||||||
|
// Das Formular darf Anfragen ausschließlich per POST absenden.
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
response.setHeader("Allow", "POST");
|
||||||
|
return json(response, 405, {
|
||||||
|
error: {
|
||||||
|
code: "METHOD_NOT_ALLOWED",
|
||||||
|
message: "Bitte POST verwenden.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Liest JSON, führt den Kontaktablauf aus und antwortet bei Erfolg mit HTTP 201.
|
||||||
|
const result = await service.submit(await readJson(request));
|
||||||
|
return json(response, 201, result);
|
||||||
|
} catch (error) {
|
||||||
|
// Erwartete Validierungs- und Dienstfehler behalten Status und Fehlercode.
|
||||||
|
if (error instanceof BookingError) {
|
||||||
|
return json(response, error.status, {
|
||||||
|
error: {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
...(Object.keys(error.fields ?? {}).length
|
||||||
|
? { fields: error.fields }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Unerwartete Fehler werden protokolliert, ohne Formulardaten offenzulegen.
|
||||||
|
console.error("Kontaktformular konnte nicht verarbeitet werden.");
|
||||||
|
return json(response, 500, {
|
||||||
|
error: {
|
||||||
|
code: "INTERNAL_ERROR",
|
||||||
|
message:
|
||||||
|
"Die Nachricht konnte gerade nicht verarbeitet werden.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { createMailTransport } from "./email-service.js";
|
||||||
|
import { BookingError } from "./booking-service.js";
|
||||||
|
|
||||||
|
// Sichtbarer Empfänger in Mailpit; der Transport darunter bleibt lokal.
|
||||||
|
const contactAddress = "ernaehrung@tri-hub.de";
|
||||||
|
|
||||||
|
// Richtet den SMTP-Transport fest auf Mailpit ein und liefert den E-Mail-Versand zurück.
|
||||||
|
export function createContactFormEmailService(
|
||||||
|
env = process.env,
|
||||||
|
makeTransport,
|
||||||
|
) {
|
||||||
|
const { transport, from } = createMailTransport(
|
||||||
|
{
|
||||||
|
SMTP_HOST: "127.0.0.1",
|
||||||
|
SMTP_PORT: "1025",
|
||||||
|
SMTP_FROM: "Tri-Hub Ernährung <noreply@tri-hub.de>",
|
||||||
|
},
|
||||||
|
makeTransport,
|
||||||
|
);
|
||||||
|
|
||||||
|
return async function sendContactForm(data) {
|
||||||
|
// Erstellt die Eingangsbestätigung für den Absender und die Anfrage für Tri-Hub.
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
to: data.email,
|
||||||
|
subject: "Wir haben deine Nachricht erhalten – Tri-Hub",
|
||||||
|
text: [
|
||||||
|
`Hallo ${data.name},`,
|
||||||
|
"",
|
||||||
|
"vielen Dank für deine Nachricht. Wir haben deine Anfrage erhalten und melden uns schnellstmöglich bei dir.",
|
||||||
|
"",
|
||||||
|
`Betreff: ${data.subject}`,
|
||||||
|
"Deine Nachricht:",
|
||||||
|
data.message,
|
||||||
|
"",
|
||||||
|
"Viele Grüße",
|
||||||
|
"Tri-Hub Ernährung",
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: contactAddress,
|
||||||
|
subject: `Tri-Hub Kontaktanfrage: ${data.subject}`,
|
||||||
|
text: [
|
||||||
|
"Neue Kontaktanfrage über die Ernährungsseite",
|
||||||
|
"",
|
||||||
|
`Name: ${data.name}`,
|
||||||
|
`E-Mail: ${data.email}`,
|
||||||
|
`Betreff: ${data.subject}`,
|
||||||
|
"",
|
||||||
|
"Nachricht:",
|
||||||
|
data.message,
|
||||||
|
].join("\n"),
|
||||||
|
replyTo: data.email,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sendet beide Nachrichten und prüft, dass Mailpit beide Empfänger angenommen hat.
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
messages.map(async (message) => {
|
||||||
|
const result = await transport.sendMail({ from, ...message });
|
||||||
|
if (
|
||||||
|
!result.accepted?.some(
|
||||||
|
(address) =>
|
||||||
|
address.toLowerCase() === message.to.toLowerCase(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("SMTP hat den Empfänger nicht angenommen.");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Ein Fehler bei mindestens einer Nachricht wird an den Service weitergegeben.
|
||||||
|
if (results.some((result) => result.status === "rejected")) {
|
||||||
|
throw new BookingError(
|
||||||
|
502,
|
||||||
|
"CONTACT_EMAIL_FAILED",
|
||||||
|
"Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { emailStatus: "accepted" };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { contactAddress };
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { BookingError, emailPattern } from "./booking-service.js";
|
||||||
|
|
||||||
|
// Prüft und normalisiert die vom Browser übermittelten Kontaktangaben.
|
||||||
|
function normalizeInput(input) {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||||
|
throw new BookingError(
|
||||||
|
400,
|
||||||
|
"INVALID_INPUT",
|
||||||
|
"Bitte alle Pflichtfelder prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legt die zulässige Höchstlänge für jedes Formularfeld fest.
|
||||||
|
const limits = { name: 120, email: 254, subject: 160, message: 5000 };
|
||||||
|
const fields = {};
|
||||||
|
const data = {};
|
||||||
|
for (const [field, max] of Object.entries(limits)) {
|
||||||
|
const value = input[field];
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
fields[field] = "Dieses Feld ist erforderlich.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) fields[field] = "Dieses Feld ist erforderlich.";
|
||||||
|
else if (trimmed.length > max)
|
||||||
|
fields[field] = `Maximal ${max} Zeichen eingeben.`;
|
||||||
|
else if (
|
||||||
|
(field === "message"
|
||||||
|
? /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
|
||||||
|
: /[\x00-\x1f\x7f]/
|
||||||
|
).test(trimmed)
|
||||||
|
)
|
||||||
|
fields[field] = "Bitte entferne ungültige Steuerzeichen.";
|
||||||
|
else data[field] = trimmed;
|
||||||
|
}
|
||||||
|
// Prüft zusätzlich das E-Mail-Format und die Mindestlänge der Nachricht.
|
||||||
|
if (
|
||||||
|
data.email &&
|
||||||
|
(!emailPattern.test(data.email) || data.email.split("@")[0].length > 64)
|
||||||
|
) {
|
||||||
|
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
|
||||||
|
}
|
||||||
|
if (data.message && [...data.message].length < 10) {
|
||||||
|
fields.message =
|
||||||
|
"Deine Nachricht muss mindestens 10 Zeichen enthalten.";
|
||||||
|
}
|
||||||
|
// Gibt alle gefundenen Feldfehler gesammelt an die Route zurück.
|
||||||
|
if (Object.keys(fields).length) {
|
||||||
|
throw new BookingError(
|
||||||
|
400,
|
||||||
|
"INVALID_INPUT",
|
||||||
|
"Bitte prüfe deine Eingaben.",
|
||||||
|
fields,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbindet Validierung, CSV-Speicherung und anschließenden E-Mail-Versand.
|
||||||
|
export function createContactFormService({ storage, sendContactForm }) {
|
||||||
|
return {
|
||||||
|
async submit(input) {
|
||||||
|
const data = normalizeInput(input);
|
||||||
|
// Ergänzt einen Zeitstempel, damit die Anfrage zeitlich nachvollziehbar bleibt.
|
||||||
|
const record = { createdAt: new Date().toISOString(), ...data };
|
||||||
|
// Speichert vor dem Versand, damit die Anfrage bei SMTP-Problemen erhalten bleibt.
|
||||||
|
try {
|
||||||
|
await storage.append(record);
|
||||||
|
} catch {
|
||||||
|
throw new BookingError(
|
||||||
|
503,
|
||||||
|
"CONTACT_NOT_SAVED",
|
||||||
|
"Deine Nachricht konnte gerade nicht gespeichert werden. Bitte versuche es erneut.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Sendet die beiden Mails erst nach erfolgreichem Speichern der Anfrage.
|
||||||
|
try {
|
||||||
|
await sendContactForm(record);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BookingError) throw error;
|
||||||
|
throw new BookingError(
|
||||||
|
502,
|
||||||
|
"CONTACT_EMAIL_FAILED",
|
||||||
|
"Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { saved: true, emailStatus: "accepted" };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { parse } from "csv-parse/sync";
|
||||||
|
import { stringify } from "csv-stringify/sync";
|
||||||
|
|
||||||
|
// Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV.
|
||||||
|
const columns = ["createdAt", "name", "email", "subject", "message"];
|
||||||
|
|
||||||
|
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
|
||||||
|
function protect(value) {
|
||||||
|
const text = String(value ?? "");
|
||||||
|
return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her.
|
||||||
|
function restore(value) {
|
||||||
|
return value.startsWith("'") ? value.slice(1) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
|
||||||
|
export function createContactFormStorage(filePath) {
|
||||||
|
let queue = Promise.resolve();
|
||||||
|
|
||||||
|
// Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
|
||||||
|
async function readAll() {
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = await readFile(filePath, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ENOENT") return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!content.trim()) throw new Error("Leere Kontaktdatei");
|
||||||
|
return parse(content, {
|
||||||
|
bom: true,
|
||||||
|
columns(header) {
|
||||||
|
if (header.join(",") !== columns.join(",")) {
|
||||||
|
throw new Error("Unbekanntes Kontakt-CSV-Format");
|
||||||
|
}
|
||||||
|
return header;
|
||||||
|
},
|
||||||
|
skip_empty_lines: true,
|
||||||
|
}).map((row) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(row).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
restore(value),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original.
|
||||||
|
async function writeAll(records) {
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
const temporary = `${filePath}.${randomUUID()}.tmp`;
|
||||||
|
const content = stringify(
|
||||||
|
records.map((row) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
columns.map((column) => [column, protect(row[column])]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{ header: true, columns, record_delimiter: "\r\n" },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const handle = await open(temporary, "wx", 0o600);
|
||||||
|
try {
|
||||||
|
await handle.writeFile(content, "utf8");
|
||||||
|
await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
await rename(temporary, filePath);
|
||||||
|
} finally {
|
||||||
|
await rm(temporary, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
readAll,
|
||||||
|
// Hängt eine Anfrage nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren.
|
||||||
|
append(record) {
|
||||||
|
const operation = queue.then(async () => {
|
||||||
|
const records = await readAll();
|
||||||
|
records.push(record);
|
||||||
|
await writeAll(records);
|
||||||
|
});
|
||||||
|
queue = operation.catch(() => {});
|
||||||
|
return operation;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createApp } from "../server/index.js";
|
||||||
|
import { createContactFormEmailService } from "../server/services/contact-form-email.js";
|
||||||
|
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
|
||||||
|
|
||||||
|
const validInput = {
|
||||||
|
name: "Test Person",
|
||||||
|
email: "person@example.test",
|
||||||
|
subject: "Eine Frage",
|
||||||
|
message: "Das ist eine ausreichend lange Nachricht.",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand.
|
||||||
|
async function fixture(t, options = {}) {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-"));
|
||||||
|
const storage =
|
||||||
|
options.storage ??
|
||||||
|
createContactFormStorage(join(directory, "contact-requests.csv"));
|
||||||
|
const sent = [];
|
||||||
|
const app = createApp({
|
||||||
|
sendConfirmation: async () => {},
|
||||||
|
sendContact: async () => {},
|
||||||
|
contactFormStorage: storage,
|
||||||
|
sendContactForm: async (data) => sent.push(data),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
|
||||||
|
t.after(async () => {
|
||||||
|
app.closeAllConnections();
|
||||||
|
await new Promise((resolve) => app.close(resolve));
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = `http://127.0.0.1:${app.address().port}`;
|
||||||
|
async function post(input = validInput, headers = {}) {
|
||||||
|
const response = await fetch(`${base}/api/nutrition-contact`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
return { status: response.status, body: await response.json() };
|
||||||
|
}
|
||||||
|
return { app, base, post, sent, storage, directory };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit.
|
||||||
|
test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const result = await f.post();
|
||||||
|
|
||||||
|
assert.equal(result.status, 201);
|
||||||
|
assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" });
|
||||||
|
assert.equal(f.sent.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
name: f.sent[0].name,
|
||||||
|
email: f.sent[0].email,
|
||||||
|
subject: f.sent[0].subject,
|
||||||
|
message: f.sent[0].message,
|
||||||
|
},
|
||||||
|
validInput,
|
||||||
|
);
|
||||||
|
assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt)));
|
||||||
|
assert.deepEqual(await f.storage.readAll(), f.sent);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden.
|
||||||
|
test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const invalidInputs = [
|
||||||
|
{ ...validInput, name: " " },
|
||||||
|
{ ...validInput, name: "Test\nBcc: fremd" },
|
||||||
|
{ ...validInput, name: "x".repeat(121) },
|
||||||
|
{ ...validInput, email: "keine-adresse" },
|
||||||
|
{ ...validInput, email: "x".repeat(255) },
|
||||||
|
{ ...validInput, subject: "x".repeat(161) },
|
||||||
|
{ ...validInput, message: "zu kurz" },
|
||||||
|
{ ...validInput, message: "x".repeat(5001) },
|
||||||
|
{ ...validInput, message: "Nachricht\u0000ungültig" },
|
||||||
|
{ ...validInput, message: undefined },
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const input of invalidInputs) {
|
||||||
|
const result = await f.post(input);
|
||||||
|
assert.equal(result.status, 400, JSON.stringify(input));
|
||||||
|
}
|
||||||
|
assert.deepEqual(await f.storage.readAll(), []);
|
||||||
|
assert.equal(f.sent.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts.
|
||||||
|
test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405);
|
||||||
|
|
||||||
|
const wrongType = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||||
|
method: "POST",
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
assert.equal(wrongType.status, 415);
|
||||||
|
|
||||||
|
const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "{",
|
||||||
|
});
|
||||||
|
assert.equal(invalidJson.status, 400);
|
||||||
|
|
||||||
|
const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: "x".repeat(9000) }),
|
||||||
|
});
|
||||||
|
assert.equal(tooLarge.status, 413);
|
||||||
|
assert.deepEqual(await f.storage.readAll(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden.
|
||||||
|
test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => {
|
||||||
|
let sendCount = 0;
|
||||||
|
const f = await fixture(t, {
|
||||||
|
storage: {
|
||||||
|
async append() {
|
||||||
|
throw new Error("Datenträger nicht verfügbar");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sendContactForm: async () => sendCount++,
|
||||||
|
});
|
||||||
|
const result = await f.post();
|
||||||
|
|
||||||
|
assert.equal(result.status, 503);
|
||||||
|
assert.equal(result.body.error.code, "CONTACT_NOT_SAVED");
|
||||||
|
assert.equal(sendCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten.
|
||||||
|
test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => {
|
||||||
|
const f = await fixture(t, {
|
||||||
|
sendContactForm: async () => {
|
||||||
|
throw new Error("Mailpit nicht erreichbar");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await f.post();
|
||||||
|
|
||||||
|
assert.equal(result.status, 502);
|
||||||
|
assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED");
|
||||||
|
assert.equal(result.body.error.message.includes("gespeichert"), true);
|
||||||
|
assert.equal((await f.storage.readAll()).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen.
|
||||||
|
test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-"));
|
||||||
|
const file = join(directory, "contact-requests.csv");
|
||||||
|
const storage = createContactFormStorage(file);
|
||||||
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
const names = [
|
||||||
|
'=HYPERLINK("evil")',
|
||||||
|
'Test, "Person"',
|
||||||
|
"Zeile 1\nZeile 2",
|
||||||
|
"'Original",
|
||||||
|
...Array.from({ length: 8 }, (_, index) => `Person ${index}`),
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
names.map((name, index) =>
|
||||||
|
storage.append({
|
||||||
|
createdAt: `2026-01-01T00:00:0${index}.000Z`,
|
||||||
|
name,
|
||||||
|
email: `person${index}@example.test`,
|
||||||
|
subject: "CSV Test",
|
||||||
|
message: "Eine Nachricht mit mehr als zehn Zeichen.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await storage.readAll();
|
||||||
|
assert.equal(records.length, names.length);
|
||||||
|
assert.deepEqual(
|
||||||
|
records.map((record) => record.name).sort(),
|
||||||
|
[...names].sort(),
|
||||||
|
);
|
||||||
|
const csv = await readFile(file, "utf8");
|
||||||
|
assert.match(csv, /'=HYPERLINK/);
|
||||||
|
assert.match(csv, /"Test, ""Person"""/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben.
|
||||||
|
test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => {
|
||||||
|
const directory = await mkdtemp(
|
||||||
|
join(tmpdir(), "trihub-contact-csv-invalid-"),
|
||||||
|
);
|
||||||
|
const file = join(directory, "contact-requests.csv");
|
||||||
|
const storage = createContactFormStorage(file);
|
||||||
|
const corrupt = "wrong,header\n1,2\n";
|
||||||
|
await writeFile(file, corrupt);
|
||||||
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
storage.append({ ...validInput, createdAt: "2026-01-01" }),
|
||||||
|
);
|
||||||
|
assert.equal(await readFile(file, "utf8"), corrupt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt.
|
||||||
|
test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => {
|
||||||
|
const config = [];
|
||||||
|
const messages = [];
|
||||||
|
const sendContactForm = createContactFormEmailService(
|
||||||
|
{},
|
||||||
|
(transportConfig) => {
|
||||||
|
config.push(transportConfig);
|
||||||
|
return {
|
||||||
|
async sendMail(message) {
|
||||||
|
messages.push(message);
|
||||||
|
return { accepted: [message.to] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendContactForm({
|
||||||
|
...validInput,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(config[0].host, "127.0.0.1");
|
||||||
|
assert.equal(config[0].port, 1025);
|
||||||
|
assert.deepEqual(
|
||||||
|
messages.map((message) => message.to).sort(),
|
||||||
|
[validInput.email, "ernaehrung@tri-hub.de"].sort(),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
messages.every(
|
||||||
|
(message) =>
|
||||||
|
message.from === "Tri-Hub Ernährung <noreply@tri-hub.de>",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
messages.every((message) => message.text.includes(validInput.message)),
|
||||||
|
);
|
||||||
|
const request = messages.find(
|
||||||
|
(message) => message.to === "ernaehrung@tri-hub.de",
|
||||||
|
);
|
||||||
|
assert.equal(request.replyTo, validInput.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden.
|
||||||
|
test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => {
|
||||||
|
const sendContactForm = createContactFormEmailService({}, () => ({
|
||||||
|
async sendMail(message) {
|
||||||
|
return {
|
||||||
|
accepted: message.to === validInput.email ? [message.to] : [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
sendContactForm(validInput),
|
||||||
|
(error) =>
|
||||||
|
error.status === 502 && error.code === "CONTACT_EMAIL_FAILED",
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue