diff --git a/packages/features/bookings/lib/handleNewBooking/createBooking.ts b/packages/features/bookings/lib/handleNewBooking/createBooking.ts index 2534ecde113a1e..eebc35ddc99200 100644 --- a/packages/features/bookings/lib/handleNewBooking/createBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/createBooking.ts @@ -6,6 +6,8 @@ import type { routingFormResponseInDbSchema } from "@calcom/app-store/routing-fo import dayjs from "@calcom/dayjs"; import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import { withReporting } from "@calcom/lib/sentryWrapper"; +import { HttpError } from "@calcom/lib/http-error"; +import { ErrorCode } from "@calcom/lib/errorCodes"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; import type { CreationSource } from "@calcom/prisma/enums"; @@ -72,6 +74,69 @@ async function getAssociatedBookingForFormResponse(formResponseId: number) { return formResponse?.routedToBookingUid ?? null; } +/** + * Check for overlapping bookings to prevent double bookings + */ +export async function checkForOverlappingBookings({ + eventTypeId, + startTime, + endTime, + rescheduleUid, +}: { + eventTypeId: number; + startTime: Date; + endTime: Date; + rescheduleUid?: string; +}) { + const overlappingBookings = await prisma.booking.findFirst({ + where: { + eventTypeId, + status: { + in: [BookingStatus.ACCEPTED, BookingStatus.PENDING], + }, + // Check for overlapping time ranges + OR: [ + // New booking starts during an existing booking + { + startTime: { lte: startTime }, + endTime: { gt: startTime }, + }, + // New booking ends during an existing booking + { + startTime: { lt: endTime }, + endTime: { gte: endTime }, + }, + // New booking completely contains an existing booking + { + startTime: { gte: startTime }, + endTime: { lte: endTime }, + }, + // New booking is completely contained within an existing booking + { + startTime: { lte: startTime }, + endTime: { gte: endTime }, + }, + ], + // Exclude the booking being rescheduled + ...(rescheduleUid && { uid: { not: rescheduleUid } }), + }, + select: { + id: true, + uid: true, + startTime: true, + endTime: true, + status: true, + }, + }); + + if (overlappingBookings) { + throw new HttpError({ + statusCode: 409, + message: ErrorCode.BookingConflict, + }); + } +} + // Define the function with underscore prefix const _createBooking = async ({ uid, @@ -173,6 +238,14 @@ async function saveBooking( * Reschedule(Cancellation + Creation) with an update of reroutingFormResponse should be atomic */ return prisma.$transaction(async (tx) => { + // Check for overlapping bookings before creating the new booking + await checkForOverlappingBookings({ + eventTypeId: newBookingData.eventTypeId!, + startTime: newBookingData.startTime, + endTime: newBookingData.endTime, + rescheduleUid: originalRescheduledBooking?.uid, + }); + if (originalBookingUpdateDataForCancellation) { await tx.booking.update(originalBookingUpdateDataForCancellation); } diff --git a/packages/features/bookings/lib/handleNewRecurringBooking.ts b/packages/features/bookings/lib/handleNewRecurringBooking.ts index e9db6bbb4965bc..19a6986bcb50b0 100644 --- a/packages/features/bookings/lib/handleNewRecurringBooking.ts +++ b/packages/features/bookings/lib/handleNewRecurringBooking.ts @@ -2,6 +2,10 @@ import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking"; import type { BookingResponse } from "@calcom/features/bookings/types"; import { SchedulingType } from "@calcom/prisma/client"; import type { AppsStatus } from "@calcom/types/Calendar"; +import { HttpError } from "@calcom/lib/http-error"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; export type PlatformParams = { platformClientId?: string; @@ -21,6 +25,74 @@ export type BookingHandlerInput = { noEmail?: boolean; } & PlatformParams; +/** + * Check for overlapping bookings across all recurring dates + */ +async function checkForOverlappingRecurringBookings({ + eventTypeId, + recurringDates, + rescheduleUid, +}: { + eventTypeId: number; + recurringDates: { start: string | undefined; end: string | undefined }[]; + rescheduleUid?: string; +}) { + for (const date of recurringDates) { + if (!date.start || !date.end) continue; + + const startTime = new Date(date.start); + const endTime = new Date(date.end); + + const overlappingBookings = await prisma.booking.findFirst({ + where: { + eventTypeId, + status: { + in: [BookingStatus.ACCEPTED, BookingStatus.PENDING], + }, + // Check for overlapping time ranges + OR: [ + // New booking starts during an existing booking + { + startTime: { lte: startTime }, + endTime: { gt: startTime }, + }, + // New booking ends during an existing booking + { + startTime: { lt: endTime }, + endTime: { gte: endTime }, + }, + // New booking completely contains an existing booking + { + startTime: { gte: startTime }, + endTime: { lte: endTime }, + }, + // New booking is completely contained within an existing booking + { + startTime: { lte: startTime }, + endTime: { gte: endTime }, + }, + ], + // Exclude the booking being rescheduled + ...(rescheduleUid && { uid: { not: rescheduleUid } }), + }, + select: { + id: true, + uid: true, + startTime: true, + endTime: true, + status: true, + }, + }); + + if (overlappingBookings) { + throw new HttpError({ + statusCode: 409, + message: ErrorCode.BookingConflict, + }); + } + } +} + export const handleNewRecurringBooking = async (input: BookingHandlerInput): Promise => { const data = input.bookingData; const createdBookings: BookingResponse[] = []; @@ -49,6 +121,13 @@ export const handleNewRecurringBooking = async (input: BookingHandlerInput): Pro areCalendarEventsEnabled: input.areCalendarEventsEnabled, }; + // Check for overlapping bookings before processing any recurring bookings + await checkForOverlappingRecurringBookings({ + eventTypeId: firstBooking.eventTypeId, + recurringDates: allRecurringDates, + rescheduleUid: firstBooking.rescheduleUid, + }); + if (isRoundRobin) { const recurringEventData = { ...firstBooking,