fix: harden booking and meeting flows

This commit is contained in:
2026-09-17 02:42:27 +02:00
parent cb30ecade9
commit 66710fc65c
42 changed files with 683 additions and 608 deletions
+25
View File
@@ -0,0 +1,25 @@
import type { BookingRequest } from "@/payload-types";
/** Explicit allow-list at the server/client boundary, including relationships. */
export function customerBookingView(booking: BookingRequest) {
const offer = typeof booking.offer === "object" ? booking.offer : null;
const event = typeof booking.linkedEvent === "object" ? booking.linkedEvent : null;
const alternative = booking.proposedAlternative;
return {
id: booking.id,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
status: booking.status,
appointmentType: booking.appointmentType,
offer: offer ? { title: offer.title } : null,
linkedEvent: event ? { slug: event.slug, isOnline: event.isOnline } : null,
proposedAlternative: alternative ? {
date: alternative.date,
startTime: alternative.startTime,
endTime: alternative.endTime,
} : null,
};
}
export type CustomerBooking = ReturnType<typeof customerBookingView>;
+3 -2
View File
@@ -13,13 +13,14 @@ import type { PayloadRequest } from "payload";
*/
export async function acquireBookingDayLock(req: PayloadRequest, dateKey: string): Promise<void> {
const txID = req.transactionID ? String(await req.transactionID) : undefined;
if (!txID) return; // No active transaction — nothing to lock against.
if (!txID) throw new Error("A booking lock requires an active transaction");
const adapter = req.payload.db as unknown as {
sessions?: Record<string, { db: { execute: (query: unknown) => Promise<unknown> } }>;
drizzle: { execute: (query: unknown) => Promise<unknown> };
};
const tx = adapter.sessions?.[txID]?.db ?? adapter.drizzle;
const tx = adapter.sessions?.[txID]?.db;
if (!tx) throw new Error("Booking transaction not found");
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${dateKey}))`);
}
+5 -4
View File
@@ -1,8 +1,9 @@
import type { BookingRequest, Event, MeetingSetting } from "@/payload-types";
import type { MeetingSetting } from "@/payload-types";
import type { CustomerBooking } from "./customerView";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus, type MeetingStatus } from "@/lib/meeting/status";
export function getBookingMeetingStatus(booking: BookingRequest, settings: MeetingSetting): MeetingStatus | null {
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
export function getBookingMeetingStatus(booking: CustomerBooking, settings: MeetingSetting): MeetingStatus | null {
const event = booking.linkedEvent;
if (booking.status !== "confirmed" || booking.appointmentType !== "online" || !event?.isOnline) return null;
const start = combineDateAndTime(booking.date, booking.startTime);
@@ -17,7 +18,7 @@ export function getBookingMeetingStatus(booking: BookingRequest, settings: Meeti
});
}
export function canJoinBookingMeeting(booking: BookingRequest, settings: MeetingSetting): boolean {
export function canJoinBookingMeeting(booking: CustomerBooking, settings: MeetingSetting): boolean {
const status = getBookingMeetingStatus(booking, settings);
return status !== null && canJoinMeeting(status);
}
+5 -5
View File
@@ -1,13 +1,13 @@
import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload";
import type { BookingRequest } from "@/payload-types";
import { customerBookingView, type CustomerBooking } from "./customerView";
/**
* These are called only after the caller has already verified who the
* current customer is (see lib/auth/customer.ts) — the `where: { user }`
* filter below is what actually scopes results to that one customer.
*/
export const getCustomerBookings = cache(async (customerId: number): Promise<BookingRequest[]> => {
export const getCustomerBookings = cache(async (customerId: number): Promise<CustomerBooking[]> => {
const payload = await getCMS();
const result = await payload.find({
collection: "booking-requests",
@@ -16,10 +16,10 @@ export const getCustomerBookings = cache(async (customerId: number): Promise<Boo
depth: 2,
limit: 200,
});
return result.docs;
return result.docs.map(customerBookingView);
});
export async function getCustomerBookingById(customerId: number, id: number): Promise<BookingRequest | null> {
export async function getCustomerBookingById(customerId: number, id: number): Promise<CustomerBooking | null> {
const payload = await getCMS();
const result = await payload.find({
collection: "booking-requests",
@@ -27,5 +27,5 @@ export async function getCustomerBookingById(customerId: number, id: number): Pr
depth: 2,
limit: 1,
});
return result.docs[0] ?? null;
return result.docs[0] ? customerBookingView(result.docs[0]) : null;
}
+11 -8
View File
@@ -1,4 +1,4 @@
import type { Payload } from "payload";
import type { Payload, PayloadRequest } from "payload";
import { WEEKDAYS } from "@/collections/Availability";
import { berlinParts, berlinWallTimeToUTC, type CalendarDate } from "@/lib/calendar/timezone";
@@ -63,17 +63,20 @@ function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
*/
export async function getAvailableSlots(
payload: Payload,
args: { durationMinutes: number; from: Date; to: Date },
args: { durationMinutes: number; from: Date; to: Date; req?: PayloadRequest },
): Promise<Map<string, TimeSlot[]>> {
const { durationMinutes, from, to } = args;
const { durationMinutes, from, to, req } = args;
const result = new Map<string, TimeSlot[]>();
if (!Number.isInteger(durationMinutes) || durationMinutes <= 0 || durationMinutes > 1440 ||
!Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || to < from ||
to.getTime() - from.getTime() > 63 * 86400000) return result;
const [{ docs: weekly }, { docs: overrides }, { docs: busyBookings }] = await Promise.all([
payload.find({ collection: "availability", where: { active: { equals: true } }, limit: 100 }),
payload.find({ collection: "availability", where: { active: { equals: true } }, pagination: false, req }),
payload.find({
collection: "availability-overrides",
where: { and: [{ date: { greater_than_equal: from.toISOString() } }, { date: { less_than_equal: to.toISOString() } }] },
limit: 200,
pagination: false, req,
}),
payload.find({
collection: "booking-requests",
@@ -84,7 +87,7 @@ export async function getAvailableSlots(
{ date: { less_than_equal: to.toISOString() } },
],
},
limit: 500,
pagination: false, req,
}),
]);
@@ -131,11 +134,11 @@ export async function getAvailableSlots(
const slotEnd = atMinutesOfDay(day, start + durationMinutes);
if (slotStart < now) continue;
if (dayBusy.some((b) => overlaps(slotStart, slotEnd, b.start, b.end))) continue;
slots.push({ start: slotStart, end: slotEnd });
if (!slots.some((slot) => slot.start.getTime() === slotStart.getTime())) slots.push({ start: slotStart, end: slotEnd });
}
}
if (slots.length > 0) result.set(key, slots);
if (slots.length > 0) result.set(key, slots.sort((a, b) => a.start.getTime() - b.start.getTime()));
}
return result;
+17
View File
@@ -0,0 +1,17 @@
import { commitTransaction, createLocalReq, initTransaction, killTransaction, type Payload, type PayloadRequest } from "payload";
import { acquireBookingDayLock } from "./lock";
export async function withBookingTransaction<T>(payload: Payload, key: string, action: (req: PayloadRequest) => Promise<T>): Promise<T> {
const req = await createLocalReq({}, payload);
try {
if (!await initTransaction(req)) throw new Error("Booking transaction unavailable");
await acquireBookingDayLock(req, "booking-calendar");
await acquireBookingDayLock(req, key);
const result = await action(req);
await commitTransaction(req);
return result;
} catch (error) {
await killTransaction(req);
throw error;
}
}