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
+403
View File
@@ -0,0 +1,403 @@
import { APIError } from "payload";
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdmin, isAdminFieldLevel } from "@/access";
import { acquireBookingDayLock } from "@/lib/booking/lock";
import {
alternativeProposedEmail,
bookingCancelledAdminEmail,
bookingConfirmedEmail,
bookingRejectedEmail,
bookingRequestReceivedEmail,
newBookingRequestAdminEmail,
} from "@/lib/email/bookingTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
import type { Customer, Event, Offer } from "@/payload-types";
export const APPOINTMENT_TYPES = [
{ label: "Vor Ort", value: "onsite" },
{ label: "Online", value: "online" },
] as const;
export const BOOKING_STATUSES = [
{ label: "Ausstehend", value: "pending" },
{ label: "Bestätigt", value: "confirmed" },
{ label: "Abgelehnt", value: "rejected" },
{ label: "Storniert", value: "cancelled" },
] as const;
function dateKey(iso: string): string {
return new Date(iso).toISOString().slice(0, 10);
}
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
const appointmentTypeLabel = (value: string) =>
APPOINTMENT_TYPES.find((t) => t.value === value)?.label ?? value;
async function notifyAdmins(
req: Parameters<CollectionAfterChangeHook>[0]["req"],
template: { subject: string; html: string },
) {
const { docs: admins } = await req.payload.find({ collection: "users", limit: 50, req });
await Promise.all(admins.filter((admin) => admin.email).map((admin) => sendEmail(admin.email, template)));
}
// Runs whenever a booking is about to become "confirmed": locks the day
// (see lib/booking/lock.ts), re-checks for overlapping confirmed bookings
// inside that same lock/transaction, and — for online appointments — creates
// the linked video-call Event (reusing the existing meeting/password system)
// before the booking itself is written.
const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req, operation }) => {
if (operation !== "update" || data.status !== "confirmed" || originalDoc?.status === "confirmed") {
return data;
}
const date = data.date ?? originalDoc.date;
const startTime = data.startTime ?? originalDoc.startTime;
const endTime = data.endTime ?? originalDoc.endTime;
const appointmentType = data.appointmentType ?? originalDoc.appointmentType;
await acquireBookingDayLock(req, dateKey(date));
const { docs: sameDayConfirmed } = await req.payload.find({
collection: "booking-requests",
where: {
and: [
{ status: { equals: "confirmed" } },
{ id: { not_equals: originalDoc.id } },
{ date: { greater_than_equal: new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString() } },
{ date: { less_than_equal: new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString() } },
],
},
req,
limit: 200,
});
const newStart = new Date(startTime).getTime();
const newEnd = new Date(endTime).getTime();
const conflict = sameDayConfirmed.some((doc) => {
const s = new Date(doc.startTime).getTime();
const e = new Date(doc.endTime).getTime();
return newStart < e && s < newEnd;
});
if (conflict) {
throw new APIError(
"Dieser Zeitraum ist bereits mit einem anderen bestätigten Termin belegt. Bitte wähle eine andere Zeit oder lehne den überschneidenden Termin zuerst ab.",
409,
undefined,
true,
);
}
if (appointmentType === "online" && !data.linkedEvent && !originalDoc.linkedEvent) {
const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc.offer, req })) as Offer;
const event = (await req.payload.create({
collection: "events",
req,
data: {
title: offer.title,
date,
startTime,
endTime,
category: "sonstiges",
isOnline: true, // Events' own beforeChange hook auto-generates the password.
isPrivateBooking: true,
bookingRequest: originalDoc.id,
},
// Created as a draft so it never appears in public /termine listings
// (see lib/payload/content.ts) — it's still reachable via its direct
// join link, which is exactly what a private 1:1 booking needs.
draft: true,
})) as Event;
data.linkedEvent = event.id;
}
return data;
};
const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, operation, req }) => {
try {
const user = (doc.user && typeof doc.user === "object" ? doc.user : await req.payload.findByID({
collection: "customers",
id: doc.user,
req,
})) as Customer;
const offer = (doc.offer && typeof doc.offer === "object" ? doc.offer : await req.payload.findByID({
collection: "offers",
id: doc.offer,
req,
})) as Offer;
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
const adminUrl = `${serverUrl}/admin/collections/booking-requests/${doc.id}`;
const accountUrl = `${serverUrl}/konto/termine`;
if (operation === "create") {
await sendEmail(
user.email,
bookingRequestReceivedEmail({
name: user.name,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: fmtTime(doc.startTime),
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
}),
);
await notifyAdmins(
req,
newBookingRequestAdminEmail({
customerName: user.name,
customerEmail: user.email,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: fmtTime(doc.startTime),
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
adminUrl,
}),
);
return doc;
}
const statusChanged = previousDoc?.status !== doc.status;
if (statusChanged && doc.status === "confirmed") {
let online: { joinUrl: string; password: string } | undefined;
let onsite: { address: string; mapsUrl: string } | undefined;
if (doc.appointmentType === "online" && doc.linkedEvent) {
const event = (typeof doc.linkedEvent === "object" ? doc.linkedEvent : await req.payload.findByID({
collection: "events",
id: doc.linkedEvent,
req,
})) as Event;
online = {
joinUrl: `${serverUrl}/termine/${event.slug}/beitreten`,
password: event.meetingPassword || "—",
};
} else if (doc.appointmentType === "onsite") {
const settings = await req.payload.findGlobal({ slug: "booking-settings", req });
const addressParts = [settings.locationName, settings.street, [settings.postalCode, settings.city].filter(Boolean).join(" ")].filter(Boolean);
const address = addressParts.join(", ");
onsite = { address, mapsUrl: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}` };
}
await sendEmail(
user.email,
bookingConfirmedEmail({
name: user.name,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: `${fmtTime(doc.startTime)} ${fmtTime(doc.endTime)}`,
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
online,
onsite,
}),
);
} else if (statusChanged && doc.status === "rejected") {
await sendEmail(
user.email,
bookingRejectedEmail({
name: user.name,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: fmtTime(doc.startTime),
}),
);
} else if (statusChanged && doc.status === "cancelled") {
await notifyAdmins(
req,
bookingCancelledAdminEmail({
customerName: user.name,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: fmtTime(doc.startTime),
adminUrl,
}),
);
}
const proposedNew = doc.proposedAlternative?.date && doc.proposedAlternative.date !== previousDoc?.proposedAlternative?.date;
if (proposedNew && doc.status === "pending") {
await sendEmail(
user.email,
alternativeProposedEmail({
name: user.name,
offerTitle: offer.title,
originalDateLabel: fmtDate(doc.date),
originalTimeLabel: fmtTime(doc.startTime),
altDateLabel: fmtDate(doc.proposedAlternative.date),
altTimeLabel: fmtTime(doc.proposedAlternative.startTime),
accountUrl,
}),
);
}
} catch (err) {
// Email delivery must never break the booking write itself.
req.payload.logger.error({ err, msg: "booking-requests notifyByEmail failed" });
}
return doc;
};
export const BookingRequests: CollectionConfig = {
slug: "booking-requests",
labels: {
singular: "Buchungsanfrage",
plural: "Buchungsanfragen",
},
admin: {
useAsTitle: "id",
defaultColumns: ["offer", "user", "date", "startTime", "status"],
group: "Buchungen",
description: "Terminanfragen von Nutzer:innen — bestätigen, ablehnen oder einen alternativen Termin vorschlagen, indem du die Felder unten änderst und speicherst.",
},
access: {
// Customer-initiated writes always go through the vetted /api/booking/*
// routes (Local API, elevated access, after the route verifies
// ownership) — never directly through REST/GraphQL. This keeps a
// customer from ever reading/writing another customer's booking, or
// setting their own status to "confirmed" directly.
create: isAdmin,
read: ({ req }) => {
if (req.user?.collection === "users") return true;
if (req.user?.collection === "customers") return { user: { equals: req.user.id } };
return false;
},
update: isAdmin,
delete: isAdmin,
},
hooks: {
beforeChange: [handleConfirmation],
afterChange: [notifyByEmail],
},
fields: [
{
name: "user",
type: "relationship",
relationTo: "customers",
label: "Nutzer:in",
required: true,
index: true,
},
{
name: "offer",
type: "relationship",
relationTo: "offers",
label: "Angebot",
required: true,
},
{
type: "row",
fields: [
{
name: "date",
type: "date",
label: "Datum",
required: true,
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, width: "34%" },
},
{
name: "startTime",
type: "date",
label: "Startzeit",
required: true,
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
},
{
name: "endTime",
type: "date",
label: "Endzeit",
required: true,
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
},
],
},
{
name: "appointmentType",
type: "select",
label: "Terminart",
required: true,
defaultValue: "onsite",
options: [...APPOINTMENT_TYPES],
},
{
name: "status",
type: "select",
label: "Status",
required: true,
defaultValue: "pending",
options: [...BOOKING_STATUSES],
admin: {
description: "Auf „Bestätigt“ setzen und speichern, um den Termin verbindlich zu machen (Konfliktprüfung läuft automatisch).",
},
},
{
name: "userMessage",
type: "textarea",
label: "Nachricht der Nutzerin/des Nutzers",
admin: { readOnly: true },
},
{
name: "internalNote",
type: "textarea",
label: "Interne Notiz",
access: { read: isAdminFieldLevel, update: isAdminFieldLevel },
admin: { description: "Nur für Anna sichtbar." },
},
{
type: "collapsible",
label: "Alternativer Termin",
fields: [
{
type: "row",
fields: [
{
name: "proposedAlternative",
type: "group",
label: "",
fields: [
{
type: "row",
fields: [
{
name: "date",
type: "date",
label: "Alternatives Datum",
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, width: "34%" },
},
{
name: "startTime",
type: "date",
label: "Alternative Startzeit",
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
},
{
name: "endTime",
type: "date",
label: "Alternative Endzeit",
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
},
],
},
],
},
],
},
],
},
{
name: "linkedEvent",
type: "relationship",
relationTo: "events",
label: "Verknüpfter Video-Termin",
admin: { position: "sidebar", readOnly: true },
},
],
};