diff --git a/packages/features/bookings/Booker/components/hooks/useSlots.ts b/packages/features/bookings/Booker/components/hooks/useSlots.ts index faebef50984eec..1519e3550d722b 100644 --- a/packages/features/bookings/Booker/components/hooks/useSlots.ts +++ b/packages/features/bookings/Booker/components/hooks/useSlots.ts @@ -90,7 +90,10 @@ export const useSlots = (event: { id: number; length: number } | null) => { ], shallow ); - const [slotReservationId, setSlotReservationId] = useSlotReservationId(); + const [slotReservationId, setSlotReservationId] = useBookerStoreContext( + (state) => [state.slotReservationId, state.setSlotReservationId], + shallow + ); const reserveSlotMutation = trpc.viewer.slots.reserveSlot.useMutation({ trpc: { context: { diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 39d97e5b405b67..0da6de8eab162a 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -154,6 +154,13 @@ export type BookerStore = { */ formValues: Record; setFormValues: (values: Record) => void; + /** + * UID of the reserved slot. Used to prevent double-bookings by + * validating reservation ownership before creating a booking. + * Set when a slot is reserved, cleared after successful booking. + */ + slotReservationId: string | null; + setSlotReservationId: (slotReservationId: string | null) => void; /** * Force event being a team event, so we only query for team events instead * of also include 'user' events and return the first event that matches with @@ -437,6 +444,10 @@ export const createBookerStore = () => setFormValues: (formValues: Record) => { set({ formValues }); }, + slotReservationId: null, + setSlotReservationId: (slotReservationId: string | null) => { + set({ slotReservationId }); + }, org: null, setOrg: (org: string | null | undefined) => { set({ org }); diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index caadc7c3e9d6b4..cf96bf325f84a1 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -32,6 +32,7 @@ export type BookingOptions = { routingFormSearchParams?: RoutingFormSearchParams; isDryRunProp?: boolean; verificationCode?: string; + reservedSlotUid?: string; }; export const mapBookingToMutationInput = ({ @@ -56,6 +57,7 @@ export const mapBookingToMutationInput = ({ routingFormSearchParams, isDryRunProp, verificationCode, + reservedSlotUid, }: BookingOptions): BookingCreateBody => { const searchParams = new URLSearchParams(routingFormSearchParams ?? window.location.search); const routedTeamMemberIds = getRoutedTeamMemberIdsFromSearchParams(searchParams); @@ -101,6 +103,7 @@ export const mapBookingToMutationInput = ({ _shouldServeCache, dub_id, verificationCode, + reservedSlotUid, }; }; diff --git a/packages/features/bookings/lib/bookingCreateBodySchema.ts b/packages/features/bookings/lib/bookingCreateBodySchema.ts index 9da43a92398edd..f1b6c7f247870d 100644 --- a/packages/features/bookings/lib/bookingCreateBodySchema.ts +++ b/packages/features/bookings/lib/bookingCreateBodySchema.ts @@ -53,6 +53,7 @@ export const bookingCreateBodySchema = z.object({ dub_id: z.string().nullish(), creationSource: z.nativeEnum(CreationSource).optional(), verificationCode: z.string().optional(), + reservedSlotUid: z.string().optional(), }); export type BookingCreateBody = z.input; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index d473c146dd7b1f..6ce4652510dc27 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -62,6 +62,7 @@ import logger from "@calcom/lib/logger"; import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; +import { PrismaSelectedSlotRepository } from "@calcom/lib/server/repository/PrismaSelectedSlotRepository"; import { BookingRepository } from "@calcom/lib/server/repository/booking"; import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService"; @@ -485,6 +486,7 @@ async function handler( routingFormResponseId, _isDryRun: isDryRun = false, _shouldServeCache, + reservedSlotUid, ...reqBody } = bookingData; @@ -839,6 +841,55 @@ async function handler( } if (!input.bookingData.allRecurringDates || input.bookingData.isFirstRecurringSlot) { + // Check for reserved slots - if reservedSlotUid is provided, verify this booking owns the reservation + // If no reservedSlotUid but slot is reserved by someone else, reject the booking + if (!isDryRun) { + const slotsRepo = new PrismaSelectedSlotRepository(prisma); + const slot = { + utcStartIso: dayjs(reqBody.start).utc().toISOString(), + utcEndIso: dayjs(reqBody.end).utc().toISOString(), + }; + + if (reservedSlotUid) { + // Check if this reservation still exists and belongs to this user + const reservation = await slotsRepo.findReservedByOthers({ + slot, + eventTypeId, + uid: reservedSlotUid, + }); + + if (reservation) { + loggerWithEventDetails.error("Slot is reserved by someone else", { + reservedSlotUid, + eventTypeId, + slot, + }); + throw new HttpError({ + statusCode: 409, + message: "selected_timeslot_unavailable", + }); + } + } else { + // Check if slot is reserved by anyone (for bookings without a reservation) + const reservation = await slotsRepo.findReservedByOthers({ + slot, + eventTypeId, + uid: "dummy-uid-to-check-all-reservations", + }); + + if (reservation) { + loggerWithEventDetails.error("Slot is reserved by another user", { + eventTypeId, + slot, + }); + throw new HttpError({ + statusCode: 409, + message: "selected_timeslot_unavailable", + }); + } + } + } + try { if (!skipAvailabilityCheck) { availableUsers = await ensureAvailableUsers( @@ -1590,6 +1641,30 @@ async function handler( } } + // Clean up reserved slot after successful booking creation + if (reservedSlotUid && booking) { + try { + await prisma.selectedSlots.deleteMany({ + where: { + uid: reservedSlotUid, + eventTypeId, + slotUtcStartDate: dayjs(reqBody.start).utc().toDate(), + slotUtcEndDate: dayjs(reqBody.end).utc().toDate(), + }, + }); + loggerWithEventDetails.debug("Reserved slot deleted after booking creation", { + reservedSlotUid, + bookingUid: booking.uid, + }); + } catch (error) { + // Log but don't fail the booking if slot cleanup fails + loggerWithEventDetails.warn("Failed to clean up reserved slot after booking creation", { + reservedSlotUid, + error, + }); + } + } + const updatedEvtWithUid = CalendarEventBuilder.fromEvent(evt) ?.withUid(booking.uid ?? null) .build(); diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index 8260e05cb86cfc..ff1d1c55f7c751 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -64,6 +64,7 @@ export const useHandleBookEvent = ({ const crmAppSlug = useBookerStoreContext((state) => state.crmAppSlug); const crmRecordId = useBookerStoreContext((state) => state.crmRecordId); const verificationCode = useBookerStoreContext((state) => state.verificationCode); + const slotReservationId = useBookerStoreContext((state) => state.slotReservationId); const handleError = (err: any) => { const errorMessage = err?.message ? t(err.message) : t("can_you_try_again"); showToast(errorMessage, "error"); @@ -115,6 +116,7 @@ export const useHandleBookEvent = ({ routingFormSearchParams, isDryRunProp: isBookingDryRun, verificationCode: verificationCode || undefined, + reservedSlotUid: slotReservationId || undefined, }; const tracking = getUtmTrackingParameters(searchParams); diff --git a/packages/platform/atoms/hooks/useSlots.ts b/packages/platform/atoms/hooks/useSlots.ts index 005fad29b353d4..04b717b07d39f5 100644 --- a/packages/platform/atoms/hooks/useSlots.ts +++ b/packages/platform/atoms/hooks/useSlots.ts @@ -42,7 +42,10 @@ export const useSlots = ( shallow ); - const [slotReservationId, setSlotReservationId] = useSlotReservationId(); + const [slotReservationId, setSlotReservationId] = useBookerStoreContext( + (state) => [state.slotReservationId, state.setSlotReservationId], + shallow + ); const reserveSlotMutation = useReserveSlot({ onSuccess: (res) => {