fix: harden booking and meeting flows
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { APIError } from "payload";
|
||||
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig, Where } from "payload";
|
||||
import { isAdmin, isAdminFieldLevel } from "@/access";
|
||||
import { combineDateAndTime } from "@/lib/meeting/status";
|
||||
import { acquireBookingDayLock } from "@/lib/booking/lock";
|
||||
import {
|
||||
alternativeProposedEmail,
|
||||
@@ -12,7 +14,7 @@ import {
|
||||
} from "@/lib/email/bookingTemplates";
|
||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||
import type { Customer, Event, Offer } from "@/payload-types";
|
||||
import { berlinDateKey, berlinDayBounds, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||
import { berlinDateKey, formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||
|
||||
export const APPOINTMENT_TYPES = [
|
||||
{ label: "Vor Ort", value: "onsite" },
|
||||
@@ -49,88 +51,73 @@ async function notifyAdmins(
|
||||
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;
|
||||
}
|
||||
|
||||
// Normalize Payload time-only inputs before conflict checks and synchronize
|
||||
// the private meeting whenever a booking is moved, cancelled or reactivated.
|
||||
export const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req }) => {
|
||||
const status = data.status ?? originalDoc?.status ?? "pending";
|
||||
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.
|
||||
if (!date || !startTime || !endTime) return data;
|
||||
const start = combineDateAndTime(date, startTime);
|
||||
const end = combineDateAndTime(date, endTime);
|
||||
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start) {
|
||||
throw new APIError("Das Ende muss nach dem Beginn am selben Tag liegen.", 400, undefined, true);
|
||||
}
|
||||
data.date = start.toISOString();
|
||||
data.startTime = start.toISOString();
|
||||
data.endTime = end.toISOString();
|
||||
|
||||
await acquireBookingDayLock(req, dateKey(date));
|
||||
|
||||
const dayBounds = berlinDayBounds(new Date(date));
|
||||
const dayStart = dayBounds.start.toISOString();
|
||||
const dayEnd = dayBounds.end.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,
|
||||
);
|
||||
const alternative = data.proposedAlternative;
|
||||
if (alternative && (alternative.date || alternative.startTime || alternative.endTime)) {
|
||||
if (!alternative.date || !alternative.startTime || !alternative.endTime) {
|
||||
throw new APIError("Bitte den alternativen Termin vollständig angeben.", 400, undefined, true);
|
||||
}
|
||||
const altStart = combineDateAndTime(alternative.date, alternative.startTime);
|
||||
const altEnd = combineDateAndTime(alternative.date, alternative.endTime);
|
||||
if (!Number.isFinite(altStart.getTime()) || !Number.isFinite(altEnd.getTime()) || altEnd <= altStart) {
|
||||
throw new APIError("Der alternative Zeitraum ist ungültig.", 400, undefined, true);
|
||||
}
|
||||
data.proposedAlternative = { date: altStart.toISOString(), startTime: altStart.toISOString(), endTime: altEnd.toISOString() };
|
||||
}
|
||||
|
||||
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;
|
||||
if (status === "confirmed") {
|
||||
await acquireBookingDayLock(req, dateKey(data.date));
|
||||
const conflictClauses: Where[] = [
|
||||
{ status: { equals: "confirmed" } },
|
||||
{ startTime: { less_than: data.endTime } },
|
||||
{ endTime: { greater_than: data.startTime } },
|
||||
];
|
||||
if (originalDoc?.id) conflictClauses.push({ id: { not_equals: originalDoc.id } });
|
||||
const { totalDocs } = await req.payload.count({ collection: "booking-requests", where: { and: conflictClauses }, req });
|
||||
if (totalDocs) throw new APIError("Dieser Zeitraum ist bereits mit einem bestätigten Termin belegt.", 409, undefined, true);
|
||||
}
|
||||
|
||||
const linked = data.linkedEvent ?? originalDoc?.linkedEvent;
|
||||
const linkedId = typeof linked === "object" ? linked?.id : linked;
|
||||
if (linkedId) {
|
||||
const existing = await req.payload.findByID({ collection: "events", id: linkedId, depth: 0, req });
|
||||
if (existing.isPrivateBooking) {
|
||||
const isOnline = status === "confirmed" && appointmentType === "online";
|
||||
if (existing.date !== data.date || existing.startTime !== data.startTime || existing.endTime !== data.endTime || existing.isOnline !== isOnline) {
|
||||
await req.payload.update({ collection: "events", id: existing.id, req, data: {
|
||||
date: data.date, startTime: data.startTime, endTime: data.endTime, isOnline,
|
||||
} });
|
||||
}
|
||||
}
|
||||
} else if (status === "confirmed" && appointmentType === "online") {
|
||||
const relatedOffer = data.offer ?? originalDoc?.offer;
|
||||
const offer = await req.payload.findByID({ collection: "offers", id: typeof relatedOffer === "object" ? relatedOffer.id : relatedOffer, req });
|
||||
const event = await req.payload.create({ collection: "events", req, draft: true, data: {
|
||||
title: offer.title,
|
||||
slug: `buchung-${randomUUID()}`,
|
||||
date: data.date, startTime: data.startTime, endTime: data.endTime,
|
||||
category: "sonstiges", isOnline: true, isPrivateBooking: true,
|
||||
...(originalDoc?.id ? { bookingRequest: originalDoc.id } : {}),
|
||||
} });
|
||||
data.linkedEvent = event.id;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -183,7 +170,8 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
||||
|
||||
const statusChanged = operation === "create" || previousDoc?.status !== doc.status;
|
||||
|
||||
if (statusChanged && doc.status === "confirmed") {
|
||||
const scheduleChanged = previousDoc && ["date", "startTime", "endTime", "appointmentType"].some((field) => doc[field] !== previousDoc[field]);
|
||||
if ((statusChanged || scheduleChanged) && doc.status === "confirmed") {
|
||||
let online: { joinUrl: string; password: string } | undefined;
|
||||
let onsite: { address: string; mapsUrl: string } | undefined;
|
||||
|
||||
@@ -239,7 +227,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
|
||||
);
|
||||
}
|
||||
|
||||
const proposedNew = doc.proposedAlternative?.date && doc.proposedAlternative.date !== previousDoc?.proposedAlternative?.date;
|
||||
const proposedNew = doc.proposedAlternative?.date && JSON.stringify(doc.proposedAlternative) !== JSON.stringify(previousDoc?.proposedAlternative);
|
||||
if (proposedNew && doc.status === "pending") {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
@@ -282,7 +270,7 @@ export const BookingRequests: CollectionConfig = {
|
||||
// setting their own status to "confirmed" directly.
|
||||
create: isAdmin,
|
||||
read: ({ req }) => {
|
||||
if (req.user?.collection === "users") return true;
|
||||
if (req.user?.collection === "users") return req.user.role === "admin";
|
||||
if (req.user?.collection === "customers") return { user: { equals: req.user.id } };
|
||||
return false;
|
||||
},
|
||||
@@ -290,8 +278,19 @@ export const BookingRequests: CollectionConfig = {
|
||||
delete: isAdmin,
|
||||
},
|
||||
hooks: {
|
||||
beforeOperation: [async ({ args, operation, req }) => {
|
||||
if (operation === "create" || operation === "update" || operation === "delete") {
|
||||
await acquireBookingDayLock(req, "booking-calendar");
|
||||
}
|
||||
return args;
|
||||
}],
|
||||
beforeChange: [handleConfirmation],
|
||||
afterChange: [notifyByEmail],
|
||||
afterDelete: [async ({ doc, req }) => {
|
||||
const id = typeof doc.linkedEvent === "object" ? doc.linkedEvent?.id : doc.linkedEvent;
|
||||
if (id) await req.payload.update({ collection: "events", id, req, data: { isOnline: false } });
|
||||
return doc;
|
||||
}],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user