Files
anouma/collections/BookingRequests.ts
T
maro 5d83c0dc1e Add calendar-based booking system: accounts, availability, admin calendar
- Customer accounts (separate auth collection) with /konto area
- Availability + AvailabilityOverrides collections driving real slot calculation
- BookingRequests with race-safe confirmation (Postgres advisory lock + transaction)
- Booking emails (request received, admin notify, confirmed, rejected, alternative proposed/accepted, cancelled)
- Confirmed online bookings auto-create a private linked video-call Event
- Custom Payload admin calendar view (month grid + day schedule)
- Wired booking widget into offer pages and /termin-buchen
2026-08-25 16:52:34 +02:00

418 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { APIError } from "payload";
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } 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 }) => {
// Covers both paths: an existing request being confirmed, AND Anna
// manually creating a booking that's already "confirmed" from the start
// (e.g. a phone booking) — both must go through the same conflict check.
const wasAlreadyConfirmed = originalDoc?.status === "confirmed";
if (data.status !== "confirmed" || wasAlreadyConfirmed) {
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;
if (!date || !startTime || !endTime) return data; // Field-level validation will reject this anyway.
await acquireBookingDayLock(req, dateKey(date));
const dayStart = new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString();
const dayEnd = new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString();
const conflictClauses: Where[] = [
{ status: { equals: "confirmed" } },
{ date: { greater_than_equal: dayStart } },
{ date: { less_than_equal: dayEnd } },
];
if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
const { docs: sameDayConfirmed } = await req.payload.find({
collection: "booking-requests",
where: { and: conflictClauses },
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,
// Only set on the update-to-confirmed path — on create-as-confirmed
// this booking doesn't have an id yet (assigned after insert), so
// the back-reference is simply left blank in that one case.
...(originalDoc?.id ? { 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`;
// A brand-new request (the normal customer-initiated path) always gets
// the "received" + admin-notification pair. A booking Anna creates
// manually as already-confirmed/rejected (operation 26: manual bookings)
// skips this and falls through to the same status-based emails below.
if (operation === "create" && doc.status === "pending") {
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 = operation === "create" || 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 },
},
],
};