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: