- 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
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
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 },
|
||
},
|
||
],
|
||
};
|