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: