feat: add CMS inquiries and group bookings

This commit is contained in:
2026-09-17 15:48:40 +02:00
parent 97dd32a2b4
commit a2b3e99174
28 changed files with 1200 additions and 45 deletions
+4 -1
View File
@@ -101,7 +101,10 @@ export async function POST(request: Request) {
startTime: start.toISOString(),
endTime: end.toISOString(),
appointmentType,
status: "pending",
// Calendar bookings use already published availability, so a slot
// selected here is booked immediately. The separate contact form
// remains the path for requests that need personal coordination.
status: "confirmed",
userMessage: message || undefined,
},
});
@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { randomUUID } from "node:crypto";
import { getPayload } from "payload";
import config from "@payload-config";
import { getClientIp, checkRateLimit } from "@/lib/auth/rateLimit";
import { newConversationAdminEmail } from "@/lib/email/conversationTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
export const dynamic = "force-dynamic";
type Body = {
name?: unknown;
email?: unknown;
message?: unknown;
subject?: unknown;
type?: unknown;
};
function clean(value: unknown, max: number): string {
return typeof value === "string" ? value.trim().slice(0, max) : "";
}
export async function POST(request: Request) {
let body: Body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const name = clean(body.name, 160);
const email = clean(body.email, 254).toLowerCase();
const message = clean(body.message, 5000);
const subject = clean(body.subject, 180) || "Allgemeine Anfrage";
const type = body.type === "appointment" ? "appointment" : "contact";
if (!name || !email || !message || !/^\S+@\S+\.\S+$/.test(email)) {
return NextResponse.json({ error: "Bitte fülle alle Pflichtfelder korrekt aus." }, { status: 400 });
}
if (!checkRateLimit(`contact:ip:${getClientIp(request)}`, { max: 8, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
}
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
const customerId = user?.collection === "customers" ? user.id : undefined;
const conversation = await payload.create({
collection: "conversations",
overrideAccess: true,
data: {
name,
email,
subject,
type,
status: "open",
threadKey: randomUUID(),
...(customerId ? { customer: customerId } : {}),
lastMessageAt: new Date().toISOString(),
},
});
await payload.create({
collection: "conversation-messages",
overrideAccess: true,
data: {
conversation: conversation.id,
senderType: "visitor",
senderName: name,
senderEmail: email,
body: message,
},
});
const contact = await payload.findGlobal({ slug: "contact", overrideAccess: true });
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
const adminUrl = `${serverUrl}/admin/collections/conversations/${conversation.id}`;
if (contact.email) {
await sendEmail(contact.email, newConversationAdminEmail({ name, email, subject, body: message, adminUrl, threadKey: conversation.threadKey }), {
replyTo: process.env.INBOUND_REPLY_TO || "reply-to@anouma.org",
});
}
return NextResponse.json({ ok: true });
} catch (error) {
console.error("contact inquiry failed", error);
return NextResponse.json({ error: "Die Nachricht konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
}
}
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type InboundBody = {
from?: unknown;
sender?: unknown;
email?: unknown;
name?: unknown;
subject?: unknown;
text?: unknown;
body?: unknown;
html?: unknown;
messageId?: unknown;
inReplyTo?: unknown;
};
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function extractEmail(value: unknown): string {
if (value && typeof value === "object" && "email" in value) return extractEmail((value as { email?: unknown }).email);
const raw = stringValue(value);
const match = raw.match(/<([^>]+)>/) || raw.match(/([\w.+-]+@[\w.-]+\.[A-Za-z]{2,})/);
return (match?.[1] || raw).trim().toLowerCase();
}
function stripHtml(value: string): string {
return value.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
function findThreadKey(subject: string): string | null {
return subject.match(/\[ANOUMA-([^\]]+)\]/i)?.[1] || null;
}
export async function POST(request: Request) {
const secret = process.env.INBOUND_EMAIL_SECRET;
const supplied = request.headers.get("x-inbound-secret") || request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
if (!secret || supplied !== secret) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
let body: InboundBody;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "JSON erwartet." }, { status: 400 });
}
const fromRaw = body.from ?? body.sender ?? body.email;
const email = extractEmail(fromRaw);
const fromName = fromRaw && typeof fromRaw === "object" && "name" in fromRaw ? stringValue((fromRaw as { name?: unknown }).name) : "";
const name = stringValue(body.name) || fromName || stringValue(fromRaw).replace(/<[^>]+>/, "").trim() || email;
const subject = stringValue(body.subject) || "Antwort auf deine Anfrage";
const messageId = stringValue(body.messageId);
const text = stringValue(body.text) || stringValue(body.body) || stripHtml(stringValue(body.html));
if (!email || !messageId || !text) return NextResponse.json({ error: "Absender, messageId und Inhalt sind erforderlich." }, { status: 400 });
try {
const payload = await getPayload({ config });
if (messageId) {
const existing = await payload.find({ collection: "conversation-messages", where: { externalMessageId: { equals: messageId } }, limit: 1, overrideAccess: true });
if (existing.docs[0]) return NextResponse.json({ ok: true, duplicate: true });
}
const threadKey = findThreadKey(subject);
let conversation = threadKey
? (await payload.find({ collection: "conversations", where: { threadKey: { equals: threadKey } }, limit: 1, overrideAccess: true })).docs[0]
: undefined;
if (!conversation) {
conversation = (await payload.find({ collection: "conversations", where: { email: { equals: email }, status: { not_equals: "closed" } }, sort: "-lastMessageAt", limit: 1, overrideAccess: true })).docs[0];
}
if (!conversation) return NextResponse.json({ error: "Keine passende Konversation gefunden." }, { status: 404 });
await payload.create({
collection: "conversation-messages",
overrideAccess: true,
data: {
conversation: conversation.id,
senderType: "visitor",
senderName: name,
senderEmail: email,
body: text.slice(0, 10000),
externalMessageId: messageId,
},
});
await payload.update({ collection: "conversations", id: conversation.id, overrideAccess: true, data: { status: "open", lastMessageAt: new Date().toISOString() } });
return NextResponse.json({ ok: true });
} catch (error) {
console.error("inbound email failed", error);
return NextResponse.json({ error: "E-Mail konnte nicht übernommen werden." }, { status: 500 });
}
}
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { groupRegistrationEmail } from "@/lib/email/conversationTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
export const dynamic = "force-dynamic";
const value = (v: unknown, max: number) => typeof v === "string" ? v.trim().slice(0, max) : "";
export async function POST(request: Request) {
if (!checkRateLimit(`group-registration:${getClientIp(request)}`, { max: 5, windowMs: 60 * 60 * 1000 })) return NextResponse.json({ error: "Zu viele Anmeldungen. Bitte versuche es später erneut." }, { status: 429 });
let body: Record<string, unknown>;
try { body = await request.json(); } catch { return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); }
const offerSlug = value(body.offerSlug, 160);
const name = value(body.name, 160);
const email = value(body.email, 254).toLowerCase();
const phone = value(body.phone, 80);
const message = value(body.message, 3000);
if (!offerSlug || !name || !email || !/^\S+@\S+\.\S+$/.test(email)) return NextResponse.json({ error: "Bitte fülle Name und E-Mail korrekt aus." }, { status: 400 });
try {
const payload = await getPayload({ config });
const offerResult = await payload.find({ collection: "offers", where: { slug: { equals: offerSlug } }, limit: 1, overrideAccess: false });
const offer = offerResult.docs[0];
if (!offer || offer.bookingMode !== "group") return NextResponse.json({ error: "Dieses Gruppenangebot ist nicht verfügbar." }, { status: 404 });
if (offer.registrationDeadline && new Date(offer.registrationDeadline) < new Date()) return NextResponse.json({ error: "Die Anmeldefrist ist bereits abgelaufen." }, { status: 409 });
if (offer.groupCapacity) {
const count = await payload.count({ collection: "group-registrations", where: { and: [{ offer: { equals: offer.id } }, { status: { in: ["pending", "accepted"] } }] }, overrideAccess: true });
if (count.totalDocs >= offer.groupCapacity && offer.groupRegistrationMode === "capacity") return NextResponse.json({ error: "Für dieses Angebot sind aktuell keine Plätze mehr frei." }, { status: 409 });
}
const registration = await payload.create({ collection: "group-registrations", overrideAccess: true, data: { offer: offer.id, name, email, phone, message, status: "pending" } });
const contact = await payload.findGlobal({ slug: "contact", overrideAccess: true });
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || new URL(request.url).origin;
await Promise.all([
contact.email ? sendEmail(contact.email, groupRegistrationEmail({ name, offerTitle: offer.title, status: "admin", adminUrl: `${serverUrl}/admin/collections/group-registrations/${registration.id}` }), { replyTo: email }) : Promise.resolve(),
sendEmail(email, groupRegistrationEmail({ name, offerTitle: offer.title, status: "received" })),
]);
return NextResponse.json({ ok: true });
} catch (error) {
console.error("group registration failed", error);
return NextResponse.json({ error: "Die Anmeldung konnte nicht gesendet werden." }, { status: 500 });
}
}