;
availability?: AvailabilityOption;
bookerLayouts: BookerLayoutSettings;
diff --git a/packages/features/schedules/components/Schedule.tsx b/packages/features/schedules/components/Schedule.tsx
index 388de8f32d..06fa8994e1 100644
--- a/packages/features/schedules/components/Schedule.tsx
+++ b/packages/features/schedules/components/Schedule.tsx
@@ -20,7 +20,6 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { weekdayNames } from "@calcom/lib/weekday";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { TimeRange } from "@calcom/types/schedule";
-
import cn from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Dropdown, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui/components/dropdown";
diff --git a/packages/lib/__mocks__/constants.ts b/packages/lib/__mocks__/constants.ts
index 9cb3f84239..47ac4b76eb 100644
--- a/packages/lib/__mocks__/constants.ts
+++ b/packages/lib/__mocks__/constants.ts
@@ -15,13 +15,13 @@ const initialConstants = {
IS_SELF_HOSTED: false,
SEO_IMG_DEFAULT: "https://cal.com/og-image.png",
SEO_IMG_OGIMG: "https://cal.com/og-image-wide.png",
- SEO_IMG_LOGO: "https://cal.com/logo.png",
CURRENT_TIMEZONE: "Europe/London",
APP_NAME: "Cal.com",
BOOKER_NUMBER_OF_DAYS_TO_LOAD: 14,
PUBLIC_QUICK_AVAILABILITY_ROLLOUT: 100,
SINGLE_ORG_SLUG: "",
-} as typeof constants;
+ DEFAULT_GROUP_ID: "default_group_id",
+} as Partial;
export const mockedConstants = { ...initialConstants };
diff --git a/packages/lib/bookings/filterHostsBySameRoundRobinHost.test.ts b/packages/lib/bookings/filterHostsBySameRoundRobinHost.test.ts
index cd246856b8..86be937c1b 100644
--- a/packages/lib/bookings/filterHostsBySameRoundRobinHost.test.ts
+++ b/packages/lib/bookings/filterHostsBySameRoundRobinHost.test.ts
@@ -64,4 +64,66 @@ describe("filterHostsBySameRoundRobinHost", () => {
})
).resolves.toStrictEqual([hosts[0]]);
});
+
+ // Tests for bookings that have more than one host
+ describe("Fixed hosts and round robin groups support", () => {
+ it("should return organizer and attendee hosts", async () => {
+ prismaMock.booking.findFirst.mockResolvedValue({
+ userId: 1,
+ attendees: [
+ { email: "host2@acme.com" },
+ { email: "host3@acme.com" },
+ { email: "attendee@example.com" }, // Non-host attendee
+ ],
+ });
+
+ const hosts = [
+ { isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "host1@acme.com" } },
+ { isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "host2@acme.com" } },
+ { isFixed: false as const, createdAt: new Date(), user: { id: 3, email: "host3@acme.com" } },
+ { isFixed: false as const, createdAt: new Date(), user: { id: 4, email: "host4@acme.com" } },
+ ];
+
+ const result = await filterHostsBySameRoundRobinHost({
+ hosts,
+ rescheduleUid: "some-uid",
+ rescheduleWithSameRoundRobinHost: true,
+ routedTeamMemberIds: null,
+ });
+
+ // Should return organizer host (id: 1) and attendee hosts (ids: 2, 3)
+ expect(result).toHaveLength(3);
+ expect(result).toEqual([
+ expect.objectContaining({ user: { id: 1, email: "host1@acme.com" } }), // organizer
+ expect.objectContaining({ user: { id: 2, email: "host2@acme.com" } }), // attendee
+ expect.objectContaining({ user: { id: 3, email: "host3@acme.com" } }), // attendee
+ ]);
+ });
+
+ it("should return only organizer host when no attendees match current hosts", async () => {
+ prismaMock.booking.findFirst.mockResolvedValue({
+ userId: 1,
+ attendees: [
+ { email: "attendee1@example.com" }, // Non-host attendee
+ { email: "attendee2@example.com" }, // Non-host attendee
+ ],
+ });
+
+ const hosts = [
+ { isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "host1@acme.com" } },
+ { isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "host2@acme.com" } },
+ ];
+
+ const result = await filterHostsBySameRoundRobinHost({
+ hosts,
+ rescheduleUid: "some-uid",
+ rescheduleWithSameRoundRobinHost: true,
+ routedTeamMemberIds: null,
+ });
+
+ // Should return only organizer host
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual(expect.objectContaining({ user: { id: 1, email: "host1@acme.com" } }));
+ });
+ });
});
diff --git a/packages/lib/bookings/filterHostsBySameRoundRobinHost.ts b/packages/lib/bookings/filterHostsBySameRoundRobinHost.ts
index 76208c0427..001c44ca80 100644
--- a/packages/lib/bookings/filterHostsBySameRoundRobinHost.ts
+++ b/packages/lib/bookings/filterHostsBySameRoundRobinHost.ts
@@ -6,7 +6,7 @@ import { isRerouting } from "./routing/utils";
export const filterHostsBySameRoundRobinHost = async <
T extends {
isFixed: false; // ensure no fixed hosts are passed.
- user: { id: number };
+ user: { id: number; email: string };
}
>({
hosts,
@@ -35,8 +35,23 @@ export const filterHostsBySameRoundRobinHost = async <
},
select: {
userId: true,
+ attendees: {
+ select: {
+ email: true,
+ },
+ },
},
});
- return hosts.filter((host) => host.user.id === originalRescheduledBooking?.userId || 0);
+ if (!originalRescheduledBooking) {
+ return hosts;
+ }
+
+ const attendeeEmails = originalRescheduledBooking.attendees?.map((attendee) => attendee.email) || [];
+
+ return hosts.filter((host) => {
+ const isOrganizer = host.user.id === originalRescheduledBooking.userId;
+ const isAttendee = attendeeEmails.includes(host.user.email);
+ return isOrganizer || isAttendee;
+ });
};
diff --git a/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.test.ts b/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.test.ts
index 7e6229ee4a..90b534df4f 100644
--- a/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.test.ts
+++ b/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.test.ts
@@ -35,6 +35,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
},
priority: undefined,
weight: undefined,
+ groupId: null,
},
{
isFixed: false,
@@ -47,6 +48,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
},
priority: undefined,
weight: undefined,
+ groupId: null,
},
{
isFixed: false,
@@ -59,6 +61,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
},
priority: undefined,
weight: undefined,
+ groupId: null,
},
];
diff --git a/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts b/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts
index 3061292c18..b2c5bab219 100644
--- a/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts
+++ b/packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts
@@ -17,6 +17,7 @@ type Host = {
createdAt: Date;
priority?: number | null;
weight?: number | null;
+ groupId: string | null;
} & {
user: T;
};
@@ -81,6 +82,7 @@ const _findQualifiedHostsWithDelegationCredentials = async <
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
+ groupId?: string | null;
user: Omit & { credentials: CredentialForCalendarService[] };
}[];
fixedHosts: {
@@ -88,6 +90,7 @@ const _findQualifiedHostsWithDelegationCredentials = async <
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
+ groupId?: string | null;
user: Omit & { credentials: CredentialForCalendarService[] };
}[];
// all hosts we want to fallback to including the qualifiedRRHosts (fairness + crm contact owner)
@@ -96,6 +99,7 @@ const _findQualifiedHostsWithDelegationCredentials = async <
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
+ groupId?: string | null;
user: Omit & { credentials: CredentialForCalendarService[] };
}[];
}> => {
@@ -132,11 +136,11 @@ const _findQualifiedHostsWithDelegationCredentials = async <
}
const hostsAfterSegmentMatching = applyFilterWithFallback(
- roundRobinHosts,
+ hostsAfterRescheduleWithSameRoundRobinHost,
(await findMatchingHostsWithEventSegment({
eventType,
- hosts: roundRobinHosts,
- })) as typeof roundRobinHosts
+ hosts: hostsAfterRescheduleWithSameRoundRobinHost,
+ })) as typeof hostsAfterRescheduleWithSameRoundRobinHost
);
if (hostsAfterSegmentMatching.length === 1) {
@@ -147,7 +151,9 @@ const _findQualifiedHostsWithDelegationCredentials = async <
}
//if segment matching doesn't return any hosts we fall back to all round robin hosts
- const officalRRHosts = hostsAfterSegmentMatching.length ? hostsAfterSegmentMatching : roundRobinHosts;
+ const officalRRHosts = hostsAfterSegmentMatching.length
+ ? hostsAfterSegmentMatching
+ : hostsAfterRescheduleWithSameRoundRobinHost;
const hostsAfterContactOwnerMatching = applyFilterWithFallback(
officalRRHosts,
diff --git a/packages/lib/bookings/getRoutedUsers.ts b/packages/lib/bookings/getRoutedUsers.ts
index 86533a2bd5..7511caf5ff 100644
--- a/packages/lib/bookings/getRoutedUsers.ts
+++ b/packages/lib/bookings/getRoutedUsers.ts
@@ -76,6 +76,7 @@ type BaseHost = {
weight?: number | null;
weightAdjustment?: number | null;
user: User;
+ groupId: string | null;
};
export type EventType = {
@@ -107,6 +108,7 @@ export function getNormalizedHosts({
priority?: number | null;
weight?: number | null;
createdAt: Date | null;
+ groupId: string | null;
}[];
}) {
const matchingRRTeamMembers = await findMatchingTeamMembersIdsForEventRRSegment({
diff --git a/packages/lib/bookings/hostGroupUtils.ts b/packages/lib/bookings/hostGroupUtils.ts
new file mode 100644
index 0000000000..71d75057aa
--- /dev/null
+++ b/packages/lib/bookings/hostGroupUtils.ts
@@ -0,0 +1,39 @@
+import { DEFAULT_GROUP_ID } from "@calcom/lib/constants";
+
+export function groupHostsByGroupId({
+ hosts,
+ hostGroups,
+}: {
+ hosts: T[];
+ hostGroups?: { id: string }[];
+}): Record {
+ const groups: Record = {};
+
+ const hasGroups = hostGroups && hostGroups.length > 0;
+
+ if (hasGroups) {
+ hostGroups.forEach((group) => {
+ groups[group.id] = [];
+ });
+ } else {
+ groups[DEFAULT_GROUP_ID] = [];
+ }
+
+ hosts.forEach((host) => {
+ const groupId = hasGroups && host.groupId ? host.groupId : DEFAULT_GROUP_ID;
+ if (groups[groupId]) {
+ groups[groupId].push(host);
+ }
+ });
+
+ return groups;
+}
+
+export function getHostsFromOtherGroups(
+ hosts: readonly T[],
+ groupId: string | null
+): T[] {
+ return hosts.filter(
+ (host) => (groupId && (!host.groupId || host.groupId !== groupId)) || (!groupId && host.groupId)
+ );
+}
diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts
index 56b578625c..ce54e31f3e 100644
--- a/packages/lib/constants.ts
+++ b/packages/lib/constants.ts
@@ -230,3 +230,5 @@ export const IS_SMS_CREDITS_ENABLED =
export const DATABASE_CHUNK_SIZE = parseInt(process.env.DATABASE_CHUNK_SIZE || "25", 10);
export const NEXTJS_CACHE_TTL = 3600; // 1 hour
+
+export const DEFAULT_GROUP_ID = "default_group_id";
diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts
index 7f90985e92..f8f5f3c212 100644
--- a/packages/lib/defaultEvents.ts
+++ b/packages/lib/defaultEvents.ts
@@ -145,6 +145,7 @@ const commons = {
instantMeetingScheduleId: null,
instantMeetingParameters: [],
eventTypeColor: null,
+ hostGroups: [],
};
export const dynamicEvent = {
diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts
index 058c05f050..b015ae9164 100644
--- a/packages/lib/errorCodes.ts
+++ b/packages/lib/errorCodes.ts
@@ -5,7 +5,7 @@ export enum ErrorCode {
RequestBodyWithouEnd = "request_body_end_time_internal_error",
AlreadySignedUpForBooking = "already_signed_up_for_this_booking_error",
FixedHostsUnavailableForBooking = "fixed_hosts_unavailable_for_booking",
- RoundRobinHostsUnavailableForBooking = "round_robin_hosts_unavailable_for_booking",
+ RoundRobinHostsUnavailableForBooking = "round_robin_host_unavailable_for_booking",
EventTypeNotFound = "event_type_not_found_error",
BookingNotFound = "booking_not_found_error",
BookingSeatsFull = "booking_seats_full_error",
diff --git a/packages/lib/getAggregatedAvailability.test.ts b/packages/lib/getAggregatedAvailability.test.ts
index 8062c3654e..539c0100ec 100644
--- a/packages/lib/getAggregatedAvailability.test.ts
+++ b/packages/lib/getAggregatedAvailability.test.ts
@@ -209,4 +209,264 @@ describe("getAggregatedAvailability", () => {
expect(isAvailable(result, timeRangeToCheckAvailable)).toBe(true);
expect(result.length).toBe(1);
});
+
+ it("requires at least one RR host from each group to be available", () => {
+ // Test scenario with two groups:
+ // Group 1: Host A (available 11:00-11:30), Host B (available 12:00-12:30)
+ // Group 2: Host C (available 11:15-11:45), Host D (available 12:15-12:45)
+ // Fixed host: available 11:00-13:00
+ // Expected: Only slots where at least one host from each group is available
+ const userAvailability = [
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
+ ],
+ user: { isFixed: true },
+ },
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
+ ],
+ user: { isFixed: false, groupId: "group1" },
+ },
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T12:00:00.000Z"), end: dayjs("2025-01-23T12:30:00.000Z") },
+ ],
+ user: { isFixed: false, groupId: "group1" },
+ },
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:15:00.000Z"), end: dayjs("2025-01-23T11:45:00.000Z") },
+ ],
+ user: { isFixed: false, groupId: "group2" },
+ },
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T12:15:00.000Z"), end: dayjs("2025-01-23T12:45:00.000Z") },
+ ],
+ user: { isFixed: false, groupId: "group2" },
+ },
+ ];
+
+ const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN");
+
+ const timeRangeAvailable = {
+ start: dayjs("2025-01-23T11:15:00.000Z"),
+ end: dayjs("2025-01-23T11:30:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeAvailable)).toBe(true);
+
+ const timeRangeAvailable2 = {
+ start: dayjs("2025-01-23T12:15:00.000Z"),
+ end: dayjs("2025-01-23T12:30:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeAvailable2)).toBe(true);
+
+ // Should NOT be available when only one group has hosts available
+ const timeRangeNotAvailable = {
+ start: dayjs("2025-01-23T11:00:00.000Z"),
+ end: dayjs("2025-01-23T11:15:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeNotAvailable)).toBe(false);
+
+ // Should NOT be available when only one group has hosts available
+ const timeRangeNotAvailable2 = {
+ start: dayjs("2025-01-23T12:30:00.000Z"),
+ end: dayjs("2025-01-23T12:45:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeNotAvailable2)).toBe(false);
+ });
+
+ it("handles single group with multiple RR hosts (union behavior)", () => {
+ // Test that when all RR hosts are in the same group, we get union behavior
+ // Host A: available 11:00-11:20, 16:10-16:30
+ // Host B: available 11:15-11:30, 13:20-13:30
+ // Expected: Union of both hosts' availability
+ const userAvailability = [
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:20:00.000Z") },
+ { start: dayjs("2025-01-23T16:10:00.000Z"), end: dayjs("2025-01-23T16:30:00.000Z") },
+ ],
+ user: { isFixed: false },
+ },
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:15:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
+ { start: dayjs("2025-01-23T13:20:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") },
+ ],
+ user: { isFixed: false },
+ },
+ ];
+
+ const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN");
+
+ // Should be available when either host is available
+ const timeRangeAvailable = {
+ start: dayjs("2025-01-23T11:00:00.000Z"),
+ end: dayjs("2025-01-23T11:20:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeAvailable)).toBe(true);
+
+ const timeRangeAvailable2 = {
+ start: dayjs("2025-01-23T13:20:00.000Z"),
+ end: dayjs("2025-01-23T13:30:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeAvailable2)).toBe(true);
+
+ // Should NOT be available when neither host is available
+ const timeRangeNotAvailable = {
+ start: dayjs("2025-01-23T11:00:00.000Z"),
+ end: dayjs("2025-01-23T11:30:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeNotAvailable)).toBe(false);
+ });
+
+ it("handles mixed groups with some hosts having groupId and others not", () => {
+ // Test scenario:
+ // Group 1: Host A (available 11:00-11:45)
+ // Group 2: Host B (available 11:15-12:00)
+ // Default group: Host C (available 11:30-12:30), Host D (available 12:15-12:45)
+ // Fixed host: available 11:00-13:00
+
+ const userAvailability = [
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
+ ],
+ user: { isFixed: true },
+ },
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:45:00.000Z") },
+ ],
+ user: { isFixed: false, groupId: "group1" },
+ },
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:15:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
+ ],
+ user: { isFixed: false, groupId: "group2" },
+ },
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:30:00.000Z"), end: dayjs("2025-01-23T12:30:00.000Z") },
+ ],
+ user: { isFixed: false },
+ },
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T12:15:00.000Z"), end: dayjs("2025-01-23T12:45:00.000Z") },
+ ],
+ user: { isFixed: false },
+ },
+ ];
+
+ const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN");
+
+ // Should be available when all groups have at least one host available
+ // Fixed host + Group 1 + Group 2 + Default group all available in this time range
+ const timeRangeAvailable = {
+ start: dayjs("2025-01-23T11:30:00.000Z"),
+ end: dayjs("2025-01-23T11:45:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeAvailable)).toBe(true);
+
+ // Should NOT be available when not all groups have hosts available
+ // Only Group 1, Group 2, and fixed host available, but Default group not available
+ const timeRangeNotAvailable = {
+ start: dayjs("2025-01-23T11:15:00.000Z"),
+ end: dayjs("2025-01-23T11:30:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeNotAvailable)).toBe(false);
+
+ // Should NOT be available when not all groups have hosts available
+ // Only Group 1 and fixed host available, Group 2 and Default group not available
+ const timeRangeNotAvailable2 = {
+ start: dayjs("2025-01-23T11:00:00.000Z"),
+ end: dayjs("2025-01-23T11:15:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeNotAvailable2)).toBe(false);
+
+ // Should NOT be available when not all groups have hosts available
+ // Only Group 2 and fixed host available, Group 1 and Default group not available
+ const timeRangeNotAvailable3 = {
+ start: dayjs("2025-01-23T11:45:00.000Z"),
+ end: dayjs("2025-01-23T12:00:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeNotAvailable3)).toBe(false);
+ });
+
+ it("handles empty groups gracefully", () => {
+ // Test scenario with empty groups
+ const userAvailability = [
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
+ ],
+ user: { isFixed: true },
+ },
+ ];
+
+ const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN");
+
+ // Should only have fixed host availability
+ const timeRangeAvailable = {
+ start: dayjs("2025-01-23T11:00:00.000Z"),
+ end: dayjs("2025-01-23T11:30:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeAvailable)).toBe(true);
+ expect(result.length).toBe(1);
+ });
+
+ it("handles scenario where one group has no available hosts", () => {
+ // Test scenario where one group has no available hosts
+ // Group 1: Host A (available 11:00-11:30)
+ // Group 2: Host B (not available at all)
+ // Fixed host: available 11:00-13:00
+ const userAvailability = [
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
+ ],
+ user: { isFixed: true },
+ },
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [
+ { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
+ ],
+ user: { isFixed: false, groupId: "group1" },
+ },
+ {
+ dateRanges: [],
+ oooExcludedDateRanges: [], // No availability
+ user: { isFixed: false, groupId: "group2" },
+ },
+ ];
+
+ const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN");
+
+ // Should NOT be available when one group has no hosts available
+ const timeRangeNotAvailable = {
+ start: dayjs("2025-01-23T11:00:00.000Z"),
+ end: dayjs("2025-01-23T11:30:00.000Z"),
+ };
+ expect(isAvailable(result, timeRangeNotAvailable)).toBe(false);
+ });
});
diff --git a/packages/lib/getAggregatedAvailability.ts b/packages/lib/getAggregatedAvailability.ts
index 6f3f9fa8bd..e78f5d1982 100644
--- a/packages/lib/getAggregatedAvailability.ts
+++ b/packages/lib/getAggregatedAvailability.ts
@@ -1,3 +1,4 @@
+import { DEFAULT_GROUP_ID } from "@calcom/lib/constants";
import type { DateRange } from "@calcom/lib/date-ranges";
import { intersect } from "@calcom/lib/date-ranges";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -25,7 +26,7 @@ export const getAggregatedAvailability = (
userAvailability: {
dateRanges: DateRange[];
oooExcludedDateRanges: DateRange[];
- user?: { isFixed?: boolean };
+ user?: { isFixed?: boolean; groupId?: string | null };
}[],
schedulingType: SchedulingType | null
): DateRange[] => {
@@ -44,10 +45,27 @@ export const getAggregatedAvailability = (
const dateRangesToIntersect = !!fixedDateRanges.length ? [fixedDateRanges] : [];
const roundRobinHosts = userAvailability.filter(({ user }) => user?.isFixed !== true);
if (roundRobinHosts.length) {
- dateRangesToIntersect.push(
- roundRobinHosts.flatMap((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges))
- );
+ // Group round robin hosts by their groupId
+ const hostsByGroup = roundRobinHosts.reduce((groups, host) => {
+ const groupId = host.user?.groupId || DEFAULT_GROUP_ID;
+ if (!groups[groupId]) {
+ groups[groupId] = [];
+ }
+ groups[groupId].push(host);
+ return groups;
+ }, {} as Record);
+
+ // at least one host from each group needs to be available
+ Object.values(hostsByGroup).forEach((groupHosts) => {
+ if (groupHosts.length > 0) {
+ const groupDateRanges = groupHosts.flatMap((s) =>
+ !isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges
+ );
+ dateRangesToIntersect.push(groupDateRanges ?? []);
+ }
+ });
}
+
const availability = intersect(dateRangesToIntersect);
const uniqueRanges = uniqueAndSortedDateRanges(availability);
diff --git a/packages/lib/server/repository/eventTypeRepository.ts b/packages/lib/server/repository/eventTypeRepository.ts
index 2c58050292..25611493c2 100644
--- a/packages/lib/server/repository/eventTypeRepository.ts
+++ b/packages/lib/server/repository/eventTypeRepository.ts
@@ -606,6 +606,12 @@ export class EventTypeRepository {
},
},
teamId: true,
+ hostGroups: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
team: {
select: {
id: true,
@@ -672,6 +678,7 @@ export class EventTypeRepository {
priority: true,
weight: true,
scheduleId: true,
+ groupId: true,
user: {
select: {
timeZone: true,
@@ -895,6 +902,12 @@ export class EventTypeRepository {
},
},
teamId: true,
+ hostGroups: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
team: {
select: {
id: true,
@@ -1197,6 +1210,12 @@ export class EventTypeRepository {
useEventLevelSelectedCalendars: true,
restrictionScheduleId: true,
useBookerTimezone: true,
+ hostGroups: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
team: {
select: {
id: true,
@@ -1246,6 +1265,7 @@ export class EventTypeRepository {
createdAt: true,
weight: true,
priority: true,
+ groupId: true,
user: {
select: {
credentials: { select: credentialForCalendarServiceSelect },
diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts
index 0bca6f53f7..3134d221c1 100644
--- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts
+++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts
@@ -101,6 +101,7 @@ export const useEventTypeForm = ({
hideOrganizerEmail: eventType.hideOrganizerEmail,
metadata: eventType.metadata,
hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)),
+ hostGroups: eventType.hostGroups || [],
successRedirectUrl: eventType.successRedirectUrl || "",
forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect,
users: eventType.users,
diff --git a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx
index b59d876e51..bc97a50282 100644
--- a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx
+++ b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx
@@ -108,7 +108,12 @@ export const EventTypeWebWrapper = ({ id, data: serverFetchedData }: EventTypeWe
return ;
};
-const EventTypeWeb = ({ id, ...rest }: EventTypeSetupProps & { id: number }) => {
+const EventTypeWeb = ({
+ id,
+ ...rest
+}: EventTypeSetupProps & {
+ id: number;
+}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const pathname = usePathname();
diff --git a/packages/prisma/migrations/20250704081718_add_host_groups/migration.sql b/packages/prisma/migrations/20250704081718_add_host_groups/migration.sql
new file mode 100644
index 0000000000..2ca7d8fd05
--- /dev/null
+++ b/packages/prisma/migrations/20250704081718_add_host_groups/migration.sql
@@ -0,0 +1,25 @@
+-- AlterTable
+ALTER TABLE "Host" ADD COLUMN "groupId" TEXT;
+
+-- CreateTable
+CREATE TABLE "HostGroup" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "eventTypeId" INTEGER,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "HostGroup_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "HostGroup_name_idx" ON "HostGroup"("name");
+
+-- CreateIndex
+CREATE INDEX "HostGroup_eventTypeId_idx" ON "HostGroup"("eventTypeId");
+
+-- AddForeignKey
+ALTER TABLE "Host" ADD CONSTRAINT "Host_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "HostGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "HostGroup" ADD CONSTRAINT "HostGroup_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 14c2e73086..a41165ed33 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -49,18 +49,20 @@ enum CreationSource {
}
model Host {
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
- eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
+ eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
eventTypeId Int
- isFixed Boolean @default(false)
+ isFixed Boolean @default(false)
priority Int?
weight Int?
// weightAdjustment is deprecated. We not calculate the calibratino value on the spot. Plan to drop this column.
weightAdjustment Int?
- schedule Schedule? @relation(fields: [scheduleId], references: [id])
+ schedule Schedule? @relation(fields: [scheduleId], references: [id])
scheduleId Int?
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
+ group HostGroup? @relation(fields: [groupId], references: [id])
+ groupId String?
@@id([userId, eventTypeId])
@@index([userId])
@@ -68,6 +70,19 @@ model Host {
@@index([scheduleId])
}
+model HostGroup {
+ id String @id @default(uuid())
+ name String
+ hosts Host[]
+ eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
+ eventTypeId Int?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([name])
+ @@index([eventTypeId])
+}
+
model CalVideoSettings {
eventTypeId Int @id
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
@@ -99,9 +114,10 @@ model EventType {
offsetStart Int @default(0)
hidden Boolean @default(false)
hosts Host[]
- users User[] @relation("user_eventtype")
- owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade)
- userId Int?
+
+ users User[] @relation("user_eventtype")
+ owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade)
+ userId Int?
profileId Int?
profile Profile? @relation(fields: [profileId], references: [id])
@@ -204,9 +220,10 @@ model EventType {
secondaryEmailId Int?
secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade)
- useBookerTimezone Boolean @default(false)
+ useBookerTimezone Boolean @default(false)
restrictionScheduleId Int?
- restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id])
+ restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id])
+ hostGroups HostGroup[]
@@unique([userId, slug])
@@unique([teamId, slug])
diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts
index 2a08b5e4e7..56e96e9aa6 100644
--- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts
+++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts
@@ -524,6 +524,12 @@ export async function getBookings({
.select(["Team.id", "Team.name", "Team.slug"])
.whereRef("EventType.teamId", "=", "Team.id")
).as("team"),
+ jsonArrayFrom(
+ eb
+ .selectFrom("HostGroup")
+ .select(["HostGroup.id", "HostGroup.name"])
+ .whereRef("HostGroup.eventTypeId", "=", "EventType.id")
+ ).as("hostGroups"),
])
.whereRef("EventType.id", "=", "Booking.eventTypeId")
).as("eventType"),
diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts
index c0cdedec03..3e11a20a5c 100644
--- a/packages/trpc/server/routers/viewer/eventTypes/types.ts
+++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts
@@ -54,6 +54,12 @@ const hostSchema = z.object({
priority: z.number().min(0).max(4).optional().nullable(),
weight: z.number().min(0).optional().nullable(),
scheduleId: z.number().optional().nullable(),
+ groupId: z.string().optional().nullable(),
+});
+
+const hostGroupSchema = z.object({
+ id: z.string().uuid(),
+ name: z.string(),
});
const childSchema = z.object({
@@ -96,6 +102,7 @@ const BaseEventTypeUpdateInput = _EventTypeModel
rrSegmentQueryValue: rrSegmentQueryValueSchema.optional(),
useEventLevelSelectedCalendars: z.boolean().optional(),
seatsPerTimeSlot: z.number().min(1).max(MAX_SEATS_PER_TIME_SLOT).nullable().optional(),
+ hostGroups: z.array(hostGroupSchema).optional(),
})
.partial()
.extend(_EventTypeModel.pick({ id: true }).shape);
diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts
index 260a830cd2..b4d5bb8650 100644
--- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts
+++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts
@@ -94,6 +94,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
seatsPerTimeSlot,
restrictionScheduleId,
calVideoSettings,
+ hostGroups,
...rest
} = input;
@@ -149,6 +150,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
workflowId: true,
},
},
+ hostGroups: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
team: {
select: {
id: true,
@@ -210,6 +217,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
);
}
+ const isLoadBalancingDisabled = !!(
+ (eventType.team?.rrTimestampBasis && eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT) ||
+ (hostGroups && hostGroups.length > 1) ||
+ (!hostGroups && eventType.hostGroups && eventType.hostGroups.length > 1)
+ );
+
const data: Prisma.EventTypeUpdateInput = {
...rest,
// autoTranslate feature is allowed for org users only
@@ -224,10 +237,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
eventTypeColor: eventTypeColor === null ? Prisma.DbNull : (eventTypeColor as Prisma.InputJsonObject),
disableGuests: guestsField?.hidden ?? false,
seatsPerTimeSlot,
- maxLeadThreshold:
- eventType.team?.rrTimestampBasis && eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT
- ? null
- : rest.maxLeadThreshold,
+ maxLeadThreshold: isLoadBalancingDisabled ? null : rest.maxLeadThreshold,
};
data.locations = locations ?? undefined;
@@ -393,6 +403,48 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
};
}
+ // Handle hostGroups updates
+ if (hostGroups !== undefined) {
+ const existingHostGroups = await ctx.prisma.hostGroup.findMany({
+ where: {
+ eventTypeId: id,
+ },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+
+ await Promise.all(
+ hostGroups.map(async (group) => {
+ await ctx.prisma.hostGroup.upsert({
+ where: { id: group.id },
+ update: { name: group.name },
+ create: {
+ id: group.id,
+ name: group.name,
+ eventTypeId: id,
+ },
+ });
+ })
+ );
+
+ const newGroupsMap = new Map(hostGroups.map((group) => [group.id, group]));
+
+ // Delete groups that are no longer in the new list
+ const groupsToDelete = existingHostGroups.filter((existingGroup) => !newGroupsMap.has(existingGroup.id));
+
+ if (groupsToDelete.length > 0) {
+ await ctx.prisma.hostGroup.deleteMany({
+ where: {
+ id: {
+ in: groupsToDelete.map((group) => group.id),
+ },
+ },
+ });
+ }
+ }
+
if (teamId && hosts) {
// check if all hosts can be assigned (memberships that have accepted invite)
const teamMemberIds = await membershipRepo.listAcceptedTeamMemberIds({ teamId });
@@ -405,10 +457,6 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
});
}
- // weights were already enabled or are enabled now
- const isWeightsEnabled =
- isRRWeightsEnabled || (typeof isRRWeightsEnabled === "undefined" && eventType.isRRWeightsEnabled);
-
const oldHostsSet = new Set(eventType.hosts.map((oldHost) => oldHost.userId));
const newHostsSet = new Set(hosts.map((oldHost) => oldHost.userId));
@@ -429,6 +477,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed,
priority: host.priority ?? 2,
weight: host.weight ?? 100,
+ groupId: host.groupId,
};
}),
update: existingHosts.map((host) => ({
@@ -443,6 +492,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
priority: host.priority ?? 2,
weight: host.weight ?? 100,
scheduleId: host.scheduleId ?? null,
+ groupId: host.groupId,
},
})),
};
@@ -641,6 +691,18 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
updatedValues,
});
+ // Clean up empty host groups
+ if (hostGroups !== undefined || hosts) {
+ await ctx.prisma.hostGroup.deleteMany({
+ where: {
+ eventTypeId: id,
+ hosts: {
+ none: {},
+ },
+ },
+ });
+ }
+
const res = ctx.res as NextApiResponse;
if (typeof res?.revalidate !== "undefined") {
try {
diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts
index 72debf6609..5f32253a33 100644
--- a/packages/trpc/server/routers/viewer/eventTypes/util.ts
+++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts
@@ -190,6 +190,7 @@ type Host = {
priority?: number | null | undefined;
weight?: number | null | undefined;
scheduleId?: number | null | undefined;
+ groupId: string | null;
};
type User = {
diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts
index 2c260668e8..5fb155c03b 100644
--- a/packages/trpc/server/routers/viewer/slots/util.ts
+++ b/packages/trpc/server/routers/viewer/slots/util.ts
@@ -709,10 +709,11 @@ export class AvailableSlotsService {
}: {
hosts: {
isFixed?: boolean;
+ groupId?: string | null;
user: GetAvailabilityUserWithDelegationCredentials;
}[];
}) {
- return hosts.map(({ isFixed, user }) => ({ isFixed, ...user }));
+ return hosts.map(({ isFixed, groupId, user }) => ({ isFixed, groupId, ...user }));
}
private getUsersWithCredentials = withReporting(
@@ -743,6 +744,7 @@ export class AvailableSlotsService {
>;
hosts: {
isFixed?: boolean;
+ groupId?: string | null;
user: GetAvailabilityUserWithDelegationCredentials;
}[];
loggerWithEventDetails: Logger;
diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts
index 161fd0e2a8..29b1421a61 100644
--- a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts
+++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts
@@ -1,5 +1,4 @@
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
-import type { TrpcSessionUser } from "@calcom/trpc/server/types";
type HasTeamPlanOptions = {
ctx: {