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
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { APIError } from "payload";
|
||||
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
||||
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload";
|
||||
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||
import { acquireBookingDayLock } from "@/lib/booking/lock";
|
||||
import {
|
||||
@@ -53,28 +53,35 @@ async function notifyAdmins(
|
||||
// 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") {
|
||||
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;
|
||||
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: [
|
||||
{ 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() } },
|
||||
],
|
||||
},
|
||||
where: { and: conflictClauses },
|
||||
req,
|
||||
limit: 200,
|
||||
});
|
||||
@@ -96,8 +103,8 @@ const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDo
|
||||
);
|
||||
}
|
||||
|
||||
if (appointmentType === "online" && !data.linkedEvent && !originalDoc.linkedEvent) {
|
||||
const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc.offer, req })) as Offer;
|
||||
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,
|
||||
@@ -109,7 +116,10 @@ const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDo
|
||||
category: "sonstiges",
|
||||
isOnline: true, // Events' own beforeChange hook auto-generates the password.
|
||||
isPrivateBooking: true,
|
||||
bookingRequest: originalDoc.id,
|
||||
// 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
|
||||
@@ -139,7 +149,11 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
||||
const adminUrl = `${serverUrl}/admin/collections/booking-requests/${doc.id}`;
|
||||
const accountUrl = `${serverUrl}/konto/termine`;
|
||||
|
||||
if (operation === "create") {
|
||||
// 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({
|
||||
@@ -165,7 +179,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
||||
return doc;
|
||||
}
|
||||
|
||||
const statusChanged = previousDoc?.status !== doc.status;
|
||||
const statusChanged = operation === "create" || previousDoc?.status !== doc.status;
|
||||
|
||||
if (statusChanged && doc.status === "confirmed") {
|
||||
let online: { joinUrl: string; password: string } | undefined;
|
||||
|
||||
Reference in New Issue
Block a user