feat: Round Robin groups (#22296)

* add Add Group button

* add host groups to schema

* UI for host groups

* raname groups to hostGroups

* schema update

* show groups in assignment tab

* add no group hosts to Group 1

* add dummy group for non group hosts

* fix type errors

* use two dimensional array for luckyUserPools

* fix empty array

* group RR hosts in handleNewBooking

* improve logic for grouping lucky users

* find all lucky users of all groups

* allow several RR hosts on booking

* clean up migrations

* create helper function

* group hosts for slots logic

* add group logic to loading available slots

* adding hosts to groups

* add groupId to hostSchema

* disable hosts from other groups

* handle groups in checkedTeamSelect

* fix adding hosts to groups

* remove and add groups

* show hosts if there are no groups

* fixing adding first group with existing hosts

* show groups empty groups correctly

* UI upddate fixes

* fix adding hosts to existing first host group

* small fixes + code clean up

* add availability fix with test

* create new round-robin test file

* disable reassignment

* fix losing fixed hosts

* fix updating weights and priorities

* disable load balancing with Round Robin Groups

* automatically disable load balancing in update handler

* allRRHosts should only include hosts from same group

* fix type errors

* fix type error

* fix tests

* fix type error

* remove undefined from groupId type

* type changes

* add tests for hostGroups

* add tests for host groups

* fixes

* fix type errors with undefined groupId

* remove seperate host groups prop

* fix editing weights

* remove console.log

* code clean up

* improve getAggregatedAvailability tests

* throw error when no available hosts in a group

* add fixme comment

* create constant for DEFAULT_GROUP_ID

* clean up code

* mock default_group_id for unit tests

* don't show fixed hosts in edit weights side bar

* add DEFAULT_GROUP_ID to  mock test-setup

* remove unused index variable

* code clean up

* fix updating host groups

* fix imports

* add default_group_id to mocks

* add uuid() to zod schema

* remove unused code

* fix singular translation key

* remove unnessary !!

* Revert formatting changes

* add additional tests for bookingActions

* use createMany

* import DEFAULT_GROUP_ID for mocks

* fix mocks

* clean up EventTeamAssignmentTab

* fix type errors in tests

* fix mocks

* remove constants.example.test.ts

* fix type error

* add missing groupId

* fix margin

* clean up empty host groups

* fix constants mock

* useCalback

* use reduce

* extract handlers into seperate functions

* fix handler functions

* fix border radius

* fix type error in CheckForEmptyAssignment

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Carina WollendorferandCarinaWolli authored and GitHub committed 2025-08-08 12:56:13 +01:00
1 parent 6af473a486
commit e29b7e83b8
48 files changed
+2761 -3992

No files matched your search

@@ -546,9 +546,7 @@ function BookingListItem(booking: BookingItemProps) {
</Link>
</div>
</div>
<div
data-testid="title-and-attendees"
className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
<Link href={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
@@ -893,7 +891,7 @@ const FirstAttendee = ({
) : (
<a
key={user.email}
className=" hover:text-blue-500"
className="hover:text-blue-500"
href={`mailto:${user.email}`}
onClick={(e) => e.stopPropagation()}>
{user.name || user.email}
@@ -1081,7 +1079,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
/>
))}
<DropdownMenuSeparator />
<div className="flex justify-end p-2 ">
<div className="flex justify-end p-2">
<Button
data-testid="update-no-show"
color="secondary"
@@ -1204,7 +1202,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className="flex justify-end space-x-2 p-2 ">
<div className="flex justify-end space-x-2 p-2">
<Link href={`mailto:${selectedEmail}`}>
<Button
color="secondary"
@@ -278,13 +278,14 @@ describe("Booking Actions", () => {
expect(rerouteAction).toBeDefined();
});
it("should include reassign action for round robin events", () => {
it("should include reassign action for round robin events with no host groups", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [],
},
},
});
@@ -294,6 +295,60 @@ describe("Booking Actions", () => {
expect(reassignAction).toBeDefined();
});
it("should include reassign action for round robin events with one host group", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [{ id: "group-1", name: "Group 1" }],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeDefined();
});
it("should exclude reassign action for round robin events with more than one host groups", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [
{ id: "group-1", name: "Group 1" },
{ id: "group-2", name: "Group 2" },
],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeUndefined();
});
it("should exclude reassign action for non-round-robin events", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.COLLECTIVE,
hostGroups: [],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeUndefined();
});
it("should exclude add_members when guests are disabled", () => {
const context = createMockContext({
booking: {
@@ -146,8 +146,9 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
icon: "user-plus",
disabled: false,
},
// Reassign (if round robin)
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN
// Reassign if round robin with no or one host groups
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
(!booking.eventType.hostGroups || booking.eventType.hostGroups?.length <= 1)
? {
id: "reassign",
label: t("reassign"),
@@ -120,7 +120,7 @@
"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.",
"round_robin_host_unavailable_for_booking": "Round Robin host is unavailable for booking.",
"help": "Help",
"price": "Price",
"paid": "Paid",
@@ -349,6 +349,7 @@
"available_apps_lower_case": "Available apps",
"available_apps_desc": "View popular apps below and explore more in our <0>App Store</0>",
"fixed_host_helper": "Add anyone who needs to attend the event. <0>Learn more</0>",
"round_robin_groups_helper": "People in a group take turns, one person of each group will show up for the event.",
"round_robin_helper": "People in the group take turns and only one person will show up for the event.",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
"finish": "Finish",
@@ -3362,6 +3363,7 @@
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
"rr_load_balancing_disabled_with_groups": "Load Balancing is not available with Round Robin Groups",
"round_robin_settings_description": "Customize the default round robin settings for this team",
"round_robin_settings_updated_successfully": "Round Robin settings updated successfully",
"booking_creation_time": "Booking Creation Time",
@@ -3378,6 +3380,7 @@
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
"restriction_schedule_not_found_error": "Restriction schedule not found",
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
"add_group": "Add Group",
"routing_funnel": "Routing Funnel",
"routing_funnel_total_submissions": "Total Submissions",
"routing_funnel_successful_routings": "Successful Routings",
+6 -5
View File
@@ -1,4 +1,5 @@
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
import { constantsScenarios } from "@calcom/lib/__mocks__/constants";
import {
getDate,
@@ -23,12 +24,12 @@ import { expect, expectedSlotsForSchedule } from "./getSchedule/expects";
import { setupAndTeardown } from "./getSchedule/setupAndTeardown";
import { timeTravelToTheBeginningOfToday } from "./getSchedule/utils";
vi.mock("@calcom/lib/constants", () => ({
IS_PRODUCTION: true,
constantsScenarios.set({
IS_PRODUCTION: true as any,
WEBAPP_URL: "http://localhost:3000",
RESERVED_SUBDOMAINS: ["auth", "docs"],
SINGLE_ORG_SLUG: "",
}));
RESERVED_SUBDOMAINS: ["auth", "docs"] as any,
SINGLE_ORG_SLUG: "" as any,
});
describe("getSchedule", () => {
const availableSlotsService = getAvailableSlotsService();
@@ -121,6 +121,7 @@ type InputHost = {
userId: number;
isFixed?: boolean;
scheduleId?: number | null;
groupId?: string | null;
};
type InputSelectedSlot = {
@@ -215,6 +216,7 @@ export type InputEventType = {
useEventLevelSelectedCalendars?: boolean;
users?: { id: number }[];
hosts?: InputHost[];
hostGroups?: { id: string; name: string }[];
schedulingType?: SchedulingType;
parent?: { id: number };
beforeEventBuffer?: number;
@@ -290,6 +292,17 @@ export const Timezones = {
async function addHostsToDb(eventTypes: InputEventType[]) {
for (const eventType of eventTypes) {
// Create host groups first if they exist
if (eventType.hostGroups?.length) {
await prismock.hostGroup.createMany({
data: eventType.hostGroups.map((group) => ({
id: group.id, // Preserve the input ID
name: group.name,
eventTypeId: eventType.id,
})),
});
}
if (!eventType.hosts?.length) continue;
for (const host of eventType.hosts) {
const data: Prisma.HostCreateInput = {
@@ -311,6 +324,13 @@ async function addHostsToDb(eventTypes: InputEventType[]) {
},
}
: undefined,
group: host.groupId
? {
connect: {
id: host.groupId,
},
}
: undefined,
};
await prismock.host.create({
@@ -363,6 +383,7 @@ export async function addEventTypesToDb(
workflows: true,
destinationCalendar: true,
schedule: true,
hostGroups: true,
},
});
@@ -473,6 +494,7 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
workflows: [],
users,
hosts,
hostGroups: eventType.hostGroups || [],
destinationCalendar: eventType.destinationCalendar
? {
create: eventType.destinationCalendar,
File diff suppressed because it is too large. Load diff
@@ -5,7 +5,7 @@ const rule = createRule({
create(context) {
// Track imported names from @calcom/lib/webstorage
const safeImportedNames = new Set<string>();
return {
ImportDeclaration(node) {
// Check if this is an import from the safe webstorage module
@@ -18,11 +18,11 @@ const rule = createRule({
});
}
},
CallExpression(node) {
const webStorages = ["localStorage", "sessionStorage"];
const callee = node.callee;
// Check for window.localStorage or window.sessionStorage
if (
// Can't figure out how to fix this TS issue
@@ -40,7 +40,7 @@ const rule = createRule({
messageId: "possible-issue-with-embed",
});
}
// Check for direct localStorage or sessionStorage usage
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -63,11 +63,11 @@ const rule = createRule({
});
}
},
// Also check for property access like localStorage.length
MemberExpression(node) {
const webStorages = ["localStorage", "sessionStorage"];
// Check for direct property access on localStorage/sessionStorage
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -86,7 +86,7 @@ const rule = createRule({
messageId: "possible-issue-with-embed",
});
}
// Check for window.localStorage/sessionStorage property access
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -44,6 +44,7 @@ import {
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import EventManager, { placeholderCreatedEvent } from "@calcom/lib/EventManager";
import { handleAnalyticsEvents } from "@calcom/lib/analyticsManager/handleAnalyticsEvents";
import { groupHostsByGroupId } from "@calcom/lib/bookings/hostGroupUtils";
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import {
@@ -281,9 +282,7 @@ export const buildEventForTeamEventType = async ({
const fixedUsers = users.filter((user) => user.isFixed);
const nonFixedUsers = users.filter((user) => !user.isFixed);
const filteredUsers =
schedulingType === SchedulingType.ROUND_ROBIN
? [...fixedUsers, ...(nonFixedUsers.length > 0 ? [nonFixedUsers[0]] : [])]
: users;
schedulingType === SchedulingType.ROUND_ROBIN ? [...fixedUsers, ...nonFixedUsers] : users;
// Organizer or user owner of this event type it's not listed as a team member.
const teamMemberPromises = filteredUsers
@@ -825,11 +824,17 @@ async function handler(
}
}
const luckyUserPool: IsFixedAwareUser[] = [];
const fixedUserPool: IsFixedAwareUser[] = [];
const nonFixedUsers: IsFixedAwareUser[] = [];
availableUsers.forEach((user) => {
user.isFixed ? fixedUserPool.push(user) : luckyUserPool.push(user);
user.isFixed ? fixedUserPool.push(user) : nonFixedUsers.push(user);
});
// Group non-fixed users by their group IDs
const luckyUserPools = groupHostsByGroupId({
hosts: nonFixedUsers,
hostGroups: eventType.hostGroups,
});
const notAvailableLuckyUsers: typeof users = [];
@@ -838,81 +843,88 @@ async function handler(
"Computed available users",
safeStringify({
availableUsers: availableUsers.map((user) => user.id),
luckyUserPool: luckyUserPool.map((user) => user.id),
luckyUserPools: Object.fromEntries(
Object.entries(luckyUserPools).map(([groupId, users]) => [groupId, users.map((user) => user.id)])
),
})
);
const luckyUsers: typeof users = [];
// loop through all non-fixed hosts and get the lucky users
// This logic doesn't run when contactOwner is used because in that case, luckUsers.length === 1
while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) {
const freeUsers = luckyUserPool.filter(
(user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id)
);
// no more freeUsers after subtracting notAvailableLuckyUsers from luckyUsers :(
if (freeUsers.length === 0) break;
assertNonEmptyArray(freeUsers); // make sure TypeScript knows it too with an assertion; the error will never be thrown.
// freeUsers is ensured
for (const [groupId, luckyUserPool] of Object.entries(luckyUserPools)) {
let luckUserFound = false;
while (luckyUserPool.length > 0 && !luckUserFound) {
const freeUsers = luckyUserPool.filter(
(user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id)
);
// no more freeUsers after subtracting notAvailableLuckyUsers from luckyUsers :(
if (freeUsers.length === 0) break;
assertNonEmptyArray(freeUsers); // make sure TypeScript knows it too with an assertion; the error will never be thrown.
// freeUsers is ensured
const userIdsSet = new Set(users.map((user) => user.id));
const firstUserOrgId = await getOrgIdFromMemberOrTeamId({
memberId: eventTypeWithUsers.users[0].id ?? null,
teamId: eventType.teamId,
});
const newLuckyUser = await getLuckyUser({
// find a lucky user that is not already in the luckyUsers array
availableUsers: freeUsers,
allRRHosts: (
await enrichHostsWithDelegationCredentials({
orgId: firstUserOrgId ?? null,
hosts: eventTypeWithUsers.hosts,
})
).filter((host) => !host.isFixed && userIdsSet.has(host.user.id)),
eventType,
routingFormResponse,
meetingStartTime: new Date(reqBody.start),
});
if (!newLuckyUser) {
break; // prevent infinite loop
}
if (
input.bookingData.isFirstRecurringSlot &&
eventType.schedulingType === SchedulingType.ROUND_ROBIN
) {
// for recurring round robin events check if lucky user is available for next slots
try {
for (
let i = 0;
i < input.bookingData.allRecurringDates.length &&
i < input.bookingData.numSlotsToCheckForAvailability;
i++
) {
const start = input.bookingData.allRecurringDates[i].start;
const end = input.bookingData.allRecurringDates[i].end;
const userIdsSet = new Set(users.map((user) => user.id));
const firstUserOrgId = await getOrgIdFromMemberOrTeamId({
memberId: eventTypeWithUsers.users[0].id ?? null,
teamId: eventType.teamId,
});
const newLuckyUser = await getLuckyUser({
// find a lucky user that is not already in the luckyUsers array
availableUsers: freeUsers,
// only hosts from the same group
allRRHosts: (
await enrichHostsWithDelegationCredentials({
orgId: firstUserOrgId ?? null,
hosts: eventTypeWithUsers.hosts,
})
).filter((host) => !host.isFixed && userIdsSet.has(host.user.id) && host.groupId === groupId),
eventType,
routingFormResponse,
meetingStartTime: new Date(reqBody.start),
});
if (!newLuckyUser) {
break; // prevent infinite loop
}
if (
input.bookingData.isFirstRecurringSlot &&
eventType.schedulingType === SchedulingType.ROUND_ROBIN
) {
// for recurring round robin events check if lucky user is available for next slots
try {
for (
let i = 0;
i < input.bookingData.allRecurringDates.length &&
i < input.bookingData.numSlotsToCheckForAvailability;
i++
) {
const start = input.bookingData.allRecurringDates[i].start;
const end = input.bookingData.allRecurringDates[i].end;
await ensureAvailableUsers(
{ ...eventTypeWithUsers, users: [newLuckyUser] },
{
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking,
},
loggerWithEventDetails,
shouldServeCache
await ensureAvailableUsers(
{ ...eventTypeWithUsers, users: [newLuckyUser] },
{
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking,
},
loggerWithEventDetails,
shouldServeCache
);
}
// if no error, then lucky user is available for the next slots
luckyUsers.push(newLuckyUser);
luckUserFound = true;
} catch {
notAvailableLuckyUsers.push(newLuckyUser);
loggerWithEventDetails.info(
`Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.`
);
}
// if no error, then lucky user is available for the next slots
} else {
luckyUsers.push(newLuckyUser);
} catch {
notAvailableLuckyUsers.push(newLuckyUser);
loggerWithEventDetails.info(
`Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.`
);
luckUserFound = true;
}
} else {
luckyUsers.push(newLuckyUser);
}
}
@@ -921,8 +933,22 @@ async function handler(
throw new Error(ErrorCode.FixedHostsUnavailableForBooking);
}
const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed);
const hostGroups = groupHostsByGroupId({
hosts: roundRobinHosts,
hostGroups: eventType.hostGroups,
});
// Filter out host groups that have no hosts in them
const nonEmptyHostGroups = Object.fromEntries(
Object.entries(hostGroups).filter(([groupId, hosts]) => hosts.length > 0)
);
// If there are RR hosts, we need to find a lucky user
if ([...qualifiedRRUsers, ...additionalFallbackRRUsers].length > 0 && luckyUsers.length === 0) {
if (
[...qualifiedRRUsers, ...additionalFallbackRRUsers].length > 0 &&
luckyUsers.length !== (Object.keys(nonEmptyHostGroups).length || 1)
) {
throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking);
}
@@ -933,7 +959,9 @@ async function handler(
...troubleshooterData,
luckyUsers: luckyUsers.map((u) => u.id),
fixedUsers: fixedUserPool.map((u) => u.id),
luckyUserPool: luckyUserPool.map((u) => u.id),
luckyUserPool: Object.values(luckyUserPools)
.flat()
.map((u) => u.id),
};
} else if (
input.bookingData.allRecurringDates &&
@@ -135,6 +135,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
priority: true,
weight: true,
createdAt: true,
groupId: true,
user: {
select: {
credentials: {
@@ -177,6 +178,12 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
assignRRMembersUsingSegment: true,
rrSegmentQueryValue: true,
useEventLevelSelectedCalendars: true,
hostGroups: {
select: {
id: true,
name: true,
},
},
},
});
@@ -206,6 +213,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
bookingFields: getBookingFieldsWithSystemFields({ ...restEventType, isOrgTeamEvent }),
rrSegmentQueryValue: rrSegmentQueryValueSchema.parse(eventType.rrSegmentQueryValue) ?? null,
isDynamic: false,
hostGroups: eventType.hostGroups || [],
};
} catch (error) {
if (error instanceof Error && error.message.includes("Record to update not found")) {
@@ -85,12 +85,13 @@ const loadUsersByEventType = async (eventType: EventType): Promise<NewBookingEve
eventType,
hosts: hosts ?? fallbackHosts,
});
return matchingHosts.map(({ user, isFixed, priority, weight, createdAt }) => ({
return matchingHosts.map(({ user, isFixed, priority, weight, createdAt, groupId }) => ({
...user,
isFixed,
priority,
weight,
createdAt,
groupId,
}));
};
@@ -139,7 +139,7 @@ describe("buildEventForTeamEventType", () => {
expect(memberEmails).toContain("fixed@example.com");
expect(memberEmails).toContain("nonfixed1@example.com");
expect(memberEmails).not.toContain("nonfixed2@example.com");
expect(memberEmails).toContain("nonfixed2@example.com");
});
it("builds a team with fallback name and id", async () => {
@@ -0,0 +1,652 @@
import {
getBooker,
TestData,
getOrganizer,
createBookingScenario,
Timezones,
getScenarioData,
mockSuccessfulVideoMeetingCreation,
BookingLocations,
getDate,
getMockBookingAttendee,
getGoogleCalendarCredential,
mockCalendarToHaveNoBusySlots,
} 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, test, vi, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
describe("Round Robin handleNewBooking", () => {
setupAndTeardown();
describe("Round Robin with groups", () => {
test("Books one host from each round robin group", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const teamMemberOne = [
{
name: "Team Member One",
username: "other-team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-one@example.com",
id: 102,
schedule: TestData.schedules.IstMorningShift,
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "other-team-member-1@google-calendar.com",
},
},
];
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "organizer@google-calendar.com",
},
});
const teamMembers = [
{
name: "Team Member 1",
username: "team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 2",
username: "team-member-2",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-2@example.com",
id: 103,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 3",
username: "team-member-3",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-3@example.com",
id: 104,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 4",
username: "team-member-4",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-4@example.com",
id: 105,
schedules: [TestData.schedules.IstEveningShift],
},
];
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
schedulingType: SchedulingType.ROUND_ROBIN,
length: 15,
users: [
{ id: teamMembers[0].id },
{ id: teamMembers[1].id },
{ id: teamMembers[2].id },
{ id: teamMembers[3].id },
],
hosts: [
{ userId: teamMembers[0].id, isFixed: false, groupId: "group-1" },
{ userId: teamMembers[1].id, isFixed: false, groupId: "group-1" },
{ userId: teamMembers[2].id, isFixed: false, groupId: "group-2" },
{ userId: teamMembers[3].id, isFixed: false, groupId: "group-2" },
],
hostGroups: [
{ id: "group-1", name: "Group 1" },
{ id: "group-2", name: "Group 2" },
],
schedule: TestData.schedules.IstMorningShift,
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "event-type-1@google-calendar.com",
},
},
],
organizer,
usersApartFromOrganizer: teamMembers,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
// Try booking the first available free timeslot in both the users' schedules
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
// Verify that the booking was created successfully
expect(createdBooking).toBeDefined();
expect(createdBooking.responses).toEqual(
expect.objectContaining({
email: booker.email,
name: booker.name,
})
);
// Verify that lucky users were selected (one from each group)
expect(createdBooking.luckyUsers).toBeDefined();
expect(createdBooking.luckyUsers).toHaveLength(2);
// Verify that the selected users are from different groups
const selectedUserIds = createdBooking.luckyUsers;
const group1UserIds = [teamMembers[0].id, teamMembers[1].id]; // group-1
const group2UserIds = [teamMembers[2].id, teamMembers[3].id]; // group-2
// Check that one user is from group-1 and one is from group-2
const hasGroup1User = selectedUserIds.some((id) => group1UserIds.includes(id));
const hasGroup2User = selectedUserIds.some((id) => group2UserIds.includes(id));
expect(hasGroup1User).toBe(true);
expect(hasGroup2User).toBe(true);
// Verify that the booking has the correct attendees
expect(createdBooking.attendees).toHaveLength(2);
expect(createdBooking.attendees[0].email).toBe(booker.email);
// The second attendee should be one of the selected lucky users
const secondAttendeeEmail = createdBooking.attendees[1].email;
const selectedUserEmails = selectedUserIds.map(
(id) => teamMembers.find((member) => member.id === id)?.email
);
expect(selectedUserEmails).toContain(secondAttendeeEmail);
});
test("Throws error when one round robin group has no available hosts", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const teamMembers = [
{
name: "Team Member 1",
username: "team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 2",
username: "team-member-2",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-2@example.com",
id: 103,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 3",
username: "team-member-3",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-3@example.com",
id: 104,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 4",
username: "team-member-4",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-4@example.com",
id: 105,
schedules: [TestData.schedules.IstEveningShift],
},
];
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "organizer@google-calendar.com",
},
});
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
schedulingType: SchedulingType.ROUND_ROBIN,
length: 15,
users: [
{ id: teamMembers[0].id },
{ id: teamMembers[1].id },
{ id: teamMembers[2].id },
{ id: teamMembers[3].id },
],
hosts: [
{ userId: teamMembers[0].id, isFixed: false, groupId: "group-1" },
{ userId: teamMembers[1].id, isFixed: false, groupId: "group-1" },
{ userId: teamMembers[2].id, isFixed: false, groupId: "group-2" },
{ userId: teamMembers[3].id, isFixed: false, groupId: "group-2" },
],
hostGroups: [
{ id: "group-1", name: "Group 1" },
{ id: "group-2", name: "Group 2" },
],
schedule: TestData.schedules.IstMorningShift,
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "event-type-1@google-calendar.com",
},
},
],
organizer,
usersApartFromOrganizer: teamMembers,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
// Add existing bookings to make only group-2 hosts busy
bookings: [
{
userId: teamMembers[0].id, // Team Member 1 (group-1)
eventTypeId: 1,
startTime: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
endTime: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
status: "ACCEPTED",
attendees: [
{
email: "existing-booker-1@example.com",
},
],
},
{
userId: teamMembers[1].id, // Team Member 2 (group-1)
eventTypeId: 1,
startTime: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
endTime: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
status: "ACCEPTED",
attendees: [
{
email: "existing-booker-2@example.com",
},
],
},
],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: appStoreMetadata.dailyvideo.dirName || "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
// Expect handleNewBooking to throw an error because group-2 has no available hosts
await expect(
handleNewBooking({
bookingData: mockBookingData,
})
).rejects.toThrow(ErrorCode.RoundRobinHostsUnavailableForBooking);
});
test("Creates successful booking even when one group has no hosts", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const teamMembers = [
{
name: "Team Member 1",
username: "team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 2",
username: "team-member-2",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-2@example.com",
id: 103,
schedules: [TestData.schedules.IstEveningShift],
},
];
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
defaultScheduleId: null,
schedules: [TestData.schedules.IstMorningShift],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "organizer@google-calendar.com",
},
});
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
schedulingType: SchedulingType.ROUND_ROBIN,
length: 15,
users: [{ id: teamMembers[0].id }, { id: teamMembers[1].id }],
hosts: [
{ userId: teamMembers[0].id, isFixed: false, groupId: "group-1" },
{ userId: teamMembers[1].id, isFixed: false, groupId: "group-1" },
],
hostGroups: [
{ id: "group-1", name: "Group 1" },
{ id: "group-2", name: "Group 2" }, // Empty group with no hosts
],
schedule: TestData.schedules.IstMorningShift,
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "event-type-1@google-calendar.com",
},
},
],
organizer,
usersApartFromOrganizer: teamMembers,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
// Verify that the booking was created successfully
expect(createdBooking).toBeDefined();
expect(createdBooking.responses).toEqual(
expect.objectContaining({
email: booker.email,
name: booker.name,
})
);
// Verify that lucky users were selected (only from group-1 since group-2 has no hosts)
expect(createdBooking.luckyUsers).toBeDefined();
expect(createdBooking.luckyUsers).toHaveLength(1);
// Verify that the selected user is from group-1
const selectedUserId = createdBooking.luckyUsers[0];
const group1UserIds = [teamMembers[0].id, teamMembers[1].id]; // group-1
expect(group1UserIds).toContain(selectedUserId);
// Verify that the booking has the correct attendees
expect(createdBooking.attendees).toHaveLength(1);
expect(createdBooking.attendees[0].email).toBe(booker.email);
// Verify that the booking user is the selected lucky user
expect(createdBooking.userId).toBe(selectedUserId);
});
});
describe("Seated Round Robin Event", () => {
test("For second seat booking, organizer remains the same with no team members included", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const EventManager = (await import("@calcom/lib/EventManager")).default;
const eventManagerSpy = vi.spyOn(EventManager.prototype, "updateCalendarAttendees");
const booker = getBooker({
email: "seat2@example.com",
name: "Seat 2",
});
const assignedHost = getOrganizer({
name: "Assigned Host",
email: "assigned-host@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
});
const teamMembers = [
{
name: "Team Member 1",
username: "team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 2",
username: "team-member-2",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-2@example.com",
id: 103,
schedules: [TestData.schedules.IstEveningShift],
},
];
const bookingId = 1;
const bookingUid = "abc123";
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const bookingStartTime = `${plus1DateString}T04:00:00Z`;
const bookingEndTime = `${plus1DateString}T04:30:00Z`;
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slug: "seated-round-robin-event",
slotInterval: 30,
length: 30,
schedulingType: SchedulingType.ROUND_ROBIN,
users: [{ id: assignedHost.id }, { id: teamMembers[0].id }, { id: teamMembers[1].id }],
hosts: [
{ userId: assignedHost.id, isFixed: false },
{ userId: teamMembers[0].id, isFixed: false },
{ userId: teamMembers[1].id, isFixed: false },
],
seatsPerTimeSlot: 3,
seatsShowAttendees: false,
},
],
bookings: [
{
id: bookingId,
uid: bookingUid,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: bookingStartTime,
endTime: bookingEndTime,
userId: assignedHost.id, // This is the assigned host for the booking
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
credentialId: null,
},
],
attendees: [
getMockBookingAttendee({
id: 1,
name: "Seat 1",
email: "seat1@test.com",
locale: "en",
timeZone: "America/Toronto",
bookingSeat: {
referenceUid: "booking-seat-1",
data: {},
},
}),
],
},
],
organizer: assignedHost,
usersApartFromOrganizer: [...teamMembers],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const reqBookingUser = "seatedAttendee";
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
bookingUid: bookingUid,
user: reqBookingUser,
},
});
await handleNewBooking({
bookingData: mockBookingData,
});
expect(eventManagerSpy).toHaveBeenCalled();
const calendarEvent = eventManagerSpy.mock.calls[0][0];
expect(calendarEvent.organizer.email).toBe(assignedHost.email);
expect(calendarEvent.team?.members).toBeDefined();
expect(calendarEvent.team?.members.length).toBe(0);
const teamMemberEmails = calendarEvent.team?.members.map((member) => member.email);
expect(teamMemberEmails).not.toContain(teamMembers[0].email);
expect(teamMemberEmails).not.toContain(teamMembers[1].email);
});
});
});
@@ -1,172 +0,0 @@
import {
getBooker,
TestData,
getOrganizer,
createBookingScenario,
Timezones,
getScenarioData,
mockSuccessfulVideoMeetingCreation,
BookingLocations,
getDate,
getMockBookingAttendee,
} 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, test, vi, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
describe("Seated Round Robin Events", () => {
setupAndTeardown();
test("For second seat booking, organizer remains the same with no team members included", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const EventManager = (await import("@calcom/lib/EventManager")).default;
const eventManagerSpy = vi.spyOn(EventManager.prototype, "updateCalendarAttendees");
const booker = getBooker({
email: "seat2@example.com",
name: "Seat 2",
});
const assignedHost = getOrganizer({
name: "Assigned Host",
email: "assigned-host@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
});
const teamMembers = [
{
name: "Team Member 1",
username: "team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 2",
username: "team-member-2",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-2@example.com",
id: 103,
schedules: [TestData.schedules.IstEveningShift],
},
];
const bookingId = 1;
const bookingUid = "abc123";
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const bookingStartTime = `${plus1DateString}T04:00:00Z`;
const bookingEndTime = `${plus1DateString}T04:30:00Z`;
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slug: "seated-round-robin-event",
slotInterval: 30,
length: 30,
schedulingType: SchedulingType.ROUND_ROBIN,
users: [{ id: assignedHost.id }, { id: teamMembers[0].id }, { id: teamMembers[1].id }],
hosts: [
{ userId: assignedHost.id, isFixed: false },
{ userId: teamMembers[0].id, isFixed: false },
{ userId: teamMembers[1].id, isFixed: false },
],
seatsPerTimeSlot: 3,
seatsShowAttendees: false,
},
],
bookings: [
{
id: bookingId,
uid: bookingUid,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: bookingStartTime,
endTime: bookingEndTime,
userId: assignedHost.id, // This is the assigned host for the booking
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
credentialId: null,
},
],
attendees: [
getMockBookingAttendee({
id: 1,
name: "Seat 1",
email: "seat1@test.com",
locale: "en",
timeZone: "America/Toronto",
bookingSeat: {
referenceUid: "booking-seat-1",
data: {},
},
}),
],
},
],
organizer: assignedHost,
usersApartFromOrganizer: [...teamMembers],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const reqBookingUser = "seatedAttendee";
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
bookingUid: bookingUid,
user: reqBookingUser,
},
});
await handleNewBooking({
bookingData: mockBookingData,
});
expect(eventManagerSpy).toHaveBeenCalled();
const calendarEvent = eventManagerSpy.mock.calls[0][0];
expect(calendarEvent.organizer.email).toBe(assignedHost.email);
expect(calendarEvent.team?.members).toBeDefined();
expect(calendarEvent.team?.members.length).toBe(0);
const teamMemberEmails = calendarEvent.team?.members.map((member) => member.email);
expect(teamMemberEmails).not.toContain(teamMembers[0].email);
expect(teamMemberEmails).not.toContain(teamMembers[1].email);
});
});
@@ -41,6 +41,7 @@ export type IsFixedAwareUser = User & {
weight?: number;
userLevelSelectedCalendars: SelectedCalendar[];
allSelectedCalendars: SelectedCalendar[];
groupId?: string | null;
availabilityData?: GetUserAvailabilityResult;
};
@@ -91,6 +91,13 @@ export const roundRobinManualReassignment = async ({
throw new Error("Event type not found");
}
if (eventType.hostGroups && eventType.hostGroups.length > 1) {
roundRobinReassignLogger.error(
`Event type ${eventTypeId} has more than one round robin group, reassignment is not allowed`
);
throw new Error("Reassignment not allowed with more than one round robin group");
}
const eventTypeHosts = eventType.hosts.length
? eventType.hosts
: eventType.users.map((user) => ({
@@ -100,6 +107,7 @@ export const roundRobinManualReassignment = async ({
weight: 100,
schedule: null,
createdAt: new Date(0), // use earliest possible date as fallback
groupId: null,
}));
const fixedHost = eventTypeHosts.find((host) => host.isFixed);
@@ -89,6 +89,13 @@ export const roundRobinReassignment = async ({
throw new Error("Event type not found");
}
if (eventType.hostGroups && eventType.hostGroups.length > 1) {
logger.error(
`Event type ${eventTypeId} has more than one round robin group, reassignment is not allowed`
);
throw new Error("Reassignment not allowed with more than one round robin group");
}
eventType.hosts = eventType.hosts.length
? eventType.hosts
: eventType.users.map((user) => ({
@@ -98,6 +105,7 @@ export const roundRobinReassignment = async ({
weight: 100,
schedule: null,
createdAt: new Date(0), // use earliest possible date as fallback
groupId: null,
}));
if (eventType.hosts.length === 0) {
@@ -61,6 +61,7 @@ const CheckedHostField = ({
onChange,
helperText,
isRRWeightsEnabled,
groupId,
customClassNames,
...rest
}: {
@@ -72,6 +73,7 @@ const CheckedHostField = ({
options?: Options<CheckedSelectOption>;
helperText?: React.ReactNode | string;
isRRWeightsEnabled?: boolean;
groupId: string | null;
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
return (
<div className="flex flex-col rounded-md">
@@ -88,6 +90,7 @@ const CheckedHostField = ({
priority: option.priority ?? 2,
weight: option.weight ?? 100,
scheduleId: option.defaultScheduleId,
groupId: option.groupId,
}))
);
}}
@@ -97,7 +100,13 @@ const CheckedHostField = ({
const option = options.find((member) => member.value === host.userId.toString());
if (!option) return acc;
acc.push({ ...option, priority: host.priority ?? 2, isFixed, weight: host.weight ?? 100 });
acc.push({
...option,
priority: host.priority ?? 2,
isFixed,
weight: host.weight ?? 100,
groupId: host.groupId,
});
return acc;
}, [] as CheckedSelectOption[])}
@@ -106,6 +115,7 @@ const CheckedHostField = ({
placeholder={placeholder}
isRRWeightsEnabled={isRRWeightsEnabled}
customClassNames={customClassNames}
groupId={groupId}
{...rest}
/>
</div>
@@ -178,6 +188,7 @@ export type AddMembersWithSwitchProps = {
isRRWeightsEnabled?: boolean;
teamId: number;
isSegmentApplicable?: boolean;
groupId: string | null;
"data-testid"?: string;
customClassNames?: AddMembersWithSwitchCustomClassNames;
};
@@ -244,6 +255,7 @@ export function AddMembersWithSwitch({
isRRWeightsEnabled,
teamId,
isSegmentApplicable,
groupId,
customClassNames,
...rest
}: AddMembersWithSwitchProps) {
@@ -273,13 +285,15 @@ export function AddMembersWithSwitch({
case AssignmentState.TEAM_MEMBERS_IN_SEGMENT_ENABLED:
return (
<>
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={onActive}
onInactive={onAssignAllTeamMembersInactive}
customClassNames={customClassNames?.assingAllTeamMembers}
/>
{!groupId && (
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={onActive}
onInactive={onAssignAllTeamMembersInactive}
customClassNames={customClassNames?.assingAllTeamMembers}
/>
)}
{assignmentState !== AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE && (
<div className="mt-2">
@@ -300,7 +314,7 @@ export function AddMembersWithSwitch({
return (
<>
<div className="mb-2">
{assignmentState === AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE && (
{assignmentState === AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE && !groupId && (
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
@@ -317,9 +331,15 @@ export function AddMembersWithSwitch({
onChange={onChange}
isFixed={isFixed}
className="mb-2"
options={teamMembers.sort(sortByLabel)}
options={teamMembers
.map((member) => ({
...member,
groupId: groupId,
}))
.sort(sortByLabel)}
placeholder={placeholder ?? t("add_attendees")}
isRRWeightsEnabled={isRRWeightsEnabled}
groupId={groupId}
customClassNames={customClassNames?.teamMemberSelect}
/>
</div>
@@ -340,7 +360,7 @@ const AddMembersWithSwitchWrapper = ({
[isPlatform]
);
return (
<div className="rounded-md ">
<div className="rounded-md">
<div className={`flex flex-col rounded-md pb-2 pt-6 ${containerClassName}`}>
<AddMembersWithSwitchWrapped {...props} />
</div>
@@ -2,17 +2,18 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useState } from "react";
import type { Props } from "react-select";
import type { Options, Props } from "react-select";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import type { SelectClassNames } from "@calcom/features/eventtypes/lib/types";
import { getHostsFromOtherGroups } from "@calcom/lib/bookings/hostGroupUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui/components/icon";
import { Select } from "@calcom/ui/components/form";
import { Tooltip } from "@calcom/ui/components/tooltip";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import classNames from "@calcom/ui/classNames";
import { Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { PriorityDialogCustomClassNames, WeightDialogCustomClassNames } from "./HostEditDialogs";
import { PriorityDialog, WeightDialog } from "./HostEditDialogs";
@@ -26,6 +27,7 @@ export type CheckedSelectOption = {
isFixed?: boolean;
disabled?: boolean;
defaultScheduleId?: number | null;
groupId: string | null;
};
export type CheckedTeamSelectCustomClassNames = {
@@ -49,12 +51,15 @@ export const CheckedTeamSelect = ({
value = [],
isRRWeightsEnabled,
customClassNames,
groupId,
...props
}: Omit<Props<CheckedSelectOption, true>, "value" | "onChange"> & {
options?: Options<CheckedSelectOption>;
value?: readonly CheckedSelectOption[];
onChange: (value: readonly CheckedSelectOption[]) => void;
isRRWeightsEnabled?: boolean;
customClassNames?: CheckedTeamSelectCustomClassNames;
groupId: string | null;
}) => {
const isPlatform = useIsPlatform();
const [priorityDialogOpen, setPriorityDialogOpen] = useState(false);
@@ -65,6 +70,15 @@ export const CheckedTeamSelect = ({
const { t } = useLocale();
const [animationRef] = useAutoAnimate<HTMLUListElement>();
const valueFromGroup = groupId ? value.filter((host) => host.groupId === groupId) : value;
const handleSelectChange = (newValue: readonly CheckedSelectOption[]) => {
const otherGroupsHosts = getHostsFromOtherGroups(value, groupId);
const newValueAllGroups = [...otherGroupsHosts, ...newValue.map((host) => ({ ...host, groupId }))];
props.onChange(newValueAllGroups);
};
return (
<>
<Select
@@ -73,26 +87,30 @@ export const CheckedTeamSelect = ({
placeholder={props.placeholder || t("select")}
isSearchable={true}
options={options}
value={value}
value={valueFromGroup}
onChange={handleSelectChange}
isMulti
className={customClassNames?.hostsSelect?.select}
innerClassNames={customClassNames?.hostsSelect?.innerClassNames}
innerClassNames={{
...customClassNames?.hostsSelect?.innerClassNames,
control: "rounded-md",
}}
/>
{/* This class name conditional looks a bit odd but it allows a seamless transition when using autoanimate
- Slides down from the top instead of just teleporting in from nowhere*/}
<ul
className={classNames(
"mb-4 mt-3 rounded-md",
value.length >= 1 && "border-subtle border",
valueFromGroup.length >= 1 && "border-subtle border",
customClassNames?.selectedHostList?.container
)}
ref={animationRef}>
{value.map((option, index) => (
{valueFromGroup.map((option, index) => (
<>
<li
key={option.value}
className={classNames(
`flex px-3 py-2 ${index === value.length - 1 ? "" : "border-subtle border-b"}`,
`flex px-3 py-2 ${index === valueFromGroup.length - 1 ? "" : "border-subtle border-b"}`,
customClassNames?.selectedHostList?.listItem?.container
)}>
{!isPlatform && <Avatar size="sm" imageSrc={option.avatar} alt={option.label} />}
@@ -170,6 +188,7 @@ export const CheckedTeamSelect = ({
isOpenDialog={priorityDialogOpen}
setIsOpenDialog={setPriorityDialogOpen}
option={currentOption}
options={options}
onChange={props.onChange}
customClassNames={customClassNames?.priorityDialog}
/>
@@ -177,6 +196,7 @@ export const CheckedTeamSelect = ({
isOpenDialog={weightDialogOpen}
setIsOpenDialog={setWeightDialogOpen}
option={currentOption}
options={options}
onChange={props.onChange}
customClassNames={customClassNames?.weightDialog}
/>
@@ -143,7 +143,9 @@ export const EditWeightsForAllTeamMembers = ({
const handleSave = () => {
// Create a map of existing hosts for easy lookup
const existingHostsMap = new Map(value.map((host) => [host.userId.toString(), host]));
const existingHostsMap = new Map(
value.filter((host) => !host.isFixed).map((host) => [host.userId.toString(), host])
);
// Create the updated value by processing all team members
const updatedValue = teamMembers
@@ -156,6 +158,7 @@ export const EditWeightsForAllTeamMembers = ({
isFixed: existingHost?.isFixed ?? false,
priority: existingHost?.priority ?? 0,
weight: localWeights[member.value] ?? existingHost?.weight ?? 100,
groupId: existingHost?.groupId ?? null,
};
})
.filter(Boolean) as Host[];
@@ -238,7 +241,10 @@ export const EditWeightsForAllTeamMembers = ({
)
.filter((member) => {
// When assignAllTeamMembers is false, only include members that exist in value array
return assignAllTeamMembers || value.some((host) => host.userId === parseInt(member.value, 10));
return (
assignAllTeamMembers ||
value.some((host) => !host.isFixed && host.userId === parseInt(member.value, 10))
);
});
}, [teamMembers, localWeights, searchQuery, assignAllTeamMembers, value]);
@@ -25,6 +25,7 @@ export type Host = {
priority: number;
weight: number;
scheduleId?: number | null;
groupId: string | null;
};
export type CustomInputParsed = typeof customInputSchema._output;
@@ -1,6 +1,7 @@
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import type { Options } from "react-select";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import type {
@@ -9,6 +10,8 @@ import type {
InputClassNames,
SelectClassNames,
} from "@calcom/features/eventtypes/lib/types";
import { groupHostsByGroupId, getHostsFromOtherGroups } from "@calcom/lib/bookings/hostGroupUtils";
import { DEFAULT_GROUP_ID } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
@@ -24,6 +27,7 @@ interface IDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
option: CheckedSelectOption;
options: Options<CheckedSelectOption>;
onChange: (value: readonly CheckedSelectOption[]) => void;
}
@@ -37,7 +41,7 @@ export const PriorityDialog = (
}
) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, option, onChange, customClassNames } = props;
const { isOpenDialog, setIsOpenDialog, option, options, onChange, customClassNames } = props;
const { getValues } = useFormContext<FormValues>();
const priorityOptions = [
@@ -53,21 +57,46 @@ export const PriorityDialog = (
if (!!newPriority) {
const hosts: Host[] = getValues("hosts");
const isRRWeightsEnabled = getValues("isRRWeightsEnabled");
const updatedHosts = hosts
.filter((host) => !host.isFixed)
.map((host) => {
return {
...option,
value: host.userId.toString(),
priority: host.userId === parseInt(option.value, 10) ? newPriority.value : host.priority,
isFixed: false,
weight: host.weight,
};
});
const hostGroups = getValues("hostGroups");
const rrHosts = hosts.filter((host) => !host.isFixed);
const sortedHosts = updatedHosts.sort((a, b) => sortHosts(a, b, isRRWeightsEnabled));
const groupedHosts = groupHostsByGroupId({ hosts: rrHosts, hostGroups });
onChange(sortedHosts);
let sortedHostGroup: CheckedSelectOption[] = [];
const hostGroupToSort = groupedHosts[option.groupId ?? DEFAULT_GROUP_ID];
if (hostGroupToSort) {
sortedHostGroup = hostGroupToSort
.map((host) => {
return {
...option,
value: host.userId.toString(),
priority: host.userId === parseInt(option.value, 10) ? newPriority.value : host.priority,
isFixed: false,
weight: host.weight,
groupId: host.groupId,
userId: host.userId,
};
})
.sort((a, b) => sortHosts(a, b, isRRWeightsEnabled));
}
const otherGroupsHosts = getHostsFromOtherGroups(rrHosts, option.groupId);
const otherGroupsOptions = otherGroupsHosts.map((host) => {
return {
...option,
value: host.userId.toString(),
priority: host.priority,
weight: host.weight,
isFixed: host.isFixed,
groupId: host.groupId,
userId: host.userId,
};
});
const updatedHosts = [...otherGroupsOptions, ...sortedHostGroup];
onChange(updatedHosts);
}
setIsOpenDialog(false);
};
@@ -128,27 +157,75 @@ export type WeightDialogCustomClassNames = {
};
export const WeightDialog = (props: IDialog & { customClassNames?: WeightDialogCustomClassNames }) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, option, onChange, customClassNames } = props;
const { isOpenDialog, setIsOpenDialog, option, options, onChange, customClassNames } = props;
const { getValues } = useFormContext<FormValues>();
const [newWeight, setNewWeight] = useState<number | undefined>();
const setWeight = () => {
if (!!newWeight) {
const hosts: Host[] = getValues("hosts");
const updatedHosts = hosts
.filter((host) => !host.isFixed)
.map((host) => {
return {
...option,
value: host.userId.toString(),
priority: host.priority,
weight: host.userId === parseInt(option.value, 10) ? newWeight : host.weight,
isFixed: false,
};
});
const isRRWeightsEnabled = getValues("isRRWeightsEnabled");
const hostGroups = getValues("hostGroups");
const rrHosts = hosts.filter((host) => !host.isFixed);
const sortedHosts = updatedHosts.sort((a, b) => sortHosts(a, b, true));
onChange(sortedHosts);
const groupedHosts = groupHostsByGroupId({ hosts: rrHosts, hostGroups });
const updateHostWeight = (host: Host) => {
if (host.userId === parseInt(option.value, 10)) {
return { ...host, weight: newWeight };
}
return host;
};
// Sort hosts within the group
let sortedHostGroup: (Host & {
avatar: string;
label: string;
})[] = [];
const hostGroupToSort = groupedHosts[option.groupId ?? DEFAULT_GROUP_ID];
if (hostGroupToSort) {
sortedHostGroup = hostGroupToSort
.map((host) => {
const userOption = options.find((opt) => opt.value === host.userId.toString());
const updatedHost = updateHostWeight(host);
return {
...updatedHost,
avatar: userOption?.avatar ?? "",
label: userOption?.label ?? host.userId.toString(),
};
})
.sort((a, b) => sortHosts(a, b, isRRWeightsEnabled));
}
const updatedOptions = sortedHostGroup.map((host) => ({
avatar: host.avatar,
label: host.label,
value: host.userId.toString(),
priority: host.priority,
weight: host.weight,
isFixed: host.isFixed,
groupId: host.groupId,
}));
// Preserve hosts from other groups
const otherGroupsHosts = getHostsFromOtherGroups(rrHosts, option.groupId);
const otherGroupsOptions = otherGroupsHosts.map((host) => {
const userOption = options.find((opt) => opt.value === host.userId.toString());
return {
avatar: userOption?.avatar ?? "",
label: userOption?.label ?? host.userId.toString(),
value: host.userId.toString(),
priority: host.priority,
weight: host.weight,
isFixed: host.isFixed,
groupId: host.groupId,
};
});
const newFullValue = [...otherGroupsOptions, ...updatedOptions];
onChange(newFullValue);
}
setIsOpenDialog(false);
};
@@ -1,9 +1,10 @@
import type { TFunction } from "i18next";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ComponentProps, Dispatch, SetStateAction } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { Options } from "react-select";
import { v4 as uuidv4 } from "uuid";
import type { AddMembersWithSwitchCustomClassNames } from "@calcom/features/eventtypes/components/AddMembersWithSwitch";
import AddMembersWithSwitch, {
@@ -25,9 +26,11 @@ import ServerTrans from "@calcom/lib/components/ServerTrans";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RRTimestampBasis, SchedulingType } from "@calcom/prisma/enums";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Label } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";
import { Tooltip } from "@calcom/ui/components/tooltip";
@@ -162,6 +165,39 @@ const FixedHosts = ({
const [isDisabled, setIsDisabled] = useState(hasActiveFixedHosts);
const handleFixedHostsActivation = useCallback(() => {
const currentHosts = getValues("hosts");
setValue(
"hosts",
teamMembers.map((teamMember) => {
const host = currentHosts.find((host) => host.userId === parseInt(teamMember.value, 10));
return {
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: host?.priority ?? 2,
weight: host?.weight ?? 100,
// if host was already added, retain scheduleId and groupId
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
groupId: host?.groupId || null,
};
}),
{ shouldDirty: true }
);
}, [getValues, setValue, teamMembers]);
const handleFixedHostsToggle = useCallback(
(checked: boolean) => {
if (!checked) {
const rrHosts = getValues("hosts")
.filter((host) => !host.isFixed)
.sort((a, b) => (b.priority ?? 2) - (a.priority ?? 2));
setValue("hosts", rrHosts, { shouldDirty: true });
}
setIsDisabled(checked);
},
[getValues, setValue]
);
return (
<div className={classNames("mt-5 rounded-lg", customClassNames?.container)}>
{!isRoundRobinEvent ? (
@@ -185,6 +221,7 @@ const FixedHosts = ({
<div className="border-subtle rounded-b-md border border-t-0 px-6">
<AddMembersWithSwitch
teamId={teamId}
groupId={null}
teamMembers={teamMembers}
value={value}
onChange={onChange}
@@ -193,24 +230,7 @@ const FixedHosts = ({
automaticAddAllEnabled={!isRoundRobinEvent}
isFixed={true}
customClassNames={customClassNames?.addMembers}
onActive={() => {
const currentHosts = getValues("hosts");
setValue(
"hosts",
teamMembers.map((teamMember) => {
const host = currentHosts.find((host) => host.userId === parseInt(teamMember.value, 10));
return {
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: host?.priority ?? 2,
weight: host?.weight ?? 100,
// if host was already added, retain scheduleId
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
};
}),
{ shouldDirty: true }
);
}}
onActive={handleFixedHostsActivation}
/>
</div>
</>
@@ -225,19 +245,12 @@ const FixedHosts = ({
labelClassName={classNames("text-sm", customClassNames?.label)}
descriptionClassName={classNames("text-sm text-subtle", customClassNames?.description)}
switchContainerClassName={customClassNames?.container}
onCheckedChange={(checked) => {
if (!checked) {
const rrHosts = getValues("hosts")
.filter((host) => !host.isFixed)
.sort((a, b) => (b.priority ?? 2) - (a.priority ?? 2));
setValue("hosts", rrHosts, { shouldDirty: true });
}
setIsDisabled(checked);
}}
onCheckedChange={handleFixedHostsToggle}
childrenClassName={classNames("lg:ml-0", customClassNames?.children)}>
<div className="border-subtle flex flex-col gap-6 rounded-bl-md rounded-br-md border border-t-0 px-6">
<AddMembersWithSwitch
data-testid="fixed-hosts-select"
groupId={null}
placeholder={t("add_a_member")}
teamId={teamId}
teamMembers={teamMembers}
@@ -248,24 +261,7 @@ const FixedHosts = ({
setAssignAllTeamMembers={setAssignAllTeamMembers}
automaticAddAllEnabled={!isRoundRobinEvent}
isFixed={true}
onActive={() => {
const currentHosts = getValues("hosts");
setValue(
"hosts",
teamMembers.map((teamMember) => {
const host = currentHosts.find((host) => host.userId === parseInt(teamMember.value, 10));
return {
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: host?.priority ?? 2,
weight: host?.weight ?? 100,
// if host was already added, retain scheduleId
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
};
}),
{ shouldDirty: true }
);
}}
onActive={handleFixedHostsActivation}
/>
</div>
</SettingsToggle>
@@ -315,6 +311,154 @@ const RoundRobinHosts = ({
control,
name: "rrSegmentQueryValue",
});
const hostGroups = useWatch({
control,
name: "hostGroups",
});
const handleWeightsEnabledChange = (active: boolean, onChange: (value: boolean) => void) => {
onChange(active);
const allHosts = getValues("hosts");
const fixedHosts = allHosts.filter((host) => host.isFixed);
const rrHosts = allHosts.filter((host) => !host.isFixed);
const sortedRRHosts = rrHosts.sort((a, b) => sortHosts(a, b, active));
// Preserve fixed hosts when updating
setValue("hosts", [...fixedHosts, ...sortedRRHosts]);
};
const handleWeightsChange = (hosts: Host[]) => {
const allHosts = getValues("hosts");
const fixedHosts = allHosts.filter((host) => host.isFixed);
const sortedRRHosts = hosts.sort((a, b) => sortHosts(a, b, true));
// Preserve fixed hosts when updating
setValue("hosts", [...fixedHosts, ...sortedRRHosts], { shouldDirty: true });
};
const handleAddGroup = useCallback(() => {
const allHosts = getValues("hosts");
const currentRRHosts = allHosts.filter((host) => !host.isFixed);
const fixedHosts = allHosts.filter((host) => host.isFixed);
// If there are already hosts added and no group exists yet, create two groups
if (hostGroups?.length === 0 && currentRRHosts.length > 0) {
const firstGroup = { id: uuidv4(), name: "" };
const secondGroup = { id: uuidv4(), name: "" };
const updatedHostGroups = [firstGroup, secondGroup];
setValue("hostGroups", updatedHostGroups, { shouldDirty: true });
const updatedRRHosts = currentRRHosts.map((host) => {
if (!host.groupId && !host.isFixed) {
return { ...host, groupId: firstGroup.id };
}
return host;
});
setValue("hosts", [...fixedHosts, ...updatedRRHosts], { shouldDirty: true });
} else {
// If groups already exist, just add one more group
const newGroup = { id: uuidv4(), name: "" };
const updatedHostGroups = [...hostGroups, newGroup];
setValue("hostGroups", updatedHostGroups, { shouldDirty: true });
}
// Disable 'Add all team members' switch if enabled
if (assignAllTeamMembers) {
setValue("assignAllTeamMembers", false, { shouldDirty: true });
setAssignAllTeamMembers(false);
}
}, [hostGroups, getValues, setValue, assignAllTeamMembers, setAssignAllTeamMembers]);
const handleGroupNameChange = useCallback(
(groupId: string, newName: string) => {
const updatedHostGroups =
hostGroups?.map((g) => (g.id === groupId ? { ...g, name: newName } : g)) || [];
setValue("hostGroups", updatedHostGroups, { shouldDirty: true });
},
[hostGroups, setValue]
);
const handleRemoveGroup = useCallback(
(groupId: string) => {
// Remove the group from hostGroups
const updatedHostGroups = hostGroups?.filter((g) => g.id !== groupId) || [];
setValue("hostGroups", updatedHostGroups, { shouldDirty: true });
// Remove all hosts that belong to this group
const updatedHosts = value.filter((host) => host.groupId !== groupId);
onChange(updatedHosts);
setValue("hosts", updatedHosts, { shouldDirty: true });
},
[hostGroups, setValue, value, onChange]
);
const handleMembersActivation = useCallback(
(groupId: string | null) => {
const currentHosts = getValues("hosts");
setValue(
"hosts",
teamMembers.map((teamMember) => {
const host = currentHosts.find((host) => host.userId === parseInt(teamMember.value, 10));
return {
isFixed: false,
userId: parseInt(teamMember.value, 10),
priority: host?.priority ?? 2,
weight: host?.weight ?? 100,
// if host was already added, retain scheduleId and groupId
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
groupId: host?.groupId || groupId,
};
}),
{ shouldDirty: true }
);
},
[getValues, setValue, teamMembers]
);
const AddMembersWithSwitchComponent = ({
groupId,
containerClassName,
}: {
groupId: string | null;
containerClassName?: string;
}) => {
return (
<AddMembersWithSwitch
placeholder={t("add_a_member")}
teamId={teamId}
teamMembers={teamMembers}
value={value}
onChange={onChange}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
isSegmentApplicable={isSegmentApplicable}
automaticAddAllEnabled={true}
isRRWeightsEnabled={isRRWeightsEnabled}
isFixed={false}
groupId={groupId}
containerClassName={containerClassName || (assignAllTeamMembers ? "-mt-4" : "")}
onActive={() => handleMembersActivation(groupId)}
customClassNames={customClassNames?.addMembers}
/>
);
};
const UnassignedHostsGroup = () => {
const unassignedHosts = value.filter((host) => !host.isFixed && !host.groupId);
if (unassignedHosts.length === 0) {
return null;
}
return (
<div className="border-subtle my-4 rounded-md border p-4 pb-0">
<div className="-mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<span className="text-default text-sm font-medium">{`Group ${hostGroups.length + 1}`}</span>
</div>
</div>
<AddMembersWithSwitchComponent groupId={null} />
</div>
);
};
return (
<div className={classNames("rounded-lg")}>
@@ -323,16 +467,23 @@ const RoundRobinHosts = ({
"border-subtle mt-5 rounded-t-md border p-6 pb-5",
customClassNames?.container
)}>
<Label className={classNames("mb-1 text-sm font-semibold", customClassNames?.label)}>
{t("round_robin_hosts")}
</Label>
<p
className={classNames(
"text-subtle max-w-full break-words text-sm leading-tight",
customClassNames?.description
)}>
{t("round_robin_helper")}
</p>
<div className="flex items-center justify-between">
<div>
<Label className={classNames("mb-1 text-sm font-semibold", customClassNames?.label)}>
{t("round_robin_hosts")}
</Label>
<p
className={classNames(
"text-subtle max-w-full break-words text-sm leading-tight",
customClassNames?.description
)}>
{hostGroups?.length > 0 ? t("round_robin_groups_helper") : t("round_robin_helper")}
</p>
</div>
<Button color="secondary" size="sm" StartIcon="plus" onClick={handleAddGroup}>
{t("add_group")}
</Button>
</div>
</div>
<div className="border-subtle rounded-b-md border border-t-0 px-6 pt-4">
<>
@@ -346,19 +497,11 @@ const RoundRobinHosts = ({
switchContainerClassName={customClassNames?.enableWeights?.container}
labelClassName={customClassNames?.enableWeights?.label}
descriptionClassName={customClassNames?.enableWeights?.description}
onCheckedChange={(active) => {
onChange(active);
const rrHosts = getValues("hosts").filter((host) => !host.isFixed);
const sortedRRHosts = rrHosts.sort((a, b) => sortHosts(a, b, active));
setValue("hosts", sortedRRHosts);
}}>
onCheckedChange={(active) => handleWeightsEnabledChange(active, onChange)}>
<EditWeightsForAllTeamMembers
teamMembers={teamMembers}
value={value}
onChange={(hosts) => {
const sortedRRHosts = hosts.sort((a, b) => sortHosts(a, b, true));
setValue("hosts", sortedRRHosts, { shouldDirty: true });
}}
onChange={handleWeightsChange}
assignAllTeamMembers={assignAllTeamMembers}
assignRRMembersUsingSegment={assignRRMembersUsingSegment}
teamId={teamId}
@@ -368,39 +511,42 @@ const RoundRobinHosts = ({
)}
/>
</>
<AddMembersWithSwitch
placeholder={t("add_a_member")}
teamId={teamId}
teamMembers={teamMembers}
value={value}
onChange={onChange}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
isSegmentApplicable={isSegmentApplicable}
automaticAddAllEnabled={true}
isRRWeightsEnabled={isRRWeightsEnabled}
isFixed={false}
containerClassName={assignAllTeamMembers ? "-mt-4" : ""}
onActive={() => {
const currentHosts = getValues("hosts");
setValue(
"hosts",
teamMembers.map((teamMember) => {
const host = currentHosts.find((host) => host.userId === parseInt(teamMember.value, 10));
return {
isFixed: false,
userId: parseInt(teamMember.value, 10),
priority: host?.priority ?? 2,
weight: host?.weight ?? 100,
// if host was already added, retain scheduleId
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
};
}),
{ shouldDirty: true }
);
}}
customClassNames={customClassNames?.addMembers}
/>
{!hostGroups.length ? (
<AddMembersWithSwitchComponent groupId={hostGroups[0]?.id ?? null} />
) : (
<>
{/* Show unassigned hosts first */}
<UnassignedHostsGroup />
{/* Show all defined groups */}
{hostGroups.map((group, index) => {
const groupNumber = index + 1;
return (
<div key={index} className="border-subtle my-4 rounded-md border p-4 pb-0">
<div className="-mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<input
type="text"
value={group.name ?? ""}
onChange={(e) => handleGroupNameChange(group.id, e.target.value)}
className="border-none bg-transparent p-0 text-sm font-medium focus:outline-none focus:ring-0"
placeholder={`Group ${groupNumber}`}
/>
</div>
<button
type="button"
onClick={() => handleRemoveGroup(group.id)}
className="text-subtle hover:text-default rounded p-1">
<Icon name="x" className="h-4 w-4" />
</button>
</div>
<AddMembersWithSwitchComponent groupId={group.id} />
</div>
);
})}
</>
)}
</div>
</div>
);
@@ -507,16 +653,18 @@ const Hosts = ({
);
}, [schedulingType, setValue, getValues, submitCount]);
// To ensure existing host do not loose its scheduleId property, whenever a new host of same type is added.
// To ensure existing host do not loose its scheduleId and groupId properties, whenever a new host of same type is added.
// This is because the host is created from list option in CheckedHostField component.
const updatedHosts = (changedHosts: Host[]) => {
const existingHosts = getValues("hosts");
return changedHosts.map((newValue) => {
const existingHost = existingHosts.find((host: Host) => host.userId === newValue.userId);
return existingHost
? {
...newValue,
scheduleId: existingHost.scheduleId,
groupId: existingHost.groupId,
}
: newValue;
});
@@ -560,7 +708,7 @@ const Hosts = ({
teamMembers={teamMembers}
value={value}
onChange={(changeValue) => {
const hosts = [...value.filter((host: Host) => host.isFixed), ...updatedHosts(changeValue)];
const hosts = [...value.filter((host: Host) => host.isFixed), ...changeValue];
onChange(hosts);
}}
assignAllTeamMembers={assignAllTeamMembers}
@@ -633,11 +781,34 @@ export const EventTeamAssignmentTab = ({
setAssignAllTeamMembers(false);
};
const handleSchedulingTypeChange = useCallback(
(schedulingType: SchedulingType | undefined, onChange: (value: SchedulingType | undefined) => void) => {
if (schedulingType) {
onChange(schedulingType);
resetRROptions();
}
},
[setValue, setAssignAllTeamMembers]
);
const handleMaxLeadThresholdChange = (val: string, onChange: (value: number | null) => void) => {
if (val === "loadBalancing") {
onChange(3);
} else {
onChange(null);
}
};
const schedulingType = useWatch({
control,
name: "schedulingType",
});
const hostGroups = useWatch({
control,
name: "hostGroups",
});
return (
<div>
{team && !isManagedEventType && (
@@ -679,10 +850,7 @@ export const EventTeamAssignmentTab = ({
customClassNames?.assignmentType?.schedulingTypeSelect?.select
)}
innerClassNames={customClassNames?.assignmentType?.schedulingTypeSelect?.innerClassNames}
onChange={(val) => {
onChange(val?.value);
resetRROptions();
}}
onChange={(val) => handleSchedulingTypeChange(val?.value, onChange)}
/>
)}
/>
@@ -701,10 +869,7 @@ export const EventTeamAssignmentTab = ({
name="maxLeadThreshold"
render={({ field: { value, onChange } }) => (
<RadioArea.Group
onValueChange={(val) => {
if (val === "loadBalancing") onChange(3);
else onChange(null);
}}
onValueChange={(val) => handleMaxLeadThresholdChange(val, onChange)}
className="mt-1 flex flex-col gap-4">
<RadioArea.Item
value="maximizeAvailability"
@@ -716,11 +881,18 @@ export const EventTeamAssignmentTab = ({
</strong>
<p>{t("rr_distribution_method_availability_description")}</p>
</RadioArea.Item>
{!!(
eventType.team?.rrTimestampBasis &&
eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT
) ? (
<Tooltip content={t("rr_load_balancing_disabled")}>
{(eventType.team?.rrTimestampBasis &&
eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT) ||
hostGroups?.length > 1 ? (
<Tooltip
content={
!!(
eventType.team?.rrTimestampBasis &&
eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT
)
? t("rr_load_balancing_disabled")
: t("rr_load_balancing_disabled_with_groups")
}>
<div className="w-full">
<RadioArea.Item
value="loadBalancing"
@@ -30,6 +30,7 @@ export type Host = {
priority: number;
weight: number;
scheduleId?: number | null;
groupId: string | null;
};
export type TeamMember = {
value: string;
@@ -146,6 +147,10 @@ export type FormValues = {
onlyShowFirstAvailableSlot: boolean;
children: ChildrenEventType[];
hosts: Host[];
hostGroups: {
id: string;
name: string;
}[];
bookingFields: z.infer<typeof eventTypeBookingFields>;
availability?: AvailabilityOption;
bookerLayouts: BookerLayoutSettings;
@@ -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";
+2 -2
View File
@@ -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<typeof constants>;
export const mockedConstants = { ...initialConstants };
@@ -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" } }));
});
});
});
@@ -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;
});
};
@@ -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,
},
];
@@ -17,6 +17,7 @@ type Host<T> = {
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<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
fixedHosts: {
@@ -88,6 +90,7 @@ const _findQualifiedHostsWithDelegationCredentials = async <
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
groupId?: string | null;
user: Omit<T, "credentials"> & { 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<T, "credentials"> & { 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,
+5
View File
@@ -76,6 +76,7 @@ type BaseHost<User extends BaseUser> = {
weight?: number | null;
weightAdjustment?: number | null;
user: User;
groupId: string | null;
};
export type EventType = {
@@ -107,6 +108,7 @@ export function getNormalizedHosts<User extends BaseUser, Host extends BaseHost<
priority: host.priority,
weight: host.weight,
createdAt: host.createdAt,
groupId: host.groupId,
})),
fallbackHosts: null,
};
@@ -119,6 +121,7 @@ export function getNormalizedHosts<User extends BaseUser, Host extends BaseHost<
email: user.email,
user: user,
createdAt: null,
groupId: null,
};
}),
};
@@ -145,6 +148,7 @@ export async function getNormalizedHostsWithDelegationCredentials<
priority: host.priority,
weight: host.weight,
createdAt: host.createdAt,
groupId: host.groupId,
}));
const firstHost = hostsWithoutDelegationCredential[0];
const firstUserOrgId = await getOrgIdFromMemberOrTeamId({
@@ -197,6 +201,7 @@ export async function findMatchingHostsWithEventSegment<User extends BaseUser>({
priority?: number | null;
weight?: number | null;
createdAt: Date | null;
groupId: string | null;
}[];
}) {
const matchingRRTeamMembers = await findMatchingTeamMembersIdsForEventRRSegment({
+39
View File
@@ -0,0 +1,39 @@
import { DEFAULT_GROUP_ID } from "@calcom/lib/constants";
export function groupHostsByGroupId<T extends { groupId?: string | null }>({
hosts,
hostGroups,
}: {
hosts: T[];
hostGroups?: { id: string }[];
}): Record<string, T[]> {
const groups: Record<string, T[]> = {};
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<T extends { groupId?: string | null }>(
hosts: readonly T[],
groupId: string | null
): T[] {
return hosts.filter(
(host) => (groupId && (!host.groupId || host.groupId !== groupId)) || (!groupId && host.groupId)
);
}
+2
View File
@@ -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";
+1
View File
@@ -145,6 +145,7 @@ const commons = {
instantMeetingScheduleId: null,
instantMeetingParameters: [],
eventTypeColor: null,
hostGroups: [],
};
export const dynamicEvent = {
+1 -1
View File
@@ -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",
@@ -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);
});
});
+22 -4
View File
@@ -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<string, typeof roundRobinHosts>);
// 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);
@@ -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 },
@@ -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,
@@ -108,7 +108,12 @@ export const EventTypeWebWrapper = ({ id, data: serverFetchedData }: EventTypeWe
return <EventTypeWeb {...eventTypeQueryData} id={id} />;
};
const EventTypeWeb = ({ id, ...rest }: EventTypeSetupProps & { id: number }) => {
const EventTypeWeb = ({
id,
...rest
}: EventTypeSetupProps & {
id: number;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const pathname = usePathname();
@@ -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;
+27 -10
View File
@@ -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])
@@ -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"),
@@ -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);
@@ -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 {
@@ -190,6 +190,7 @@ type Host = {
priority?: number | null | undefined;
weight?: number | null | undefined;
scheduleId?: number | null | undefined;
groupId: string | null;
};
type User = {
@@ -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<unknown>;
@@ -1,5 +1,4 @@
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
type HasTeamPlanOptions = {
ctx: {