Merge branch 'feature/Information' into dev
commit
56e241c387
|
|
@ -10,5 +10,6 @@ Thumbs.db
|
|||
# Private Buchungsdaten und lokale Testergebnisse
|
||||
src/server/data/*
|
||||
!src/server/data/.gitkeep
|
||||
!src/server/data/contact-requests.csv
|
||||
playwright-report/
|
||||
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="email"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
|
|
@ -485,6 +486,7 @@
|
|||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
select {
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
|
|
@ -557,6 +559,21 @@
|
|||
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 {
|
||||
min-height: 20px;
|
||||
margin-top: 12px;
|
||||
|
|
@ -1102,6 +1119,117 @@
|
|||
</form>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<footer>
|
||||
|
|
@ -1118,6 +1246,10 @@
|
|||
type="module"
|
||||
src="/src/components/navigationsbar/navigation.js"
|
||||
></script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/contact/contact-form.js"
|
||||
></script>
|
||||
<script>
|
||||
const form = document.getElementById("reviewForm");
|
||||
const status = document.getElementById("formStatus");
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
<a href="#erfahrungen" class="navbar__link">Rezensionen</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/index.html" class="navbar__link">Kontakt</a>
|
||||
<a href="#kontakt" class="navbar__link">Kontakt</a>
|
||||
</li>
|
||||
<li>
|
||||
<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 { createEmailService } from "./services/email-service.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({
|
||||
catalog = packages,
|
||||
|
|
@ -20,6 +24,10 @@ export function createApp({
|
|||
details,
|
||||
sendConfirmation,
|
||||
sendContact,
|
||||
contactFormStorage = createContactFormStorage(
|
||||
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
|
||||
),
|
||||
sendContactForm,
|
||||
} = {}) {
|
||||
const ids = new Set();
|
||||
for (const entry of catalog) {
|
||||
|
|
@ -50,8 +58,17 @@ export function createApp({
|
|||
storage,
|
||||
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) => {
|
||||
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") {
|
||||
return handleContact(
|
||||
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