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:
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+825
-3526
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,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -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 () => {
|
||||
|
||||
+652
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
-172
@@ -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);
|
||||
};
|
||||
|
||||
+286
-114
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -145,6 +145,7 @@ const commons = {
|
||||
instantMeetingScheduleId: null,
|
||||
instantMeetingParameters: [],
|
||||
eventTypeColor: null,
|
||||
hostGroups: [],
|
||||
};
|
||||
|
||||
export const dynamicEvent = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user