Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Comment on lines +241 to +247
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

LGTM! Proper integration into the transaction flow.

The overlap check is correctly placed at the beginning of the transaction before any database modifications. The parameters are properly extracted from the booking data.

Verify that eventTypeId is always non-null at this point to ensure the non-null assertion is safe:


🏁 Script executed:

#!/bin/bash
# Description: Check if eventTypeId can be null in buildNewBookingData function
# Expected: eventTypeId should always be set when this function is called

ast-grep --pattern $'function buildNewBookingData($$$) {
  $$$
  eventType: eventTypeRel,
  $$$
}'

Length of output: 99


🏁 Script executed:

#!/bin/bash
set -e

echo "🔍 Locating newBookingData definition in createBooking.ts"
rg -n -C5 "newBookingData" packages/features/bookings/lib/handleNewBooking/createBooking.ts

echo ""
echo "🔍 Searching for the NewBookingData type/interface declaration in the repo"
rg -n -C3 "NewBookingData" -g "*.ts"

Length of output: 6345


🏁 Script executed:

#!/bin/bash
# Extract buildNewBookingData including eventTypeId assignments
sed -n '280,360p' packages/features/bookings/lib/handleNewBooking/createBooking.ts

Length of output: 2792


Ensure the correct eventTypeId is used for overlap checking

The current call uses newBookingData.eventTypeId!, but buildNewBookingData never assigns an eventTypeId property—so it will always be undefined at runtime. Please update the overlap check to use the actual eventType.id from the function parameters (or explicitly add eventTypeId to newBookingData).

Suggested change:

--- a/packages/features/bookings/lib/handleNewBooking/createBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking/createBooking.ts
@@ 241,247c241,245
-    await checkForOverlappingBookings({
-      eventTypeId: newBookingData.eventTypeId!,
+    await checkForOverlappingBookings({
+      eventTypeId: eventType.id,
       startTime: newBookingData.startTime,
       endTime: newBookingData.endTime,
       rescheduleUid: originalRescheduledBooking?.uid,

• File: packages/features/bookings/lib/handleNewBooking/createBooking.ts
• Lines: 241–247

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check for overlapping bookings before creating the new booking
await checkForOverlappingBookings({
eventTypeId: newBookingData.eventTypeId!,
startTime: newBookingData.startTime,
endTime: newBookingData.endTime,
rescheduleUid: originalRescheduledBooking?.uid,
});
// Check for overlapping bookings before creating the new booking
await checkForOverlappingBookings({
eventTypeId: eventType.id,
startTime: newBookingData.startTime,
endTime: newBookingData.endTime,
rescheduleUid: originalRescheduledBooking?.uid,
});
🤖 Prompt for AI Agents
In packages/features/bookings/lib/handleNewBooking/createBooking.ts around lines
241 to 247, the overlap check uses newBookingData.eventTypeId which is never set
and thus undefined. Fix this by replacing newBookingData.eventTypeId with the
correct eventType.id parameter value when calling checkForOverlappingBookings,
ensuring the actual event type ID is used for the overlap check.


if (originalBookingUpdateDataForCancellation) {
await tx.booking.update(originalBookingUpdateDataForCancellation);
}
Expand Down
79 changes: 79 additions & 0 deletions packages/features/bookings/lib/handleNewRecurringBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
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;
Expand All @@ -21,6 +25,74 @@
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({

Check failure on line 88 in packages/features/bookings/lib/handleNewRecurringBooking.ts

View workflow job for this annotation

GitHub Actions / Tests / Unit

packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts > handleNewBooking > Recurring EventType: > Round robin event type: > should create bookings that are all assigned to the least recently booked user that is available on the first two slots, if the least recently booked user is not available on the second slot

HttpError: booking_conflict_error ❯ checkForOverlappingRecurringBookings packages/features/bookings/lib/handleNewRecurringBooking.ts:88:13 ❯ Module.handleNewRecurringBooking packages/features/bookings/lib/handleNewRecurringBooking.ts:125:3 ❯ handler apps/web/pages/api/book/recurring-event.ts:45:46 ❯ packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts:1394:34 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { statusCode: 409, url: undefined, method: undefined, data: undefined }

Check failure on line 88 in packages/features/bookings/lib/handleNewRecurringBooking.ts

View workflow job for this annotation

GitHub Actions / Tests / Unit

packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts > handleNewBooking > Recurring EventType: > User event type: > should create successful bookings for the number of slots requested even if the last slot is already booked as long as first two slots are free 1. Should create the same number of bookings as requested slots in the database 2. Should send emails for the first booking only to the booker as well as organizer 3. Should create a calendar event for every booking in the destination calendar 3. Should trigger BOOKING_CREATED webhook for every booking

HttpError: booking_conflict_error ❯ checkForOverlappingRecurringBookings packages/features/bookings/lib/handleNewRecurringBooking.ts:88:13 ❯ Module.handleNewRecurringBooking packages/features/bookings/lib/handleNewRecurringBooking.ts:125:3 ❯ handler apps/web/pages/api/book/recurring-event.ts:45:46 ❯ packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts:730:35 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { statusCode: 409, url: undefined, method: undefined, data: undefined }

Check failure on line 88 in packages/features/bookings/lib/handleNewRecurringBooking.ts

View workflow job for this annotation

GitHub Actions / Tests / Unit

packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts > handleNewBooking > Recurring EventType: > User event type: > should create successful bookings for the number of slots requested even if the third slot is already booked as long as first two slots are free 1. Should create the same number of bookings as requested slots in the database 2. Should send emails for the first booking only to the booker as well as organizer 3. Should create a calendar event for every booking in the destination calendar 3. Should trigger BOOKING_CREATED webhook for every booking

HttpError: booking_conflict_error ❯ checkForOverlappingRecurringBookings packages/features/bookings/lib/handleNewRecurringBooking.ts:88:13 ❯ Module.handleNewRecurringBooking packages/features/bookings/lib/handleNewRecurringBooking.ts:125:3 ❯ handler apps/web/pages/api/book/recurring-event.ts:45:46 ❯ packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts:511:35 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { statusCode: 409, url: undefined, method: undefined, data: undefined }
statusCode: 409,
message: ErrorCode.BookingConflict,
});
}
}
}
Comment on lines +28 to +94
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Optimize database queries for better performance.

While the logic is correct, the current implementation makes a separate database query for each recurring date, which could be inefficient for bookings with many recurrences.

Consider optimizing with a single query that checks all dates at once:

-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: [
-          // ... overlap conditions
-        ],
-        // 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,
-      });
-    }
-  }
-}
+async function checkForOverlappingRecurringBookings({
+  eventTypeId,
+  recurringDates,
+  rescheduleUid,
+}: {
+  eventTypeId: number;
+  recurringDates: { start: string | undefined; end: string | undefined }[];
+  rescheduleUid?: string;
+}) {
+  const validDates = recurringDates
+    .filter(date => date.start && date.end)
+    .map(date => ({
+      startTime: new Date(date.start!),
+      endTime: new Date(date.end!)
+    }));
+
+  if (validDates.length === 0) return;
+
+  const dateConditions = validDates.flatMap(({ startTime, endTime }) => [
+    { startTime: { lte: startTime }, endTime: { gt: startTime } },
+    { startTime: { lt: endTime }, endTime: { gte: endTime } },
+    { startTime: { gte: startTime }, endTime: { lte: endTime } },
+    { startTime: { lte: startTime }, endTime: { gte: endTime } },
+  ]);
+
+  const overlappingBookings = await prisma.booking.findFirst({
+    where: {
+      eventTypeId,
+      status: {
+        in: [BookingStatus.ACCEPTED, BookingStatus.PENDING],
+      },
+      OR: dateConditions,
+      ...(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,
+    });
+  }
+}
🤖 Prompt for AI Agents
In packages/features/bookings/lib/handleNewRecurringBooking.ts between lines 28
and 94, the function currently performs a separate database query for each
recurring date to check for overlapping bookings, which is inefficient. Refactor
the code to consolidate all recurring date checks into a single Prisma query by
constructing a combined OR condition that includes all date ranges at once. This
will reduce the number of database calls and improve performance for bookings
with many recurrences.


export const handleNewRecurringBooking = async (input: BookingHandlerInput): Promise<BookingResponse[]> => {
const data = input.bookingData;
const createdBookings: BookingResponse[] = [];
Expand Down Expand Up @@ -49,6 +121,13 @@
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,
Expand Down
Loading