Skip to content

feat: Multiple Round Robin Host Selection #20796

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions apps/web/playwright/multiple-rr-hosts.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { expect } from "@playwright/test";

import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
import { submitAndWaitForResponse } from "./lib/testUtils";

test.describe.configure({ mode: "parallel" });

test.describe("Team Availability", () => {
test.beforeEach(async ({ users }) => {
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];

const owner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
teammates: teamMatesObj,
}
);

await owner.apiLogin();
});

test.afterEach(async ({ users }) => {
await users.deleteAll();
});

test("success booking with multiple hosts", async ({ page, users }) => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rule violated: E2E Tests Best Practices

  Missing URL assertions after navigation actions

await page.goto("/event-types");
await expect(page).toHaveURL(/.*\/event-types/);

// creating new team round robin event
await page.getByTestId("new-event-type").click();
await page.getByTestId("option-team-1").click();
await page.getByTestId("event-type-quick-chat").fill("test-rr-event");
await page.getByLabel("Round RobinCycle meetings").click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByTestId("round-robin-multiple-hosts-toggle").click();

// setting multiple hosts count
await page.getByTestId("round-robin-multiple-hosts-count-input").fill("3");

// assigning all team members
await page.getByTestId("assign-all-team-members-toggle").click();

// updating event type
await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
action: () => page.locator("[data-testid=update-eventtype]").click(),
});
const page2Promise = page.waitForEvent("popup");

// booking event
await page.getByTestId("preview-button").click();
const page2 = await page2Promise;

await selectFirstAvailableTimeSlotNextMonth(page2);
await bookTimeSlot(page2);

// booking success as multiple hosts count(3) is less than available hosts(4)
await expect(page2.getByTestId("success-page")).toBeVisible();
});

test("show alert if multiple hosts count is more than available hosts", async ({ page, users }) => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rule violated: E2E Tests Best Practices

  Missing URL assertions after navigation actions

await page.goto("/event-types");
await expect(page).toHaveURL(/.*\/event-types/);

// creating new team round robin event
await page.getByTestId("new-event-type").click();
await page.getByTestId("option-team-1").click();
await page.getByTestId("event-type-quick-chat").fill("test-rr-event");
await page.getByLabel("Round RobinCycle meetings").click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByTestId("round-robin-multiple-hosts-toggle").click();

// setting multiple hosts count
await page.getByTestId("round-robin-multiple-hosts-count-input").fill("10");

// assigning all team members
await page.getByTestId("assign-all-team-members-toggle").click();

// updating event type
await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
action: () => page.locator("[data-testid=update-eventtype]").click(),
});
const page2Promise = page.waitForEvent("popup");

// booking event
await page.getByTestId("preview-button").click();
const page2 = await page2Promise;
await selectFirstAvailableTimeSlotNextMonth(page2);

// showing error alert as multiple hosts count(10) is more than available hosts(4)
await bookTimeSlot(page2, { expectedStatusCode: 500 });
await expect(page2.getByTestId("alert")).toBeVisible();
await expect(page2.getByTestId("alert")).toContainText("Could not book the meeting.");
});
});
5 changes: 4 additions & 1 deletion apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"delete_calendar_event_error": "Unable to delete Calendar event.",
"already_signed_up_for_this_booking_error": "You are already signed up for this booking.",
"hosts_unavailable_for_booking": "Some of the hosts are unavailable for booking.",
"not_enough_available_hosts_error": "Not enough hosts are available for booking, please try another time slot.",
"help": "Help",
"price": "Price",
"paid": "Paid",
Expand Down Expand Up @@ -321,7 +322,9 @@
"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_helper": "People in the group take turns and only one person will show up for the event.",
"round_robin_helper": "People in the group take turns and only one person will be assigned to the event by default.",
"round_robin_multiple_hosts": "Multiple Round-Robin Hosts",
"round_robin_multiple_hosts_description": "People in the group take turns and multiple persons will be assigned to the event.",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
"finish": "Finish",
"organization_general_description": "Manage settings for your team language and timezone",
Expand Down
11 changes: 10 additions & 1 deletion packages/features/bookings/lib/handleNewBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ async function handler(
},
}),
};

if (input.bookingData.allRecurringDates && input.bookingData.isFirstRecurringSlot) {
const isTeamEvent =
eventType.schedulingType === SchedulingType.COLLECTIVE ||
Expand Down Expand Up @@ -692,7 +693,7 @@ async function handler(

// 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 */) {
while (luckyUserPool.length > 0 && luckyUsers.length < eventType.roundRobinHostsCount) {
const freeUsers = luckyUserPool.filter(
(user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id)
);
Expand Down Expand Up @@ -760,6 +761,14 @@ async function handler(
luckyUsers.push(newLuckyUser);
}
}

// for round robin, luckyUsers.length must be equal to eventType.roundRobinHostsCount
if (
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
luckyUsers.length < eventType.roundRobinHostsCount
) {
throw new Error(ErrorCode.NotEnoughAvailableHosts);
}
// ALL fixed users must be available
if (fixedUserPool.length !== users.filter((user) => user.isFixed).length) {
throw new Error(ErrorCode.HostsUnavailableForBooking);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
isRRWeightsEnabled: true,
roundRobinHostsCount: true,
beforeEventBuffer: true,
customReplyToEmail: true,
afterEventBuffer: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import { Button } from "@calcom/ui/components/button";
import { TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";

export const MultiplePrivateLinksController = ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import classNames from "@calcom/ui/classNames";
import { Label } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";

import { EditWeightsForAllTeamMembers } from "../../EditWeightsForAllTeamMembers";
Expand Down Expand Up @@ -334,39 +335,70 @@ const RoundRobinHosts = ({
</p>
</div>
<div className="border-subtle rounded-b-md border border-t-0 px-6 pt-4">
<>
<Controller<FormValues>
name="isRRWeightsEnabled"
render={({ field: { value: isRRWeightsEnabled, onChange } }) => (
<Controller<FormValues>
name="roundRobinHostsCount"
render={({ field: { value: roundRobinHostsCount, onChange } }) => (
<div className="pb-8">
<SettingsToggle
title={t("enable_weights")}
description={<WeightDescription t={t} />}
checked={isRRWeightsEnabled}
switchContainerClassName={customClassNames?.enableWeights?.container}
labelClassName={customClassNames?.enableWeights?.label}
descriptionClassName={customClassNames?.enableWeights?.description}
title={t("round_robin_multiple_hosts")}
description={<p>{t("round_robin_multiple_hosts_description")}</p>}
checked={roundRobinHostsCount > 1}
switchContainerClassName=""
labelClassName=""
descriptionClassName=""
data-testid="round-robin-multiple-hosts-toggle"
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);
active ? onChange(2) : onChange(1);
}}>
<EditWeightsForAllTeamMembers
teamMembers={teamMembers}
value={value}
onChange={(hosts) => {
const sortedRRHosts = hosts.sort((a, b) => sortHosts(a, b, true));
setValue("hosts", sortedRRHosts, { shouldDirty: true });
<TextField
required
type="number"
value={roundRobinHostsCount}
onChange={(e) => {
let hostCount = Number(e.target.value);
if (hostCount < 2) hostCount = 2;
onChange(hostCount);
}}
assignAllTeamMembers={assignAllTeamMembers}
assignRRMembersUsingSegment={assignRRMembersUsingSegment}
teamId={teamId}
queryValue={rrSegmentQueryValue}
min={2}
containerClassName={classNames("max-w-80")}
addOnSuffix="hosts"
data-testid="round-robin-multiple-hosts-count-input"
/>
</SettingsToggle>
)}
/>
</>
</div>
)}
/>
<Controller<FormValues>
name="isRRWeightsEnabled"
render={({ field: { value: isRRWeightsEnabled, onChange } }) => (
<SettingsToggle
title={t("enable_weights")}
description={<WeightDescription t={t} />}
checked={isRRWeightsEnabled}
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);
}}>
<EditWeightsForAllTeamMembers
teamMembers={teamMembers}
value={value}
onChange={(hosts) => {
const sortedRRHosts = hosts.sort((a, b) => sortHosts(a, b, true));
setValue("hosts", sortedRRHosts, { shouldDirty: true });
}}
assignAllTeamMembers={assignAllTeamMembers}
assignRRMembersUsingSegment={assignRRMembersUsingSegment}
teamId={teamId}
queryValue={rrSegmentQueryValue}
/>
</SettingsToggle>
)}
/>
<AddMembersWithSwitch
placeholder={t("add_a_member")}
teamId={teamId}
Expand Down
1 change: 1 addition & 0 deletions packages/features/eventtypes/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export type FormValues = {
forwardParamsSuccessRedirect: boolean | null;
secondaryEmailId?: number;
isRRWeightsEnabled: boolean;
roundRobinHostsCount: number;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type should be 'number & { min: 1 }' or similar to indicate that this field only accepts positive integers. Currently, it accepts any number which could lead to bugs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't really require, as we're having that check in other part of code.

maxLeadThreshold?: number;
};

Expand Down
1 change: 1 addition & 0 deletions packages/lib/defaultEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const commons = {
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
roundRobinHostsCount: 1,
rescheduleWithSameRoundRobinHost: false,
useEventTypeDestinationCalendarEmail: false,
secondaryEmailId: null,
Expand Down
1 change: 1 addition & 0 deletions packages/lib/errorCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ export enum ErrorCode {
UpdatingOauthClientError = "updating_oauth_client_error",
CreatingOauthClientError = "creating_oauth_client_error",
BookingTimeOutOfBounds = "booking_time_out_of_bounds_error",
NotEnoughAvailableHosts = "not_enough_available_hosts_error",
}
1 change: 1 addition & 0 deletions packages/lib/server/repository/eventType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ export class EventTypeRepository {
assignRRMembersUsingSegment: true,
rrSegmentQueryValue: true,
isRRWeightsEnabled: true,
roundRobinHostsCount: true,
rescheduleWithSameRoundRobinHost: true,
successRedirectUrl: true,
forwardParamsSuccessRedirect: true,
Expand Down
1 change: 1 addition & 0 deletions packages/lib/test/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
profileId: null,
secondaryEmailId: null,
isRRWeightsEnabled: false,
roundRobinHostsCount: 1,
eventTypeColor: null,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const useEventTypeForm = ({
autoTranslateDescriptionEnabled: eventType.autoTranslateDescriptionEnabled,
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
assignAllTeamMembers: eventType.assignAllTeamMembers,
roundRobinHostsCount: eventType.roundRobinHostsCount,
assignRRMembersUsingSegment: eventType.assignRRMembersUsingSegment,
rrSegmentQueryValue: eventType.rrSegmentQueryValue,
aiPhoneCallConfig: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "roundRobinHostsCount" INTEGER DEFAULT 1;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Column should be defined as NOT NULL since it has a default value and validation in code ensures it's always >= 1.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a CHECK constraint to enforce roundRobinHostsCount >= 1 at the database level.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
Warnings:

- Made the column `roundRobinHostsCount` on table `EventType` required. This step will fail if there are existing NULL values in that column.

*/
-- AlterTable
ALTER TABLE "EventType" ALTER COLUMN "roundRobinHostsCount" SET NOT NULL;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration will fail if any NULL values exist in the roundRobinHostsCount column despite the initial default value.

1 change: 1 addition & 0 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ model EventType {
useEventTypeDestinationCalendarEmail Boolean @default(false)
aiPhoneCallConfig AIPhoneCallConfiguration?
isRRWeightsEnabled Boolean @default(false)
roundRobinHostsCount Int @default(1)
fieldTranslations EventTypeTranslation[]
maxLeadThreshold Int?
selectedCalendars SelectedCalendar[]
Expand Down
1 change: 1 addition & 0 deletions packages/prisma/zod-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
requiresBookerEmailVerification: true,
assignAllTeamMembers: true,
isRRWeightsEnabled: true,
roundRobinHostsCount: true,
eventTypeColor: true,
allowReschedulingPastBookings: true,
hideOrganizerEmail: true,
Expand Down
Loading
Loading