Initial commit: ANOUMA website with Payload CMS, WebRTC meetings, and booking system

- Next.js 16 App Router site with the ANOUMA design system
- Payload CMS (PostgreSQL) for offers, events, posts and page content
- WebRTC video-call system with custom signaling server
- SMTP email reminders and booking-request notifications
- Customer accounts, calendar-based availability, and booking workflow
This commit is contained in:
maro
2026-08-25 16:40:51 +02:00
commit 45261a0461
138 changed files with 23263 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import "@payloadcms/next/css";
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from "@payloadcms/next/routes";
export const GET = REST_GET(config);
export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config);
export const PATCH = REST_PATCH(config);
export const PUT = REST_PUT(config);
export const OPTIONS = REST_OPTIONS(config);
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ id: string }> };
export async function POST(request: Request, { params }: Args) {
const { id } = await params;
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) {
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
}
const alternative = booking.proposedAlternative;
if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) {
return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 });
}
// The beforeChange hook on booking-requests re-checks for conflicts
// (with an advisory lock) before allowing this to become "confirmed" —
// the alternative slot may have been taken by someone else since it was
// proposed.
await payload.update({
collection: "booking-requests",
id,
data: {
status: "confirmed",
date: alternative.date,
startTime: alternative.startTime,
endTime: alternative.endTime,
},
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) {
return NextResponse.json({ error: error.message }, { status: 409 });
}
console.error("accept-alternative failed", error);
return NextResponse.json({ error: "Der Termin konnte nicht angenommen werden." }, { status: 500 });
}
}
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ id: string }> };
export async function POST(request: Request, { params }: Args) {
const { id } = await params;
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) {
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
}
if (booking.status === "cancelled") {
return NextResponse.json({ ok: true });
}
await payload.update({ collection: "booking-requests", id, data: { status: "cancelled" } });
return NextResponse.json({ ok: true });
} catch (error) {
console.error("booking cancel failed", error);
return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 });
}
}
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots";
export const dynamic = "force-dynamic";
type Body = {
offerSlug?: unknown;
start?: unknown;
appointmentType?: unknown;
message?: unknown;
};
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 offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : "";
const startISO = typeof body.start === "string" ? body.start : "";
const appointmentType = body.appointmentType === "online" ? "online" : "onsite";
const message = typeof body.message === "string" ? body.message.trim().slice(0, 2000) : "";
if (!offerSlug || !startISO) {
return NextResponse.json({ error: "Angebot und Termin sind erforderlich." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an, um einen Termin anzufragen." }, { status: 401 });
}
const { docs } = await payload.find({
collection: "offers",
where: { slug: { equals: offerSlug } },
limit: 1,
overrideAccess: false,
});
const offer = docs[0];
if (!offer || !offer.bookable || !offer.durationMinutes) {
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
}
const start = new Date(startISO);
if (Number.isNaN(start.getTime()) || start < new Date()) {
return NextResponse.json({ error: "Bitte wähle einen gültigen, zukünftigen Termin." }, { status: 400 });
}
const end = new Date(start.getTime() + offer.durationMinutes * 60_000);
// Re-validate the slot is actually free right now (defense against a
// stale slot list) — the hard, race-safe check still runs again when
// Anna confirms (see collections/BookingRequests.ts).
const dayStart = new Date(start);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(start);
dayEnd.setHours(23, 59, 59, 999);
const slotsByDay = await getAvailableSlots(payload, {
durationMinutes: offer.durationMinutes,
from: dayStart,
to: dayEnd,
});
const key = dayStart.toISOString().slice(0, 10);
const isStillFree = (slotsByDay.get(key) ?? []).some((s) => s.start.getTime() === start.getTime());
if (!isStillFree) {
return NextResponse.json({ error: "Dieser Termin ist leider nicht mehr verfügbar." }, { status: 409 });
}
await payload.create({
collection: "booking-requests",
data: {
user: user.id,
offer: offer.id,
date: start.toISOString(),
startTime: start.toISOString(),
endTime: end.toISOString(),
appointmentType,
status: "pending",
userMessage: message || undefined,
},
});
return NextResponse.json({ ok: true });
} catch (error) {
console.error("booking request failed", error);
return NextResponse.json({ error: "Die Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots";
export const dynamic = "force-dynamic";
/** GET /api/booking/slots?offer=<slug>&from=YYYY-MM-DD&to=YYYY-MM-DD */
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const offerSlug = searchParams.get("offer");
const fromParam = searchParams.get("from");
const toParam = searchParams.get("to");
if (!offerSlug) {
return NextResponse.json({ error: "offer ist erforderlich." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "offers",
where: { slug: { equals: offerSlug } },
limit: 1,
overrideAccess: false,
});
const offer = docs[0];
if (!offer || !offer.bookable || !offer.durationMinutes) {
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
}
const from = fromParam ? new Date(fromParam) : new Date();
from.setHours(0, 0, 0, 0);
const to = toParam ? new Date(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
to.setHours(23, 59, 59, 999);
// Cap the range so this can't be abused to run an expensive scan.
const maxRangeMs = 62 * 24 * 60 * 60 * 1000;
if (to.getTime() - from.getTime() > maxRangeMs) {
return NextResponse.json({ error: "Zeitraum zu groß." }, { status: 400 });
}
const slotsByDay = await getAvailableSlots(payload, { durationMinutes: offer.durationMinutes, from, to });
const result: Record<string, { start: string; end: string }[]> = {};
for (const [day, slots] of slotsByDay) {
result[day] = slots.map((s) => ({ start: s.start.toISOString(), end: s.end.toISOString() }));
}
return NextResponse.json({ durationMinutes: offer.durationMinutes, slots: result });
} catch (error) {
console.error("booking slots failed", error);
return NextResponse.json({ error: "Verfügbarkeiten konnten nicht geladen werden." }, { status: 500 });
}
}
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { runEventReminders } from "@/lib/meeting/reminders";
export const dynamic = "force-dynamic";
/**
* Intended to be called by an external cron job every few minutes, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
*/
export async function GET(request: Request) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 });
}
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const result = await runEventReminders();
return NextResponse.json({ ok: true, ...result });
} catch (error) {
console.error("event-reminders cron failed", error);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
@@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import "@payloadcms/next/css";
import { GRAPHQL_PLAYGROUND_GET } from "@payloadcms/next/routes";
export const GET = GRAPHQL_PLAYGROUND_GET(config);
+8
View File
@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import { GRAPHQL_POST, REST_OPTIONS } from "@payloadcms/next/routes";
export const POST = GRAPHQL_POST(config);
export const OPTIONS = REST_OPTIONS(config);
@@ -0,0 +1,97 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status";
export const dynamic = "force-dynamic";
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
let body: { name?: unknown; password?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const password = typeof body.password === "string" ? body.password.trim() : "";
if (!name) {
return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "events",
where: { slug: { equals: slug } },
limit: 1,
});
const event = docs[0];
if (!event || !event.isOnline) {
return NextResponse.json({ error: "Dieser Online-Termin wurde nicht gefunden." }, { status: 404 });
}
const { user } = await payload.auth({ headers: request.headers });
const isHost = Boolean(user);
if (!isHost) {
if (!password) {
return NextResponse.json({ error: "Bitte gib das Meeting-Passwort ein." }, { status: 400 });
}
if (!event.meetingPassword || !safeEqual(password, event.meetingPassword)) {
return NextResponse.json({ error: "Das Meeting-Passwort ist nicht korrekt." }, { status: 401 });
}
}
const settings = await payload.findGlobal({ slug: "meeting-settings" });
const start = combineDateAndTime(event.date, event.startTime);
const end = event.endTime
? combineDateAndTime(event.date, event.endTime)
: new Date(start.getTime() + 60 * 60_000);
const status = getMeetingStatus({
start,
end,
joinWindowMinutes: isHost ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
closeAfterMinutes: settings.meetingCloseAfterMinutes,
});
if (!canJoinMeeting(status)) {
const message =
status === "scheduled"
? "Dieser Termin ist noch nicht offen. Bitte versuche es näher am Beginn erneut."
: "Dieser Termin ist bereits beendet.";
return NextResponse.json({ error: message }, { status: 403 });
}
const { token, participantId } = createMeetingToken({
eventSlug: slug,
name,
role: isHost ? "host" : "participant",
});
return NextResponse.json({
token,
participantId,
role: isHost ? "host" : "participant",
eventTitle: event.title,
});
} catch (error) {
console.error("meeting join failed", error);
return NextResponse.json({ error: "Der Beitritt ist gerade nicht möglich. Bitte versuche es später erneut." }, { status: 500 });
}
}
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
let body: { name?: unknown; email?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const email = typeof body.email === "string" ? body.email.trim() : "";
if (!name || !email || !email.includes("@")) {
return NextResponse.json({ error: "Bitte gib deinen Namen und eine gültige E-Mail-Adresse an." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "events",
where: { slug: { equals: slug } },
limit: 1,
});
const event = docs[0];
if (!event) {
return NextResponse.json({ error: "Dieser Termin wurde nicht gefunden." }, { status: 404 });
}
const { docs: existingForEmail } = await payload.find({
collection: "event-registrations",
where: {
and: [
{ event: { equals: event.id } },
{ email: { equals: email } },
{ status: { equals: "registered" } },
],
},
limit: 1,
});
if (existingForEmail[0]) {
return NextResponse.json({ ok: true, alreadyRegistered: true });
}
if (event.maxParticipants) {
const { totalDocs } = await payload.count({
collection: "event-registrations",
where: { and: [{ event: { equals: event.id } }, { status: { equals: "registered" } }] },
});
if (totalDocs >= event.maxParticipants) {
return NextResponse.json({ error: "Dieser Termin ist bereits ausgebucht." }, { status: 409 });
}
}
await payload.create({
collection: "event-registrations",
data: { event: event.id, name, email, status: "registered" },
});
return NextResponse.json({ ok: true });
} catch (error) {
console.error("event registration failed", error);
return NextResponse.json({ error: "Die Anmeldung ist gerade nicht möglich. Bitte versuche es später erneut." }, { status: 500 });
}
}