feat: Kontaktformular für ein Erstgespräch
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 4m31s
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 4m31s
Neuer Abschnitt nach den Fragen, im Menü als Erstgespräch. Anfrage geht als E-Mail an die Veranstalter-Adresse (Admin) bzw. ADMIN_NOTIFY_EMAIL, sofort zugestellt, sonst über den Worker wiederholt. Gleicher Schutz wie die Buchung: nur JSON von der eigenen Seite, Rate Limit (5/h je IP), Prüfung auf dem Server, Honeypot gegen Bots, doppeltes Absenden ergibt eine Mail. Keine automatische Mail an die angegebene Adresse (sonst Missbrauch für Spam an Dritte). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
d45700a9ef
commit
7745d5862d
9 files changed
+336
-1
No files matched your search
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Formular "Erstgespraech" (src/components/sections/ContactSection.tsx).
|
||||||
|
* Gleiche Schutzschicht wie die Buchung: nur JSON von der eigenen Seite,
|
||||||
|
* Rate Limit je IP, serverseitige Pruefung aller Felder.
|
||||||
|
*/
|
||||||
|
import { after } from "next/server";
|
||||||
|
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
||||||
|
import { contactRequestSchema, submitContactRequest } from "@/lib/contact";
|
||||||
|
import { getDb } from "@/lib/db";
|
||||||
|
import { flushEmails } from "@/lib/email";
|
||||||
|
import { requireAppSecret } from "@/lib/env";
|
||||||
|
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const blocked = checkSameOrigin(request);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
const limited = await rateLimited(request, RATE_LIMITS.contact);
|
||||||
|
if (limited) return limited;
|
||||||
|
const body = await readJson(request, contactRequestSchema);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const result = await submitContactRequest(db, body.data, { now: new Date(), secret: requireAppSecret() });
|
||||||
|
if (!result.ok) {
|
||||||
|
return fail(503, { code: "NOT_CONFIGURED", message: "Das Formular ist gerade nicht verfügbar. Bitte schreib uns direkt eine E-Mail." });
|
||||||
|
}
|
||||||
|
// Sofort zustellen statt auf den naechsten Worker-Lauf zu warten; schlaegt
|
||||||
|
// das fehl, versucht es der Worker erneut.
|
||||||
|
if (result.delivered) {
|
||||||
|
after(() => flushEmails(db, { limit: 5 }).catch(() => undefined));
|
||||||
|
}
|
||||||
|
return ok({ received: true });
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export default function DatenschutzPage() {
|
|||||||
<li>Buchungsdaten: Name, E-Mail, Telefon, Land, optionale Anmerkungen, Zeitpunkte der Erklärungen; gespeichert in einer selbst betriebenen PostgreSQL-Datenbank.</li>
|
<li>Buchungsdaten: Name, E-Mail, Telefon, Land, optionale Anmerkungen, Zeitpunkte der Erklärungen; gespeichert in einer selbst betriebenen PostgreSQL-Datenbank.</li>
|
||||||
<li>Zahlungen über Stripe (Stripe Payments Europe Ltd.); Kartendaten gelangen nicht auf unsere Server, gespeichert werden nur Stripe-Objekt-IDs.</li>
|
<li>Zahlungen über Stripe (Stripe Payments Europe Ltd.); Kartendaten gelangen nicht auf unsere Server, gespeichert werden nur Stripe-Objekt-IDs.</li>
|
||||||
<li>E-Mail-Versand über den konfigurierten SMTP-Anbieter.</li>
|
<li>E-Mail-Versand über den konfigurierten SMTP-Anbieter.</li>
|
||||||
|
<li>Formular Erstgespräch: Name, E-Mail, optional Telefon, Erreichbarkeit und Nachricht; wird nicht in einer eigenen Tabelle gespeichert, sondern als E-Mail an den Veranstalter geschickt (Kopie im Postausgang der Datenbank).</li>
|
||||||
<li>Rate Limiting speichert nur einen gehashten IP-Schlüssel, maximal einen Tag.</li>
|
<li>Rate Limiting speichert nur einen gehashten IP-Schlüssel, maximal einen Tag.</li>
|
||||||
<li>Keine Analyse- oder Tracking-Dienste, keine Cookies auf der öffentlichen Seite; Schriften werden selbst ausgeliefert.</li>
|
<li>Keine Analyse- oder Tracking-Dienste, keine Cookies auf der öffentlichen Seite; Schriften werden selbst ausgeliefert.</li>
|
||||||
<li>Externe Videoanbieter nur nach Klick und nur, wenn EXTERNAL_VIDEO_PROVIDER gesetzt ist.</li>
|
<li>Externe Videoanbieter nur nach Klick und nur, wenn EXTERNAL_VIDEO_PROVIDER gesetzt ist.</li>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AccommodationSection } from "@/components/sections/CampSections";
|
|||||||
import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections";
|
import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections";
|
||||||
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
||||||
import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections";
|
import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections";
|
||||||
|
import { ContactSection } from "@/components/sections/ContactSection";
|
||||||
import { IntroVideoSection } from "@/components/sections/IntroVideoSection";
|
import { IntroVideoSection } from "@/components/sections/IntroVideoSection";
|
||||||
import { PricingSection } from "@/components/sections/PricingSection";
|
import { PricingSection } from "@/components/sections/PricingSection";
|
||||||
import { ReviewsSection } from "@/components/sections/ReviewsSection";
|
import { ReviewsSection } from "@/components/sections/ReviewsSection";
|
||||||
@@ -49,6 +50,7 @@ export default async function HomePage() {
|
|||||||
<ReviewsSection data={reviews} showDrafts={showDrafts} />
|
<ReviewsSection data={reviews} showDrafts={showDrafts} />
|
||||||
<AboutSection showDrafts={showDrafts} />
|
<AboutSection showDrafts={showDrafts} />
|
||||||
<FaqSection facts={facts} pricing={pricing} />
|
<FaqSection facts={facts} pricing={pricing} />
|
||||||
|
<ContactSection />
|
||||||
<ClosingSection pricing={pricing} />
|
<ClosingSection pricing={pricing} />
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter facts={facts} showDrafts={showDrafts} logo={media.logoDark} />
|
<SiteFooter facts={facts} showDrafts={showDrafts} logo={media.logoDark} />
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "error"; message: string };
|
||||||
|
type Fields = Record<string, string>;
|
||||||
|
|
||||||
|
const FIELD_IDS = ["name", "email", "phone", "availability", "message", "privacy"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anfrage fuer ein unverbindliches Erstgespraech (POST /api/contact). Die
|
||||||
|
* Pruefung passiert auf dem Server; Fehler erscheinen am jeweiligen Feld,
|
||||||
|
* der Fokus springt auf das erste fehlerhafte. Das Feld "website" ist eine
|
||||||
|
* fuer Menschen unsichtbare Falle fuer Bots.
|
||||||
|
*/
|
||||||
|
export function ContactSection() {
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const [status, setStatus] = useState<Status>({ kind: "idle" });
|
||||||
|
const [errors, setErrors] = useState<Fields>({});
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const text = (key: string) => String(form.get(key) ?? "");
|
||||||
|
setStatus({ kind: "sending" });
|
||||||
|
setErrors({});
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/contact", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: text("name"),
|
||||||
|
email: text("email"),
|
||||||
|
phone: text("phone"),
|
||||||
|
availability: text("availability"),
|
||||||
|
message: text("message"),
|
||||||
|
privacy: form.get("privacy") === "on",
|
||||||
|
website: text("website"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = (await response.json().catch(() => null)) as { success: boolean; error?: { message: string; fields?: Fields } } | null;
|
||||||
|
if (response.ok && result?.success) {
|
||||||
|
setStatus({ kind: "sent" });
|
||||||
|
formRef.current?.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fields = result?.error?.fields ?? {};
|
||||||
|
setErrors(fields);
|
||||||
|
setStatus({ kind: "error", message: result?.error?.message ?? "Senden hat nicht geklappt. Bitte versuche es erneut." });
|
||||||
|
const first = FIELD_IDS.find((id) => fields[id]);
|
||||||
|
if (first) requestAnimationFrame(() => document.getElementById(`ct-${first}`)?.focus());
|
||||||
|
} catch {
|
||||||
|
setStatus({ kind: "error", message: "Keine Verbindung. Bitte prüfe dein Internet und versuche es erneut." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldProps = (id: (typeof FIELD_IDS)[number]) => ({
|
||||||
|
id: `ct-${id}`,
|
||||||
|
name: id,
|
||||||
|
"aria-invalid": errors[id] ? true : undefined,
|
||||||
|
"aria-describedby": errors[id] ? `ct-${id}-error` : undefined,
|
||||||
|
});
|
||||||
|
const error = (id: string) =>
|
||||||
|
errors[id] ? (
|
||||||
|
<p id={`ct-${id}-error`} className="mt-1.5 text-sm font-medium text-rosso">
|
||||||
|
{errors[id]}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="erstgespraech" aria-labelledby="erstgespraech-title" className="bg-plaster py-24 md:py-32">
|
||||||
|
<div className="container-edge grid gap-12 md:grid-cols-12">
|
||||||
|
<div className="md:col-span-5">
|
||||||
|
<h2 id="erstgespraech-title" className="section-title">
|
||||||
|
Erstgespräch
|
||||||
|
</h2>
|
||||||
|
<p className="mt-6 max-w-md text-lg text-ink/80">
|
||||||
|
Du bist dir noch unsicher, ob das Modelcamp zu dir passt? Lass uns unverbindlich sprechen. Schreib uns kurz, wann du erreichbar bist, wir melden uns bei dir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-7">
|
||||||
|
{status.kind === "sent" ? (
|
||||||
|
<div role="status" className="rounded-2xl bg-paper p-8 shadow-[0_1px_0_var(--color-line)]">
|
||||||
|
<p className="text-xl font-bold">Danke, deine Anfrage ist angekommen.</p>
|
||||||
|
<p className="mt-2 text-ink/80">Wir melden uns in den nächsten Tagen bei dir.</p>
|
||||||
|
<button type="button" onClick={() => setStatus({ kind: "idle" })} className="btn btn-quiet mt-6">
|
||||||
|
Weitere Anfrage senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form ref={formRef} onSubmit={submit} noValidate className="relative grid gap-5 rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] md:grid-cols-2 md:p-8">
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-name">Name</label>
|
||||||
|
<input {...fieldProps("name")} className="field" autoComplete="name" required maxLength={120} />
|
||||||
|
{error("name")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-email">E-Mail</label>
|
||||||
|
<input {...fieldProps("email")} className="field" type="email" autoComplete="email" inputMode="email" required maxLength={200} />
|
||||||
|
{error("email")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-phone">Telefon (optional)</label>
|
||||||
|
<input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" maxLength={60} />
|
||||||
|
{error("phone")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-availability">Wann bist du erreichbar? (optional)</label>
|
||||||
|
<input {...fieldProps("availability")} className="field" placeholder="z. B. werktags ab 18 Uhr" maxLength={200} />
|
||||||
|
{error("availability")}
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="field-label" htmlFor="ct-message">Worüber möchtest du sprechen? (optional)</label>
|
||||||
|
<textarea {...fieldProps("message")} className="field min-h-32" rows={4} maxLength={2000} />
|
||||||
|
{error("message")}
|
||||||
|
</div>
|
||||||
|
{/* Falle fuer Bots: nicht sichtbar, nicht per Tab erreichbar. */}
|
||||||
|
<div aria-hidden className="absolute -left-[9999px] h-px w-px overflow-hidden">
|
||||||
|
<label htmlFor="ct-website">Webseite</label>
|
||||||
|
<input id="ct-website" name="website" tabIndex={-1} autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label htmlFor="ct-privacy" className="flex cursor-pointer gap-3 text-[0.9375rem] leading-snug">
|
||||||
|
<input {...fieldProps("privacy")} type="checkbox" required className="mt-0.5 h-5 w-5 shrink-0 accent-ink" />
|
||||||
|
<span>
|
||||||
|
Ich bin einverstanden, dass meine Angaben zur Bearbeitung der Anfrage verwendet werden (
|
||||||
|
<a href="/datenschutz" target="_blank" rel="noopener" className="underline">
|
||||||
|
Datenschutzhinweise
|
||||||
|
</a>
|
||||||
|
).
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{error("privacy")}
|
||||||
|
</div>
|
||||||
|
{status.kind === "error" && (
|
||||||
|
<p role="alert" className="rounded-lg border border-rosso/40 bg-plaster p-4 text-[0.9375rem] font-medium text-rosso md:col-span-2">
|
||||||
|
{status.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<button type="submit" disabled={status.kind === "sending"} className="btn btn-primary px-8 disabled:opacity-60">
|
||||||
|
{status.kind === "sending" ? "Wird gesendet …" : "Erstgespräch anfragen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -271,4 +271,5 @@ export const NAV_ITEMS = [
|
|||||||
{ id: "preis", label: "Preis" },
|
{ id: "preis", label: "Preis" },
|
||||||
{ id: "team", label: "Team" },
|
{ id: "team", label: "Team" },
|
||||||
{ id: "fragen", label: "Fragen" },
|
{ id: "fragen", label: "Fragen" },
|
||||||
|
{ id: "erstgespraech", label: "Erstgespräch" },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Anfrage fuer ein unverbindliches Erstgespraech. Kein eigenes Datenmodell:
|
||||||
|
* die Anfrage geht als E-Mail in den Postausgang (EmailMessage, mit
|
||||||
|
* Wiederholungen und Anzeige unzustellbarer Mails im Admin) an den
|
||||||
|
* Veranstalter bzw. ADMIN_NOTIFY_EMAIL.
|
||||||
|
*
|
||||||
|
* Bewusst keine automatische Bestaetigungsmail an die angegebene Adresse:
|
||||||
|
* sonst liesse sich das Formular nutzen, um Dritten Mails zu schicken.
|
||||||
|
*/
|
||||||
|
import "./zod-de";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { audit, CUSTOMER } from "./audit";
|
||||||
|
import { getCamp } from "./camp";
|
||||||
|
import type { Db } from "./db";
|
||||||
|
import { enqueueEmail } from "./email";
|
||||||
|
import { contactInquiryEmail } from "./email-templates";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { hmacHex } from "./tokens";
|
||||||
|
|
||||||
|
const optionalText = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v || null);
|
||||||
|
|
||||||
|
export const contactRequestSchema = z.object({
|
||||||
|
name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120),
|
||||||
|
email: z.string().trim().max(200).pipe(z.email("Bitte gib eine gültige E-Mail-Adresse an.")),
|
||||||
|
phone: optionalText(60),
|
||||||
|
availability: optionalText(200),
|
||||||
|
message: optionalText(2000),
|
||||||
|
privacy: z.literal(true, { error: "Bitte bestätige die Datenschutzhinweise." }),
|
||||||
|
/** Honeypot: fuer Menschen unsichtbar, Bots fuellen es aus. */
|
||||||
|
website: z.string().max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ContactRequest = z.infer<typeof contactRequestSchema>;
|
||||||
|
export type ContactResult = { ok: true; delivered: boolean } | { ok: false; error: "NOT_CONFIGURED" };
|
||||||
|
|
||||||
|
/** Empfaenger: Veranstalter-Adresse aus dem Admin, sonst ADMIN_NOTIFY_EMAIL. */
|
||||||
|
export async function contactRecipient(db: Db): Promise<string | null> {
|
||||||
|
const camp = await getCamp(db);
|
||||||
|
return camp.organizerEmail || env().ADMIN_NOTIFY_EMAIL || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitContactRequest(db: Db, input: ContactRequest, ctx: { now: Date; secret: string }): Promise<ContactResult> {
|
||||||
|
// Bot: so tun, als waere alles gut, aber nichts verschicken.
|
||||||
|
if (input.website) return { ok: true, delivered: false };
|
||||||
|
const to = await contactRecipient(db);
|
||||||
|
if (!to) return { ok: false, error: "NOT_CONFIGURED" };
|
||||||
|
|
||||||
|
// Doppelt abgeschicktes Formular (gleiche Angaben in derselben Minute)
|
||||||
|
// ergibt nur eine Mail.
|
||||||
|
const minute = Math.floor(ctx.now.getTime() / 60_000);
|
||||||
|
const fingerprint = hmacHex(ctx.secret, `contact:${input.email.toLowerCase()}:${input.message ?? ""}:${minute}`).slice(0, 32);
|
||||||
|
await enqueueEmail(db, contactInquiryEmail(to, input, `contact:${fingerprint}`, ctx.now));
|
||||||
|
await audit(db, CUSTOMER, "contact.requested", "Contact", fingerprint);
|
||||||
|
return { ok: true, delivered: true };
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@
|
|||||||
* bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt".
|
* bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt".
|
||||||
*/
|
*/
|
||||||
import type { Booking, Installment } from "@/generated/prisma/client";
|
import type { Booking, Installment } from "@/generated/prisma/client";
|
||||||
|
import type { ContactRequest } from "./contact";
|
||||||
import { escapeHtml, type EmailDraft } from "./email";
|
import { escapeHtml, type EmailDraft } from "./email";
|
||||||
import { appUrl } from "./env";
|
import { appUrl } from "./env";
|
||||||
import { formatEuro } from "./money";
|
import { formatEuro } from "./money";
|
||||||
import { PLAN_LABEL } from "./payment-plan";
|
import { PLAN_LABEL } from "./payment-plan";
|
||||||
import { PHASE_LABEL } from "./pricing";
|
import { PHASE_LABEL } from "./pricing";
|
||||||
import { formatDate } from "./time";
|
import { formatDate, formatDateTime } from "./time";
|
||||||
|
|
||||||
type BookingWithInstallments = Booking & { installments: Installment[] };
|
type BookingWithInstallments = Booking & { installments: Installment[] };
|
||||||
|
|
||||||
@@ -163,3 +164,20 @@ export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Anfrage fuer ein Erstgespraech an den Veranstalter (src/lib/contact.ts). */
|
||||||
|
export function contactInquiryEmail(to: string, inquiry: ContactRequest, dedupeKey: string, receivedAt: Date): EmailDraft {
|
||||||
|
const subject = `Anfrage Erstgespräch: ${inquiry.name}`;
|
||||||
|
const lines = [
|
||||||
|
`Über die Webseite kam eine Anfrage für ein Erstgespräch (${formatDateTime(receivedAt)}).`,
|
||||||
|
[
|
||||||
|
`Name: ${inquiry.name}`,
|
||||||
|
`E-Mail: ${inquiry.email}`,
|
||||||
|
`Telefon: ${inquiry.phone ?? "–"}`,
|
||||||
|
`Erreichbar: ${inquiry.availability ?? "–"}`,
|
||||||
|
].join("\n"),
|
||||||
|
inquiry.message ? `Nachricht:\n${inquiry.message}` : "Keine Nachricht angegeben.",
|
||||||
|
];
|
||||||
|
const reply = `mailto:${encodeURIComponent(inquiry.email)}?subject=${encodeURIComponent(`Dein Erstgespräch – ${BRAND}`)}`;
|
||||||
|
return { dedupeKey, to, subject: `${subject} | ${BRAND}`, ...wrap(subject, lines, { href: reply, label: "Antworten" }) };
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export const RATE_LIMITS = {
|
|||||||
voucher: { action: "voucher", limit: 10, windowSeconds: 900 },
|
voucher: { action: "voucher", limit: 10, windowSeconds: 900 },
|
||||||
checkout: { action: "checkout", limit: 8, windowSeconds: 900 },
|
checkout: { action: "checkout", limit: 8, windowSeconds: 900 },
|
||||||
statusAction: { action: "status-action", limit: 20, windowSeconds: 900 },
|
statusAction: { action: "status-action", limit: 20, windowSeconds: 900 },
|
||||||
|
contact: { action: "contact", limit: 5, windowSeconds: 3600 },
|
||||||
} satisfies Record<string, RateLimitRule>;
|
} satisfies Record<string, RateLimitRule>;
|
||||||
|
|
||||||
export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number };
|
export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number };
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { contactRequestSchema, submitContactRequest } from "@/lib/contact";
|
||||||
|
import { resetEnvCache } from "@/lib/env";
|
||||||
|
import { db, resetDb, SECRET, setupCamp } from "./support/db";
|
||||||
|
|
||||||
|
const NOW = new Date("2026-11-15T10:00:00Z");
|
||||||
|
const ctx = { now: NOW, secret: SECRET };
|
||||||
|
|
||||||
|
function request(overrides: Record<string, unknown> = {}) {
|
||||||
|
return contactRequestSchema.parse({
|
||||||
|
name: "Lena Beispiel",
|
||||||
|
email: "[email protected]",
|
||||||
|
phone: "",
|
||||||
|
availability: "abends",
|
||||||
|
message: "Passt das Camp für Einsteigerinnen?",
|
||||||
|
privacy: true,
|
||||||
|
website: "",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
await setupCamp({ organizerEmail: "[email protected]" });
|
||||||
|
delete process.env.ADMIN_NOTIFY_EMAIL;
|
||||||
|
resetEnvCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Erstgespräch", () => {
|
||||||
|
test("Anfrage landet als Mail beim Veranstalter, mit allen Angaben", async () => {
|
||||||
|
expect(await submitContactRequest(db, request(), ctx)).toEqual({ ok: true, delivered: true });
|
||||||
|
const mails = await db.emailMessage.findMany();
|
||||||
|
expect(mails).toHaveLength(1);
|
||||||
|
expect(mails[0]!.toAddress).toBe("[email protected]");
|
||||||
|
expect(mails[0]!.subject).toContain("Lena Beispiel");
|
||||||
|
expect(mails[0]!.textBody).toContain("[email protected]");
|
||||||
|
expect(mails[0]!.textBody).toContain("Passt das Camp für Einsteigerinnen?");
|
||||||
|
expect(mails[0]!.htmlBody).toContain("mailto:lena%40example.org");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doppelt abgeschickt ergibt nur eine Mail", async () => {
|
||||||
|
await submitContactRequest(db, request(), ctx);
|
||||||
|
await submitContactRequest(db, request(), { ...ctx, now: new Date(NOW.getTime() + 10_000) });
|
||||||
|
expect(await db.emailMessage.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Bot im Honeypot: Erfolg vortäuschen, nichts verschicken", async () => {
|
||||||
|
expect(await submitContactRequest(db, request({ website: "https://spam.example" }), ctx)).toEqual({ ok: true, delivered: false });
|
||||||
|
expect(await db.emailMessage.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ohne Empfänger nicht verfügbar, mit ADMIN_NOTIFY_EMAIL als Ersatz", async () => {
|
||||||
|
await setupCamp({ organizerEmail: null });
|
||||||
|
expect(await submitContactRequest(db, request(), ctx)).toEqual({ ok: false, error: "NOT_CONFIGURED" });
|
||||||
|
process.env.ADMIN_NOTIFY_EMAIL = "[email protected]";
|
||||||
|
resetEnvCache();
|
||||||
|
await submitContactRequest(db, request(), ctx);
|
||||||
|
expect((await db.emailMessage.findFirstOrThrow()).toAddress).toBe("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Pflichtangaben und Einwilligung werden serverseitig geprüft", () => {
|
||||||
|
expect(contactRequestSchema.safeParse({ name: "L", email: "kein-mail", privacy: false }).success).toBe(false);
|
||||||
|
expect(contactRequestSchema.safeParse({ name: "Lena", email: "[email protected]", privacy: false }).success).toBe(false);
|
||||||
|
expect(request({ phone: " " }).phone).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user