From 45698db0b461063534cf37f332fb90bca276b495 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Fri, 11 Jul 2025 17:47:21 +0200 Subject: [PATCH] fix: validate Round Robin host availability in handleNewBooking (#22253) * fix: validate Round Robin host availability in handleNewBooking - Add validation to ensure Round Robin events have at least one available non-fixed host - Throw NoAvailableUsersFound error when no Round Robin hosts are available - Add unit test to verify the fix works correctly - Fixes issue where Round Robin events with fixed hosts could be booked without Round Robin hosts Co-Authored-By: carina@cal.com * test: fix host configuration in Round Robin test Co-Authored-By: carina@cal.com * test: improve Round Robin test to only make RR host busy Co-Authored-By: carina@cal.com * fix: correct Round Robin validation to only check when RR hosts assigned Co-Authored-By: carina@cal.com * fix: refine Round Robin validation to only apply when both fixed and RR hosts present - Add fixedUserPool.length > 0 condition to validation - Ensures validation only triggers for specific bug scenario: events with fixed hosts + RR hosts but no available RR hosts - Prevents blocking normal Round Robin events that only have RR hosts - Updates test description to reflect more precise validation logic Co-Authored-By: carina@cal.com * fix: simplify Round Robin validation to resolve E2E test failures Remove overly restrictive fixedUserPool.length > 0 condition that was blocking valid Round Robin booking scenarios in E2E tests. The validation now only checks if Round Robin hosts are assigned but none are available, which is the intended behavior for the bug fix. Co-Authored-By: carina@cal.com * fix: restore fixedUserPool condition to Round Robin validation - Add back fixedUserPool.length > 0 condition to make validation specific to bug scenario - Only triggers when Round Robin events have both fixed hosts AND Round Robin hosts but no available Round Robin hosts - Prevents blocking normal Round Robin events that only have Round Robin hosts (like in organization settings E2E test) - Updates test description to reflect more precise validation logic Co-Authored-By: carina@cal.com Co-Authored-By: carina@cal.com * throw error if no RR user is available * add tests * fix comments * revert change * remove comments --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: CarinaWolli --- .../dialog/__tests__/RerouteDialog.test.tsx | 12 +- apps/web/public/static/locales/en/common.json | 2 + .../googlecalendar/lib/CalendarService.ts | 2 +- .../features/bookings/lib/handleNewBooking.ts | 11 +- .../test/dynamic-group-booking.test.ts | 4 +- .../test/round-robin-no-hosts.test.ts | 238 ++++++++++++++++++ .../collective-scheduling.test.ts | 2 +- .../ee/round-robin/roundRobinReassignment.ts | 1 - packages/lib/errorCodes.ts | 3 +- .../server/getServerErrorFromUnknown.test.ts | 3 +- .../lib/server/getServerErrorFromUnknown.ts | 3 +- 11 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/test/round-robin-no-hosts.test.ts diff --git a/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx index 538948e199..4848a8027e 100644 --- a/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx +++ b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx @@ -440,7 +440,11 @@ describe("RerouteDialog", () => { render( - + ); @@ -483,7 +487,11 @@ describe("RerouteDialog", () => { render( - + ); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c23f0d318d..0738b76b95 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -116,6 +116,8 @@ "delete_calendar_event_error": "Unable to delete Calendar event.", "already_signed_up_for_this_booking_error": "You are already signed up for this booking.", "hosts_unavailable_for_booking": "Some of the hosts are unavailable for booking.", + "fixed_hosts_unavailable_for_booking": "Some of the fixed hosts are unavailable for booking.", + "round_robin_hosts_unavailable_for_booking": "No Round Robin hosts is available for booking.", "help": "Help", "price": "Price", "paid": "Paid", diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 3f32fbb4f3..07ae5308f6 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -677,7 +677,7 @@ export default class GoogleCalendarService implements Calendar { const fromDate = new Date(dateFrom); const toDate = new Date(dateTo); const oneDayMs = 1000 * 60 * 60 * 24; - const diff = Math.floor((toDate.getTime() - fromDate.getTime()) / (oneDayMs)); + const diff = Math.floor((toDate.getTime() - fromDate.getTime()) / oneDayMs); // Google API only allows a date range of 90 days for /freebusy if (diff <= 90) { diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 417564da20..fa1b0cbc52 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -893,10 +893,17 @@ async function handler( luckyUsers.push(newLuckyUser); } } + // ALL fixed users must be available if (fixedUserPool.length !== users.filter((user) => user.isFixed).length) { - throw new Error(ErrorCode.HostsUnavailableForBooking); + throw new Error(ErrorCode.FixedHostsUnavailableForBooking); } + + // If there are RR hosts, we need to find a lucky user + if ([...qualifiedRRUsers, ...additionalFallbackRRUsers].length > 0 && luckyUsers.length === 0) { + throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking); + } + // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer. users = [...fixedUserPool, ...luckyUsers]; luckyUserResponse = { luckyUsers: luckyUsers.map((u) => u.id) }; @@ -926,7 +933,7 @@ async function handler( if (users.length === 0 && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { loggerWithEventDetails.error(`No available users found for round robin event.`); - throw new Error(ErrorCode.NoAvailableUsersFound); + throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking); } // If the team member is requested then they should be the organizer diff --git a/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts index c99a86be82..efa78724d1 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts @@ -209,7 +209,7 @@ describe("handleNewBooking", () => { await handleNewBooking({ bookingData: mockBookingData, }) - ).rejects.toThrowError(ErrorCode.HostsUnavailableForBooking); + ).rejects.toThrowError(ErrorCode.FixedHostsUnavailableForBooking); }, timeout ); @@ -298,7 +298,7 @@ describe("handleNewBooking", () => { await handleNewBooking({ bookingData: mockBookingData, }) - ).rejects.toThrowError(ErrorCode.HostsUnavailableForBooking); + ).rejects.toThrowError(ErrorCode.FixedHostsUnavailableForBooking); }, timeout ); diff --git a/packages/features/bookings/lib/handleNewBooking/test/round-robin-no-hosts.test.ts b/packages/features/bookings/lib/handleNewBooking/test/round-robin-no-hosts.test.ts new file mode 100644 index 0000000000..d26b409701 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/round-robin-no-hosts.test.ts @@ -0,0 +1,238 @@ +import { + createBookingScenario, + getBooker, + getOrganizer, + getScenarioData, + mockCalendar, + TestData, + Timezones, + getDate, + getGoogleCalendarCredential, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import { describe, expect } from "vitest"; + +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { SchedulingType } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; + +const timeout = process.env.CI ? 5000 : 20000; + +describe("handleNewBooking - Round Robin Host Validation", () => { + setupAndTeardown(); + + test( + "should throw NoAvailableUsersFound when Round Robin event has both fixed and round robin hosts busy", + async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const fixedHost = getOrganizer({ + name: "Fixed Host", + email: "fixed-host@example.com", + id: 101, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const roundRobinHost = { + name: "Round Robin Host", + username: "round-robin-host", + timeZone: Timezones["+5:30"], + email: "round-robin-host@example.com", + id: 102, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }; + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + hosts: [ + { + userId: 101, + isFixed: true, + }, + { + userId: 102, + isFixed: false, + }, + ], + schedulingType: SchedulingType.ROUND_ROBIN, + }, + ], + organizer: fixedHost, + usersApartFromOrganizer: [roundRobinHost], + bookings: [ + { + userId: 101, // Make fixed host busy + eventTypeId: 1, + startTime: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, + endTime: `${getDate({ dateIncrement: 1 }).dateString}T04:45:00.000Z`, + status: "ACCEPTED", + attendees: [ + { + email: "existing-booker@example.com", + name: "Existing Booker", + }, + ], + }, + { + userId: 102, // Make round robin host busy + eventTypeId: 1, + startTime: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, + endTime: `${getDate({ dateIncrement: 1 }).dateString}T04:45:00.000Z`, + status: "ACCEPTED", + attendees: [ + { + email: "existing-booker2@example.com", + name: "Existing Booker 2", + }, + ], + }, + ], + }); + + await createBookingScenario(scenarioData); + + mockCalendar("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + busySlots: [], + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T04:45:00.000Z`, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + await expect( + handleNewBooking({ + bookingData: mockBookingData, + }) + ).rejects.toThrow(ErrorCode.NoAvailableUsersFound); + }, + timeout + ); + + test( + "should throw RoundRobinHostsUnavailableForBooking when Round Robin event has fixed hosts but no round robin host is available", + async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const fixedHost = getOrganizer({ + name: "Fixed Host", + email: "fixed-host@example.com", + id: 101, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const roundRobinHost = { + name: "Round Robin Host", + username: "round-robin-host", + timeZone: Timezones["+5:30"], + email: "round-robin-host@example.com", + id: 102, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }; + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + hosts: [ + { + userId: 101, + isFixed: true, + }, + { + userId: 102, + isFixed: false, + }, + ], + schedulingType: SchedulingType.ROUND_ROBIN, + }, + ], + organizer: fixedHost, + usersApartFromOrganizer: [roundRobinHost], + bookings: [ + { + userId: 102, // Make round robin host busy with an existing booking + eventTypeId: 1, + startTime: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, + endTime: `${getDate({ dateIncrement: 1 }).dateString}T04:45:00.000Z`, + status: "ACCEPTED", + attendees: [ + { + email: "existing-booker@example.com", + name: "Existing Booker", + }, + ], + }, + ], + }); + + await createBookingScenario(scenarioData); + + mockCalendar("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + busySlots: [], + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T04:45:00.000Z`, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + await expect( + handleNewBooking({ + bookingData: mockBookingData, + }) + ).rejects.toThrow(ErrorCode.RoundRobinHostsUnavailableForBooking); + }, + timeout + ); +}); diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts index ba238ad180..90a70966b0 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts @@ -360,7 +360,7 @@ describe("handleNewBooking", () => { await handleNewBooking({ bookingData: mockBookingData, }); - }).rejects.toThrowError(ErrorCode.HostsUnavailableForBooking); + }).rejects.toThrowError(ErrorCode.FixedHostsUnavailableForBooking); }, timeout ); diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index 39c087b3d5..660e91c86b 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -21,7 +21,6 @@ import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey, } from "@calcom/lib/delegationCredential/server"; import { getEventName } from "@calcom/lib/event"; -import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService"; import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import logger from "@calcom/lib/logger"; diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts index 9e0775ff6f..6f7cb7f056 100644 --- a/packages/lib/errorCodes.ts +++ b/packages/lib/errorCodes.ts @@ -4,7 +4,8 @@ export enum ErrorCode { ChargeCardFailure = "couldnt_charge_card_error", RequestBodyWithouEnd = "request_body_end_time_internal_error", AlreadySignedUpForBooking = "already_signed_up_for_this_booking_error", - HostsUnavailableForBooking = "hosts_unavailable_for_booking", + FixedHostsUnavailableForBooking = "fixed_hosts_unavailable_for_booking", + RoundRobinHostsUnavailableForBooking = "round_robin_hosts_unavailable_for_booking", EventTypeNotFound = "event_type_not_found_error", BookingNotFound = "booking_not_found_error", BookingSeatsFull = "booking_seats_full_error", diff --git a/packages/lib/server/getServerErrorFromUnknown.test.ts b/packages/lib/server/getServerErrorFromUnknown.test.ts index 31e920fed6..0e54136034 100644 --- a/packages/lib/server/getServerErrorFromUnknown.test.ts +++ b/packages/lib/server/getServerErrorFromUnknown.test.ts @@ -26,7 +26,8 @@ const test404Codes = [ const test409Codes = [ ErrorCode.NoAvailableUsersFound, - ErrorCode.HostsUnavailableForBooking, + ErrorCode.FixedHostsUnavailableForBooking, + ErrorCode.RoundRobinHostsUnavailableForBooking, ErrorCode.AlreadySignedUpForBooking, ErrorCode.BookingSeatsFull, ErrorCode.NotEnoughAvailableSeats, diff --git a/packages/lib/server/getServerErrorFromUnknown.ts b/packages/lib/server/getServerErrorFromUnknown.ts index b7b469bb60..abafecdcfc 100644 --- a/packages/lib/server/getServerErrorFromUnknown.ts +++ b/packages/lib/server/getServerErrorFromUnknown.ts @@ -114,7 +114,8 @@ function getStatusCode(cause: Error | ErrorWithCode): number { return 400; // 409 Conflict case ErrorCode.NoAvailableUsersFound: - case ErrorCode.HostsUnavailableForBooking: + case ErrorCode.FixedHostsUnavailableForBooking: + case ErrorCode.RoundRobinHostsUnavailableForBooking: case ErrorCode.AlreadySignedUpForBooking: case ErrorCode.BookingSeatsFull: case ErrorCode.NotEnoughAvailableSeats: