feat: Round Robin Weights (#15558)

* WIP

* add frontend

* backend to update weight

* UI improvements

* WIP weight algorithm

* enable weights switch + algorithm improvements

* fix weightDescription

* clean up code

* remove OOOEntryHost from schema.prisma

* implement logic (not yet tested)

* add tests for weight algorithm

* add test with weight adjustment

* finish unit tests

* fix type error

* fix type error

* add migration

* fix type error

* fix event type update handler

* fix failing test

* UI fixes for saving hosts

* make sure weightAdjustment is not lost on host changes

* fix weightadjustment for new hosts

* add weightAdjustment to availableUsers

* fix type errors

* fix default value for weight

* make weight and weightAdjustment optional

* fix type errors from schema changes

* type fix

* clean up code

* improve comments

* remove comment

* clean up code

* add tests & weight adjustment improvments

* better variable naming

* fixes for weight adjustments

* make weightAdjustments proportional to weights

* fix previous host weight adjustments

* improved tests for weight adjustments

* save weight and priority + sort hosts correctly

* fix type error

* code clean up

* remove console.log

* use BookingRepository to fetch bookings of users

* use BookingRepository to fetch bookings in getLuckyUser

* fix type errors

* fix weightAdjustment if changed from fixed to rr host

* disable weights when 'assign all' is enabled

* typo

* allow 0 weight

* set min (and max) for weight and priority

* use useWatch

* code clean up

* fix type error

* only count accepted bookings for RR

* fix type error

* improve data fetching of bookings

* only filter bookings of availableUsers

* code clean up form feedback

* fix tests

* don't count no show bookings

* code clean up

* choose user with highest weight

* use one reduce instead of two

* use reduce instead of filter and map

* don't show weights toggle when 'assign all' is enabled

* design fixes

* fix type errors

* fix: type check

* Update packages/features/eventtypes/components/AddMembersWithSwitch.tsx

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Carina Wollendorfer
2024-08-15 15:01:15 +01:00
committed by GitHub
co-authored by CarinaWolli Alex van Andel
parent d60f165223
commit cd311f076c
29 changed files with 1249 additions and 218 deletions
+46 -17
View File
@@ -11,6 +11,7 @@ import AddMembersWithSwitch, {
} from "@calcom/features/eventtypes/components/AddMembersWithSwitch";
import AssignAllTeamMembers from "@calcom/features/eventtypes/components/AssignAllTeamMembers";
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { sortHosts, weightDescription } from "@calcom/features/eventtypes/components/HostEditDialogs";
import type { FormValues, TeamMember } from "@calcom/features/eventtypes/lib/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -134,6 +135,8 @@ const FixedHosts = ({
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: 2,
weight: 100,
weightAdjustment: 0,
})),
{ shouldDirty: true }
)
@@ -175,6 +178,8 @@ const FixedHosts = ({
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: 2,
weight: 100,
weightAdjustment: 0,
})),
{ shouldDirty: true }
)
@@ -202,7 +207,12 @@ const RoundRobinHosts = ({
}) => {
const { t } = useLocale();
const { setValue } = useFormContext<FormValues>();
const { setValue, getValues, control } = useFormContext<FormValues>();
const isRRWeightsEnabled = useWatch({
control,
name: "isRRWeightsEnabled",
});
return (
<div className="rounded-lg ">
@@ -210,7 +220,26 @@ const RoundRobinHosts = ({
<Label className="mb-1 text-sm font-semibold">{t("round_robin_hosts")}</Label>
<p className="text-subtle max-w-full break-words text-sm leading-tight">{t("round_robin_helper")}</p>
</div>
<div className="border-subtle rounded-b-md border border-t-0">
<div className="border-subtle rounded-b-md border border-t-0 px-6 pt-4">
{!assignAllTeamMembers && (
<Controller<FormValues>
name="isRRWeightsEnabled"
render={({ field: { value, onChange } }) => (
<SettingsToggle
title={t("enable_weights")}
description={weightDescription}
checked={value}
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);
}}
/>
)}
/>
)}
<AddMembersWithSwitch
teamMembers={teamMembers}
value={value}
@@ -218,20 +247,23 @@ const RoundRobinHosts = ({
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
automaticAddAllEnabled={true}
isRRWeightsEnabled={isRRWeightsEnabled}
isFixed={false}
onActive={() =>
containerClassName={assignAllTeamMembers ? "-mt-4" : ""}
onActive={() => {
setValue(
"hosts",
teamMembers
.map((teamMember) => ({
isFixed: false,
userId: parseInt(teamMember.value, 10),
priority: 2,
}))
.sort((a, b) => b.priority - a.priority),
teamMembers.map((teamMember) => ({
isFixed: false,
userId: parseInt(teamMember.value, 10),
priority: 2,
weight: 100,
weightAdjustment: 0,
})),
{ shouldDirty: true }
)
}
);
setValue("isRRWeightsEnabled", false);
}}
/>
</div>
</div>
@@ -340,11 +372,8 @@ const Hosts = ({
teamMembers={teamMembers}
value={value}
onChange={(changeValue) => {
onChange(
[...value.filter((host: Host) => host.isFixed), ...changeValue].sort(
(a, b) => b.priority - a.priority
)
);
const hosts = [...value.filter((host: Host) => host.isFixed), ...changeValue];
onChange(hosts);
}}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
@@ -21,6 +21,7 @@ import {
} from "@calcom/features/ee/cal-ai-phone/promptTemplates";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { sortHosts } from "@calcom/features/eventtypes/components/HostEditDialogs";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { validateIntervalLimitOrder } from "@calcom/lib";
import { WEBSITE_URL } from "@calcom/lib/constants";
@@ -89,7 +90,13 @@ const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/Manag
const AssignmentWarningDialog = dynamic(() => import("@components/eventtype/AssignmentWarningDialog"));
export type Host = { isFixed: boolean; userId: number; priority: number };
export type Host = {
isFixed: boolean;
userId: number;
priority: number;
weight: number;
weightAdjustment: number;
};
export type CustomInputParsed = typeof customInputSchema._output;
@@ -297,7 +304,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
slotInterval: eventType.slotInterval,
minimumBookingNotice: eventType.minimumBookingNotice,
metadata: eventType.metadata,
hosts: eventType.hosts,
hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)),
successRedirectUrl: eventType.successRedirectUrl || "",
forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect,
users: eventType.users,
@@ -330,6 +337,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
templateType: eventType.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE",
schedulerName: eventType.aiPhoneCallConfig?.schedulerName,
},
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
};
}, [eventType, periodDates]);
const formMethods = useForm<FormValues>({
@@ -399,6 +407,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router, eventType.hosts, eventType.children, eventType.assignAllTeamMembers]);
const appsMetadata = formMethods.getValues("metadata")?.apps;
@@ -2363,7 +2363,11 @@
"Highest": "highest",
"send_booker_to": "Send Booker to",
"set_priority": "Set Priority",
"set_weight": "Set Weight",
"enable_weights": "Enable Weights",
"priority_for_user": "Priority for {{userName}}",
"weights_description": "Weights determine how meetings are distributed among hosts. <1>Learn more</1>",
"weight_for_user": "Weight for {{userName}}",
"change_priority": "change priority",
"field_identifiers_as_variables": "Use field identifiers as variables for your custom event redirect",
"field_identifiers_as_variables_with_example": "Use field identifiers as variables for your custom event redirect (e.g. {{variable}})",
@@ -8,7 +8,7 @@ describe("Tests to Check if Event Types have empty Assignment", () => {
checkForEmptyAssignment({
assignedUsers: [],
assignAllTeamMembers: false,
hosts: [{ userId: 101, isFixed: false, priority: 2 }],
hosts: [{ userId: 101, isFixed: false, priority: 2, weight: 100, weightAdjustment: 0 }],
isManagedEventType: true,
})
).toBe(true);
@@ -61,7 +61,7 @@ describe("Tests to Check if Event Types have empty Assignment", () => {
checkForEmptyAssignment({
assignedUsers: [],
assignAllTeamMembers: false,
hosts: [{ userId: 101, isFixed: false, priority: 2 }],
hosts: [{ userId: 101, isFixed: false, priority: 2, weight: 100, weightAdjustment: 0 }],
isManagedEventType: false,
})
).toBe(false);
+496 -19
View File
@@ -1,16 +1,17 @@
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { expect, it } from "vitest";
import { expect, it, describe } from "vitest";
import { getLuckyUser } from "@calcom/lib/server";
import { buildUser } from "@calcom/lib/test/builder";
import { buildUser, buildBooking } from "@calcom/lib/test/builder";
import { addWeightAdjustmentToNewHosts } from "@calcom/trpc/server/routers/viewer/eventTypes/util";
it("can find lucky user with maximize availability", async () => {
const user1 = buildUser({
id: 1,
username: "test1",
name: "Test User 1",
email: "test@example.com",
email: "test1@example.com",
bookings: [
{
createdAt: new Date("2022-01-25T05:30:00.000Z"),
@@ -24,7 +25,7 @@ it("can find lucky user with maximize availability", async () => {
id: 2,
username: "test2",
name: "Test User 2",
email: "tes2t@example.com",
email: "test2@example.com",
bookings: [
{
createdAt: new Date("2022-01-25T04:30:00.000Z"),
@@ -39,7 +40,11 @@ it("can find lucky user with maximize availability", async () => {
await expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: users,
eventTypeId: 1,
eventType: {
id: 1,
isRRWeightsEnabled: false,
},
allRRHosts: [],
})
).resolves.toStrictEqual(users[1]);
});
@@ -49,7 +54,7 @@ it("can find lucky user with maximize availability and priority ranking", async
id: 1,
username: "test1",
name: "Test User 1",
email: "test@example.com",
email: "test1@example.com",
priority: 2,
bookings: [
{
@@ -64,7 +69,7 @@ it("can find lucky user with maximize availability and priority ranking", async
id: 2,
username: "test2",
name: "Test User 2",
email: "tes2t@example.com",
email: "test2@example.com",
bookings: [
{
createdAt: new Date("2022-01-25T04:30:00.000Z"),
@@ -76,16 +81,16 @@ it("can find lucky user with maximize availability and priority ranking", async
// TODO: we may be able to use native prisma generics somehow?
prismaMock.user.findMany.mockResolvedValue(users);
prismaMock.booking.findMany.mockResolvedValue([]);
const test = await getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: users,
eventTypeId: 1,
});
// both users have medium priority (one user has no priority set, default to medium) so pick least recently booked
await expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: users,
eventTypeId: 1,
eventType: {
id: 1,
isRRWeightsEnabled: false,
},
allRRHosts: [],
})
).resolves.toStrictEqual(users[1]);
@@ -93,7 +98,7 @@ it("can find lucky user with maximize availability and priority ranking", async
id: 1,
username: "test1",
name: "Test User 1",
email: "test@example.com",
email: "test1@example.com",
priority: 0,
bookings: [
{
@@ -105,7 +110,7 @@ it("can find lucky user with maximize availability and priority ranking", async
id: 2,
username: "test2",
name: "Test User 2",
email: "tes2t@example.com",
email: "test2@example.com",
priority: 2,
bookings: [
{
@@ -118,7 +123,7 @@ it("can find lucky user with maximize availability and priority ranking", async
id: 2,
username: "test2",
name: "Test User 2",
email: "tes2t@example.com",
email: "test2@example.com",
priority: 4,
bookings: [
{
@@ -136,7 +141,11 @@ it("can find lucky user with maximize availability and priority ranking", async
await expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: usersWithPriorities,
eventTypeId: 1,
eventType: {
id: 1,
isRRWeightsEnabled: false,
},
allRRHosts: [],
})
).resolves.toStrictEqual(usersWithPriorities[2]);
@@ -144,7 +153,7 @@ it("can find lucky user with maximize availability and priority ranking", async
id: 1,
username: "test1",
name: "Test User 1",
email: "test@example.com",
email: "test1@example.com",
priority: 0,
bookings: [
{
@@ -169,7 +178,7 @@ it("can find lucky user with maximize availability and priority ranking", async
id: 3,
username: "test3",
name: "Test User 3",
email: "test3t@example.com",
email: "test3@example.com",
priority: 3,
bookings: [
{
@@ -187,7 +196,475 @@ it("can find lucky user with maximize availability and priority ranking", async
await expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: usersWithSamePriorities,
eventTypeId: 1,
eventType: {
id: 1,
isRRWeightsEnabled: false,
},
allRRHosts: [],
})
).resolves.toStrictEqual(usersWithSamePriorities[1]);
});
describe("maximize availability and weights", () => {
it("can find lucky user if hosts have same weights", async () => {
const user1 = buildUser({
id: 1,
username: "test1",
name: "Test User 1",
email: "test1@example.com",
priority: 3,
weight: 100,
bookings: [
{
createdAt: new Date("2022-01-25T06:30:00.000Z"),
},
{
createdAt: new Date("2022-01-25T03:30:00.000Z"),
},
],
});
const user2 = buildUser({
id: 2,
username: "test2",
name: "Test User 2",
email: "test2@example.com",
priority: 3,
weight: 100,
bookings: [
{
createdAt: new Date("2022-01-25T05:30:00.000Z"),
},
{
createdAt: new Date("2022-01-25T04:30:00.000Z"),
},
],
});
prismaMock.user.findMany.mockResolvedValue([user1, user2]);
prismaMock.booking.findMany.mockResolvedValue([
buildBooking({
id: 1,
userId: 1,
createdAt: new Date("2022-01-25T06:30:00.000Z"),
}),
buildBooking({
id: 2,
userId: 1,
createdAt: new Date("2022-01-25T03:30:00.000Z"),
}),
buildBooking({
id: 3,
userId: 2,
createdAt: new Date("2022-01-25T05:30:00.000Z"),
}),
buildBooking({
id: 4,
userId: 2,
createdAt: new Date("2022-01-25T04:30:00.000Z"),
}),
]);
const allRRHosts = [
{
user: { id: user1.id, email: user1.email },
weight: user1.weight,
weightAdjustment: user1.weightAdjustment,
},
{
user: { id: user2.id, email: user2.email },
weight: user2.weight,
weightAdjustment: user2.weightAdjustment,
},
];
await expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: [user1, user2],
eventType: {
id: 1,
isRRWeightsEnabled: true,
},
allRRHosts,
})
).resolves.toStrictEqual(user2);
});
it("can find lucky user if hosts have different weights", async () => {
const user1 = buildUser({
id: 1,
username: "test1",
name: "Test User 1",
email: "test1@example.com",
priority: 3,
weight: 200,
bookings: [
{
createdAt: new Date("2022-01-25T08:30:00.000Z"),
},
{
createdAt: new Date("2022-01-25T07:30:00.000Z"),
},
{
createdAt: new Date("2022-01-25T05:30:00.000Z"),
},
],
});
const user2 = buildUser({
id: 2,
username: "test2",
name: "Test User 2",
email: "test2@example.com",
priority: 3,
weight: 100,
bookings: [
{
createdAt: new Date("2022-01-25T06:30:00.000Z"),
},
{
createdAt: new Date("2022-01-25T03:30:00.000Z"),
},
],
});
prismaMock.user.findMany.mockResolvedValue([user1, user2]);
prismaMock.booking.findMany.mockResolvedValue([
buildBooking({
id: 1,
userId: 1,
createdAt: new Date("2022-01-25T08:30:00.000Z"),
}),
buildBooking({
id: 2,
userId: 1,
createdAt: new Date("2022-01-25T07:30:00.000Z"),
}),
buildBooking({
id: 3,
userId: 1,
createdAt: new Date("2022-01-25T05:30:00.000Z"),
}),
buildBooking({
id: 4,
userId: 2,
createdAt: new Date("2022-01-25T06:30:00.000Z"),
}),
buildBooking({
id: 4,
userId: 2,
createdAt: new Date("2022-01-25T03:30:00.000Z"),
}),
]);
const allRRHosts = [
{
user: { id: user1.id, email: user1.email },
weight: user1.weight,
weightAdjustment: user1.weightAdjustment,
},
{
user: { id: user2.id, email: user2.email },
weight: user2.weight,
weightAdjustment: user2.weightAdjustment,
},
];
await expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: [user1, user2],
eventType: {
id: 1,
isRRWeightsEnabled: true,
},
allRRHosts,
})
).resolves.toStrictEqual(user1);
});
it("can find lucky user with weights and adjusted weights", async () => {
const user1 = buildUser({
id: 1,
username: "test1",
name: "Test User 1",
email: "test1@example.com",
priority: 3,
weight: 150,
weightAdjustment: 4, // + 3 = 7 bookings
bookings: [
{
createdAt: new Date("2022-01-25T07:30:00.000Z"),
},
{
createdAt: new Date("2022-01-25T05:30:00.000Z"),
},
{
createdAt: new Date("2022-01-25T03:30:00.000Z"),
},
],
});
const user2 = buildUser({
id: 2,
username: "test2",
name: "Test User 2",
email: "test2@example.com",
priority: 3,
weight: 100,
weightAdjustment: 3, // + 2 = 5 bookings
bookings: [
{
createdAt: new Date("2022-01-25T06:30:00.000Z"),
},
{
createdAt: new Date("2022-01-25T03:30:00.000Z"),
},
],
});
prismaMock.user.findMany.mockResolvedValue([user1, user2]);
prismaMock.booking.findMany.mockResolvedValue([
buildBooking({
id: 1,
userId: 1,
createdAt: new Date("2022-01-25T05:30:00.000Z"),
}),
buildBooking({
id: 2,
userId: 1,
createdAt: new Date("2022-01-25T03:30:00.000Z"),
}),
buildBooking({
id: 3,
userId: 1,
createdAt: new Date("2022-01-25T07:30:00.000Z"),
}),
buildBooking({
id: 4,
userId: 2,
createdAt: new Date("2022-01-25T06:30:00.000Z"),
}),
buildBooking({
id: 4,
userId: 2,
createdAt: new Date("2022-01-25T03:30:00.000Z"),
}),
]);
const allRRHosts = [
{
user: { id: user1.id, email: user1.email },
weight: user1.weight,
weightAdjustment: user1.weightAdjustment,
},
{
user: { id: user2.id, email: user2.email },
weight: user2.weight,
weightAdjustment: user2.weightAdjustment,
},
];
await expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: [user1, user2],
eventType: {
id: 1,
isRRWeightsEnabled: true,
},
allRRHosts,
})
).resolves.toStrictEqual(user1);
});
});
function convertHostsToUsers(
hosts: {
userId: number;
isFixed: boolean;
priority: number;
weight: number;
weightAdjustment?: number;
newHost?: boolean;
}[]
) {
return hosts.map((host) => {
return {
id: host.userId,
email: `test${host.userId}@example.com`,
hosts: host.newHost
? []
: [
{
isFixed: host.isFixed,
priority: host.priority,
weightAdjustment: host.weightAdjustment,
weight: host.weight,
},
],
};
});
}
describe("addWeightAdjustmentToNewHosts", () => {
it("weight adjustment is correctly added to host with two hosts that have the same weight", async () => {
const hosts = [
{
userId: 1,
isFixed: false,
priority: 2,
weight: 100,
},
{
userId: 2,
isFixed: false,
priority: 2,
weight: 100,
newHost: true,
},
];
const users = convertHostsToUsers(hosts);
prismaMock.user.findMany.mockResolvedValue(users);
// mock for allBookings (for ongoing RR hosts)
prismaMock.booking.findMany
.mockResolvedValueOnce([
buildBooking({
id: 1,
userId: 1,
}),
buildBooking({
id: 2,
userId: 1,
}),
buildBooking({
id: 3,
userId: 1,
}),
buildBooking({
id: 4,
userId: 1,
}),
])
// mock for bookings of new RR host
.mockResolvedValueOnce([
buildBooking({
id: 5,
userId: 2,
}),
]);
const hostsWithAdjustedWeight = await addWeightAdjustmentToNewHosts({
hosts,
isWeightsEnabled: true,
eventTypeId: 1,
prisma: prismaMock,
});
/*
both users have weight 100, user1 has 4 bookings user 2 has 1 bookings already
*/
expect(hostsWithAdjustedWeight.find((host) => host.userId === 2)?.weightAdjustment).toBe(3);
});
it("weight adjustment is correctly added to host with several hosts that have different weights", async () => {
// make different weights
const hosts = [
{
userId: 1,
isFixed: false,
priority: 2,
weight: 100,
},
{
userId: 2,
isFixed: false,
priority: 2,
weight: 200,
newHost: true,
},
{
userId: 3,
isFixed: false,
priority: 2,
weight: 200,
},
{
userId: 4,
isFixed: false,
priority: 2,
weight: 100,
},
{
userId: 5,
isFixed: false,
priority: 2,
weight: 50,
newHost: true,
},
];
const users = convertHostsToUsers(hosts);
prismaMock.user.findMany.mockResolvedValue(users);
// mock for allBookings (for ongoing RR hosts)
prismaMock.booking.findMany
.mockResolvedValueOnce([
// 8 bookings for ongoing hosts (hosts that already existed before)
buildBooking({
id: 1,
userId: 1,
}),
buildBooking({
id: 2,
userId: 1,
}),
buildBooking({
id: 3,
userId: 3,
}),
buildBooking({
id: 4,
userId: 3,
}),
buildBooking({
id: 5,
userId: 4,
}),
buildBooking({
id: 6,
userId: 4,
}),
buildBooking({
id: 7,
userId: 4,
}),
buildBooking({
id: 8,
userId: 4,
}),
])
// mock for bookings of new RR host
.mockResolvedValueOnce([
buildBooking({
id: 5,
userId: 2,
}),
])
.mockResolvedValue([]);
const hostsWithAdjustedWeight = await addWeightAdjustmentToNewHosts({
hosts,
isWeightsEnabled: true,
eventTypeId: 1,
prisma: prismaMock,
});
// 8 bookings from ongoing hosts, 400 total weight --> average 0.02 bookings per weight unit --> 0.02 * 200 = 4 - 1 prev bookings = 3
expect(hostsWithAdjustedWeight.find((host) => host.userId === 2)?.weightAdjustment).toBe(3);
// 8 bookings from ongoing hosts, 400 total weight --> average 0.02 bookings per weight unit --> 0.02 * 50 = 1 (no prev bookings)
expect(hostsWithAdjustedWeight.find((host) => host.userId === 5)?.weightAdjustment).toBe(1);
});
});
@@ -185,6 +185,7 @@ type WhiteListedBookingProps = {
credentialId?: number | null;
})[];
bookingSeat?: Prisma.BookingSeatCreateInput[];
createdAt?: string;
};
type InputBooking = Partial<Omit<Booking, keyof WhiteListedBookingProps>> & WhiteListedBookingProps;
@@ -368,7 +368,9 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
value={sendUpdatesTo.map((userId) => ({
isFixed: true,
userId: userId,
priority: 1,
priority: 2,
weight: 100,
weightAdjustment: 0,
}))}
onChange={(value) => {
hookForm.setValue(
@@ -613,7 +613,8 @@ async function handler(
: await getLuckyUser("MAXIMIZE_AVAILABILITY", {
// find a lucky user that is not already in the luckyUsers array
availableUsers: freeUsers,
eventTypeId: eventType.id,
allRRHosts: eventTypeWithUsers.hosts.filter((host) => !host.isFixed),
eventType,
});
if (!newLuckyUser) {
break; // prevent infinite loop
@@ -61,6 +61,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
durationLimits: true,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
isRRWeightsEnabled: true,
parentId: true,
parent: {
select: {
@@ -93,6 +94,8 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
select: {
isFixed: true,
priority: true,
weight: true,
weightAdjustment: true,
user: {
select: {
credentials: {
@@ -31,10 +31,12 @@ export const loadUsers = async (eventType: EventType, dynamicUserList: string[],
const loadUsersByEventType = async (eventType: EventType): Promise<NewBookingEventType["users"]> => {
const hosts = eventType.hosts || [];
const users = hosts.map(({ user, isFixed, priority }) => ({
const users = hosts.map(({ user, isFixed, priority, weight, weightAdjustment }) => ({
...user,
isFixed,
priority,
weight,
weightAdjustment,
}));
return users.length ? users : eventType.users;
};
@@ -57,6 +57,8 @@ export type IsFixedAwareUser = User & {
credentials: CredentialPayload[];
organization?: { slug: string };
priority?: number;
weight?: number;
weightAdjustment?: number;
};
export type NewBookingEventType = AwaitedGetDefaultEvent | getEventTypeResponse;
@@ -88,7 +88,13 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number
eventType.hosts = eventType.hosts.length
? eventType.hosts
: eventType.users.map((user) => ({ user, isFixed: false, priority: 2 }));
: eventType.users.map((user) => ({
user,
isFixed: false,
priority: 2,
weight: 100,
weightAdjustment: 0,
}));
const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed);
@@ -130,8 +136,13 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number
const reassignedRRHost = await getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers,
eventTypeId: eventTypeId,
eventType: {
id: eventTypeId,
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
},
allRRHosts: eventType.hosts.filter((host) => !host.isFixed),
});
const hasOrganizerChanged = !previousRRHost || booking.userId === previousRRHost?.id;
const organizer = hasOrganizerChanged ? reassignedRRHost : booking.user;
const organizerT = await getTranslation(organizer?.locale || "en", "common");
@@ -46,6 +46,7 @@ const CheckedHostField = ({
value,
onChange,
helperText,
isRRWeightsEnabled,
...rest
}: {
labelText?: string;
@@ -55,6 +56,7 @@ const CheckedHostField = ({
onChange?: (options: Host[]) => void;
options?: Options<CheckedSelectOption>;
helperText?: React.ReactNode | string;
isRRWeightsEnabled?: boolean;
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
return (
<div className="flex flex-col rounded-md">
@@ -69,6 +71,8 @@ const CheckedHostField = ({
isFixed,
userId: parseInt(option.value, 10),
priority: option.priority ?? 2,
weight: option.weight ?? 100,
weightAdjustment: option.weightAdjustment ?? 0,
}))
);
}}
@@ -78,12 +82,14 @@ const CheckedHostField = ({
const option = options.find((member) => member.value === host.userId.toString());
if (!option) return acc;
acc.push({ ...option, priority: host.priority ?? 2, isFixed });
acc.push({ ...option, priority: host.priority ?? 2, isFixed, weight: host.weight ?? 100 });
return acc;
}, [] as CheckedSelectOption[])}
controlShouldRenderValue={false}
options={options}
placeholder={placeholder}
isRRWeightsEnabled={isRRWeightsEnabled}
{...rest}
/>
</div>
@@ -102,6 +108,7 @@ const AddMembersWithSwitch = ({
isFixed,
placeholder = "",
containerClassName = "",
isRRWeightsEnabled,
}: {
value: Host[];
onChange: (hosts: Host[]) => void;
@@ -113,13 +120,14 @@ const AddMembersWithSwitch = ({
isFixed: boolean;
placeholder?: string;
containerClassName?: string;
isRRWeightsEnabled?: boolean;
}) => {
const { t } = useLocale();
const { setValue } = useFormContext<FormValues>();
return (
<div className="rounded-md ">
<div className={`flex flex-col rounded-md px-6 pb-2 pt-6 ${containerClassName}`}>
<div className={`flex flex-col rounded-md pb-2 pt-6 ${containerClassName}`}>
{automaticAddAllEnabled ? (
<div className="mb-2">
<AssignAllTeamMembers
@@ -139,6 +147,7 @@ const AddMembersWithSwitch = ({
isFixed={isFixed}
options={teamMembers.sort(sortByLabel)}
placeholder={placeholder ?? t("add_attendees")}
isRRWeightsEnabled={isRRWeightsEnabled}
/>
) : (
<></>
@@ -1,30 +1,20 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import type { Props } from "react-select";
import type { FormValues, Host } from "@calcom/features/eventtypes/lib/types";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
Avatar,
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
Icon,
Label,
Select,
Tooltip,
} from "@calcom/ui";
import { Avatar, Button, Icon, Select, Tooltip } from "@calcom/ui";
import { PriorityDialog, WeightDialog } from "./HostEditDialogs";
export type CheckedSelectOption = {
avatar: string;
label: string;
value: string;
priority?: number;
weight?: number;
weightAdjustment?: number;
isFixed?: boolean;
disabled?: boolean;
};
@@ -32,12 +22,16 @@ export type CheckedSelectOption = {
export const CheckedTeamSelect = ({
options = [],
value = [],
isRRWeightsEnabled,
...props
}: Omit<Props<CheckedSelectOption, true>, "value" | "onChange"> & {
value?: readonly CheckedSelectOption[];
onChange: (value: readonly CheckedSelectOption[]) => void;
isRRWeightsEnabled?: boolean;
}) => {
const [priorityDialogOpen, setPriorityDialogOpen] = useState(false);
const [weightDialogOpen, setWeightDialogOpen] = useState(false);
const [currentOption, setCurrentOption] = useState(value[0] ?? null);
const { t } = useLocale();
@@ -68,20 +62,35 @@ export const CheckedTeamSelect = ({
<p className="text-emphasis my-auto ms-3 text-sm">{option.label}</p>
<div className="ml-auto flex items-center">
{option && !option.isFixed ? (
<Tooltip content={t("change_priority")}>
<Button
color="minimal"
onClick={() => {
setPriorityDialogOpen(true);
setCurrentOption(option);
}}
className={classNames(
"mr-6 h-2 p-0 text-sm hover:bg-transparent",
getPriorityTextAndColor(option.priority).color
)}>
{t(getPriorityTextAndColor(option.priority).text)}
</Button>
</Tooltip>
<>
<Tooltip content={t("change_priority")}>
<Button
color="minimal"
onClick={() => {
setPriorityDialogOpen(true);
setCurrentOption(option);
}}
className={classNames(
"mr-6 h-2 p-0 text-sm hover:bg-transparent",
getPriorityTextAndColor(option.priority).color
)}>
{t(getPriorityTextAndColor(option.priority).text)}
</Button>
</Tooltip>
{isRRWeightsEnabled ? (
<Button
color="minimal"
className="mr-6 h-2 w-4 p-0 text-sm hover:bg-transparent"
onClick={() => {
setWeightDialogOpen(true);
setCurrentOption(option);
}}>
{option.weight ?? 100}%
</Button>
) : (
<></>
)}
</>
) : (
<></>
)}
@@ -89,7 +98,7 @@ export const CheckedTeamSelect = ({
<Icon
name="x"
onClick={() => props.onChange(value.filter((item) => item.value !== option.value))}
className="my-auto h-4 w-4"
className="my-auto ml-2 h-4 w-4"
/>
</div>
</li>
@@ -97,12 +106,20 @@ export const CheckedTeamSelect = ({
))}
</ul>
{currentOption && !currentOption.isFixed ? (
<PriorityDialog
isOpenDialog={priorityDialogOpen}
setIsOpenDialog={setPriorityDialogOpen}
option={currentOption}
onChange={props.onChange}
/>
<>
<PriorityDialog
isOpenDialog={priorityDialogOpen}
setIsOpenDialog={setPriorityDialogOpen}
option={currentOption}
onChange={props.onChange}
/>
<WeightDialog
isOpenDialog={weightDialogOpen}
setIsOpenDialog={setWeightDialogOpen}
option={currentOption}
onChange={props.onChange}
/>
</>
) : (
<></>
)}
@@ -110,67 +127,6 @@ export const CheckedTeamSelect = ({
);
};
interface IPriiorityDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
option: CheckedSelectOption;
onChange: (value: readonly CheckedSelectOption[]) => void;
}
const PriorityDialog = (props: IPriiorityDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, option, onChange } = props;
const { getValues } = useFormContext<FormValues>();
const priorityOptions = [
{ label: t("lowest"), value: 0 },
{ label: t("low"), value: 1 },
{ label: t("medium"), value: 2 },
{ label: t("high"), value: 3 },
{ label: t("highest"), value: 4 },
];
const [newPriority, setNewPriority] = useState<{ label: string; value: number }>();
const setPriority = () => {
if (!!newPriority) {
const hosts: Host[] = getValues("hosts");
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,
};
})
.sort((a, b) => b.priority ?? 2 - a.priority ?? 2);
onChange(updatedHosts);
}
setIsOpenDialog(false);
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent title={t("set_priority")}>
<div className="mb-4">
<Label>{t("priority_for_user", { userName: option.label })}</Label>
<Select
defaultValue={priorityOptions[option.priority ?? 2]}
value={newPriority}
onChange={(value) => setNewPriority(value ?? priorityOptions[2])}
options={priorityOptions}
/>
</div>
<DialogFooter>
<DialogClose />
<Button onClick={setPriority}>{t("confirm")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const getPriorityTextAndColor = (priority?: number) => {
switch (priority) {
case 0:
@@ -0,0 +1,172 @@
import { Trans } from "next-i18next";
import Link from "next/link";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import type { FormValues, Host } from "@calcom/features/eventtypes/lib/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
Dialog,
DialogContent,
DialogFooter,
DialogClose,
Button,
Label,
Select,
TextField,
} from "@calcom/ui";
import type { CheckedSelectOption } from "./CheckedTeamSelect";
interface IDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
option: CheckedSelectOption;
onChange: (value: readonly CheckedSelectOption[]) => void;
}
export const PriorityDialog = (props: IDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, option, onChange } = props;
const { getValues } = useFormContext<FormValues>();
const priorityOptions = [
{ label: t("lowest"), value: 0 },
{ label: t("low"), value: 1 },
{ label: t("medium"), value: 2 },
{ label: t("high"), value: 3 },
{ label: t("highest"), value: 4 },
];
const [newPriority, setNewPriority] = useState<{ label: string; value: number }>();
const setPriority = () => {
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,
weightAdjustment: host.weightAdjustment,
};
});
const sortedHosts = updatedHosts.sort((a, b) => sortHosts(a, b, isRRWeightsEnabled));
onChange(sortedHosts);
}
setIsOpenDialog(false);
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent title={t("set_priority")}>
<div className="mb-4">
<Label>{t("priority_for_user", { userName: option.label })}</Label>
<Select
defaultValue={priorityOptions[option.priority ?? 2]}
value={newPriority}
onChange={(value) => setNewPriority(value ?? priorityOptions[2])}
options={priorityOptions}
/>
</div>
<DialogFooter>
<DialogClose />
<Button onClick={setPriority}>{t("confirm")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const weightDescription = (
<Trans i18nKey="weights_description">
Weights determine how meetings are distributed among hosts.
<Link
className="underline underline-offset-2"
target="_blank"
href="https://cal.com/docs/enterprise-features/teams/round-robin-scheduling#weights">
Learn more
</Link>
</Trans>
);
export function sortHosts(
hostA: { priority: number | null; weight: number | null },
hostB: { priority: number | null; weight: number | null },
isRRWeightsEnabled: boolean
) {
const weightA = hostA.weight ?? 100;
const priorityA = hostA.priority ?? 2;
const weightB = hostB.weight ?? 100;
const priorityB = hostB.priority ?? 2;
if (isRRWeightsEnabled) {
if (weightA === weightB) {
return priorityB - priorityA;
} else {
return weightB - weightA;
}
} else {
return priorityB - priorityA;
}
}
export const WeightDialog = (props: IDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, option, onChange } = props;
const { getValues } = useFormContext<FormValues>();
const [newWeight, setNewWeight] = useState<number>(100);
const setWeight = () => {
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,
weightAdjustment: host.weightAdjustment,
};
});
const sortedHosts = updatedHosts.sort((a, b) => sortHosts(a, b, true));
onChange(sortedHosts);
setIsOpenDialog(false);
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent title={t("set_weight")} description={weightDescription}>
<div className="mb-4 mt-2">
<Label>{t("weight_for_user", { userName: option.label })}</Label>
<div className="w-36">
<TextField
required
min={0}
label={t("Weight")}
value={newWeight}
type="number"
onChange={(e) => setNewWeight(parseInt(e.target.value))}
addOnSuffix={<>%</>}
/>
</div>
</div>
<DialogFooter>
<DialogClose />
<Button onClick={setWeight}>{t("confirm")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+8 -1
View File
@@ -20,7 +20,13 @@ export type AvailabilityOption = {
};
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
export type Host = { isFixed: boolean; userId: number; priority: number };
export type Host = {
isFixed: boolean;
userId: number;
priority: number;
weight: number;
weightAdjustment: number;
};
export type TeamMember = {
value: string;
label: string;
@@ -122,6 +128,7 @@ export type FormValues = {
useEventTypeDestinationCalendarEmail: boolean;
forwardParamsSuccessRedirect: boolean | null;
secondaryEmailId?: number;
isRRWeightsEnabled: boolean;
};
export type LocationFormValues = Pick<FormValues, "id" | "locations" | "bookingFields" | "seatsPerTimeSlot">;
+1
View File
@@ -104,6 +104,7 @@ const commons = {
metadata: EventTypeMetaDataSchema.parse({}),
bookingFields: [],
assignAllTeamMembers: false,
isRRWeightsEnabled: false,
rescheduleWithSameRoundRobinHost: false,
useEventTypeDestinationCalendarEmail: false,
secondaryEmailId: null,
+1
View File
@@ -43,6 +43,7 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
instantMeetingExpiryTimeOffsetInSeconds: true,
aiPhoneCallConfig: true,
assignAllTeamMembers: true,
isRRWeightsEnabled: true,
rescheduleWithSameRoundRobinHost: true,
recurringEvent: true,
locations: true,
@@ -104,7 +104,11 @@ describe("getLuckyUser tests", () => {
expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: [organizerThatShowedUp, organizerThatDidntShowUp],
eventTypeId,
eventType: {
id: eventTypeId,
isRRWeightsEnabled: false,
},
allRRHosts: [],
})
).resolves.toStrictEqual(organizerThatDidntShowUp);
});
@@ -170,7 +174,11 @@ describe("getLuckyUser tests", () => {
expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: [organizerWhoseAttendeeShowedUp, organizerWhoseAttendeeDidntShowUp],
eventTypeId,
eventType: {
id: eventTypeId,
isRRWeightsEnabled: false,
},
allRRHosts: [],
})
).resolves.toStrictEqual(organizerWhoseAttendeeDidntShowUp);
});
@@ -289,7 +297,11 @@ describe("getLuckyUser tests", () => {
fixedHostOrganizerWhoseAttendeeDidNotShowUp,
organizerWhoWasAttendeeAndDidntShowUp,
],
eventTypeId,
eventType: {
id: eventTypeId,
isRRWeightsEnabled: false,
},
allRRHosts: [],
})
).resolves.toStrictEqual(organizerWhoWasAttendeeAndDidntShowUp);
});
@@ -356,7 +368,11 @@ describe("getLuckyUser tests", () => {
expect(
getLuckyUser("MAXIMIZE_AVAILABILITY", {
availableUsers: [user1, user2],
eventTypeId,
eventType: {
id: eventTypeId,
isRRWeightsEnabled: false,
},
allRRHosts: [],
})
).resolves.toStrictEqual(user2);
});
+154 -53
View File
@@ -1,14 +1,31 @@
import type { User } from "@prisma/client";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import prisma from "@calcom/prisma";
import type { Booking } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
async function leastRecentlyBookedUser<T extends Pick<User, "id" | "email">>({
availableUsers,
eventTypeId,
}: {
type PartialBooking = Pick<Booking, "id" | "createdAt" | "userId" | "status"> & {
attendees: { email: string | null }[];
};
type PartialUser = Pick<User, "id" | "email">;
interface GetLuckyUserParams<T extends PartialUser> {
availableUsers: T[];
eventTypeId: number;
}) {
eventType: { id: number; isRRWeightsEnabled: boolean };
allRRHosts: {
user: { id: number; email: string };
weight?: number | null;
weightAdjustment?: number | null;
}[];
}
async function leastRecentlyBookedUser<T extends PartialUser>({
availableUsers,
eventType,
bookingsOfAvailableUsers,
}: GetLuckyUserParams<T> & { bookingsOfAvailableUsers: PartialBooking[] }) {
// First we get all organizers (fixed host/single round robin user)
const organizersWithLastCreated = await prisma.user.findMany({
where: {
@@ -23,7 +40,8 @@ async function leastRecentlyBookedUser<T extends Pick<User, "id" | "email">>({
createdAt: true,
},
where: {
eventTypeId,
eventTypeId: eventType.id,
status: BookingStatus.ACCEPTED,
attendees: {
some: {
noShow: false,
@@ -56,48 +74,19 @@ async function leastRecentlyBookedUser<T extends Pick<User, "id" | "email">>({
{}
);
const bookings = await prisma.booking.findMany({
where: {
AND: [
{
eventTypeId,
},
{
attendees: {
some: {
email: {
in: availableUsers.map((user) => user.email),
},
noShow: false,
},
},
},
],
const attendeeUserIdAndAtCreatedPair = bookingsOfAvailableUsers.reduce(
(aggregate: { [userId: number]: Date }, booking) => {
availableUsers.forEach((user) => {
if (aggregate[user.id]) return; // Bookings are ordered DESC, so if the reducer aggregate
// contains the user id, it's already got the most recent booking marked.
if (!booking.attendees.map((attendee) => attendee.email).includes(user.email)) return;
if (organizerIdAndAtCreatedPair[user.id] > booking.createdAt) return; // only consider bookings if they were created after organizer bookings
aggregate[user.id] = booking.createdAt;
});
return aggregate;
},
select: {
id: true,
createdAt: true,
attendees: {
select: {
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
const attendeeUserIdAndAtCreatedPair = bookings.reduce((aggregate: { [userId: number]: Date }, booking) => {
availableUsers.forEach((user) => {
if (aggregate[user.id]) return; // Bookings are ordered DESC, so if the reducer aggregate
// contains the user id, it's already got the most recent booking marked.
if (!booking.attendees.map((attendee) => attendee.email).includes(user.email)) return;
if (organizerIdAndAtCreatedPair[user.id] > booking.createdAt) return; // only consider bookings if they were created after organizer bookings
aggregate[user.id] = booking.createdAt;
});
return aggregate;
}, {});
{}
);
const userIdAndAtCreatedPair = {
...organizerIdAndAtCreatedPair,
@@ -118,7 +107,7 @@ async function leastRecentlyBookedUser<T extends Pick<User, "id" | "email">>({
return leastRecentlyBookedUser;
}
function getUsersWithHighestPriority<T extends Pick<User, "id" | "email"> & { priority?: number | null }>({
function getUsersWithHighestPriority<T extends PartialUser & { priority?: number | null }>({
availableUsers,
}: {
availableUsers: T[];
@@ -130,18 +119,130 @@ function getUsersWithHighestPriority<T extends Pick<User, "id" | "email"> & { pr
);
}
async function getUsersBasedOnWeights<
T extends PartialUser & {
weight?: number | null;
weightAdjustment?: number | null;
}
>({
availableUsers,
bookingsOfAvailableUsers,
allRRHosts,
eventType,
}: GetLuckyUserParams<T> & { bookingsOfAvailableUsers: PartialBooking[] }) {
//get all bookings of all other RR hosts that are not available
const availableUserIds = new Set(availableUsers.map((user) => user.id));
const notAvailableHosts = allRRHosts.reduce(
(
acc: {
id: number;
email: string;
}[],
host
) => {
if (!availableUserIds.has(host.user.id)) {
acc.push({
id: host.user.id,
email: host.user.email,
});
}
return acc;
},
[]
);
const bookingsOfNotAvailableUsers = await BookingRepository.getAllBookingsForRoundRobin({
eventTypeId: eventType.id,
users: notAvailableHosts,
});
const allBookings = bookingsOfAvailableUsers.concat(bookingsOfNotAvailableUsers);
// Calculate the total weightAdjustments and weight of all round-robin hosts
const { allWeightAdjustments, totalWeight } = allRRHosts.reduce(
(acc, host) => {
acc.allWeightAdjustments += host.weightAdjustment ?? 0;
acc.totalWeight += host.weight ?? 100;
return acc;
},
{ allWeightAdjustments: 0, totalWeight: 0 }
);
// Calculate booking shortfall for each available user
const usersWithBookingShortfalls = availableUsers.map((user) => {
const targetPercentage = (user.weight ?? 100) / totalWeight;
const userBookings = bookingsOfAvailableUsers.filter(
(booking) =>
booking.userId === user.id || booking.attendees.some((attendee) => attendee.email === user.email)
);
const targetNumberOfBookings = (allBookings.length + allWeightAdjustments) * targetPercentage;
const bookingShortfall = targetNumberOfBookings - (userBookings.length + (user.weightAdjustment ?? 0));
return {
...user,
bookingShortfall,
};
});
// Find users with the highest booking shortfall
const maxShortfall = Math.max(...usersWithBookingShortfalls.map((user) => user.bookingShortfall));
const usersWithMaxShortfall = usersWithBookingShortfalls.filter(
(user) => user.bookingShortfall === maxShortfall
);
// ff more user's were found, find users with highest weights
const maxWeight = Math.max(...usersWithMaxShortfall.map((user) => user.weight ?? 100));
const userIdsWithMaxShortfallAndWeight = new Set(
usersWithMaxShortfall.filter((user) => user.weight === maxWeight).map((user) => user.id)
);
return availableUsers.filter((user) => userIdsWithMaxShortfallAndWeight.has(user.id));
}
// TODO: Configure distributionAlgorithm from the event type configuration
// TODO: Add 'MAXIMIZE_FAIRNESS' algorithm.
export async function getLuckyUser<T extends Pick<User, "id" | "email"> & { priority?: number | null }>(
export async function getLuckyUser<
T extends PartialUser & {
priority?: number | null;
weight?: number | null;
weightAdjustment?: number | null;
}
>(
distributionAlgorithm: "MAXIMIZE_AVAILABILITY" = "MAXIMIZE_AVAILABILITY",
{ availableUsers, eventTypeId }: { availableUsers: T[]; eventTypeId: number }
getLuckyUserParams: GetLuckyUserParams<T>
) {
const { availableUsers, eventType, allRRHosts } = getLuckyUserParams;
if (availableUsers.length === 1) {
return availableUsers[0];
}
const bookingsOfAvailableUsers = await BookingRepository.getAllBookingsForRoundRobin({
eventTypeId: eventType.id,
users: availableUsers.map((user) => {
return { id: user.id, email: user.email };
}),
});
switch (distributionAlgorithm) {
case "MAXIMIZE_AVAILABILITY":
const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers });
return leastRecentlyBookedUser<T>({ availableUsers: highestPriorityUsers, eventTypeId });
let possibleLuckyUsers = availableUsers;
if (eventType.isRRWeightsEnabled) {
possibleLuckyUsers = await getUsersBasedOnWeights({
...getLuckyUserParams,
bookingsOfAvailableUsers,
});
}
const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers: possibleLuckyUsers });
return leastRecentlyBookedUser<T>({
...getLuckyUserParams,
availableUsers: highestPriorityUsers,
bookingsOfAvailableUsers,
});
}
}
+60 -1
View File
@@ -1,4 +1,7 @@
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@prisma/client";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { UserRepository } from "./user";
@@ -54,6 +57,62 @@ export class BookingRepository {
});
}
static async getAllBookingsForRoundRobin({
users,
eventTypeId,
}: {
users: { id: number; email: string }[];
eventTypeId: number;
}) {
const whereClause: Prisma.BookingWhereInput = {
OR: [
{
user: {
id: {
in: users.map((user) => user.id),
},
},
OR: [
{
noShowHost: false,
},
{
noShowHost: null,
},
],
},
{
attendees: {
some: {
email: {
in: users.map((user) => user.email),
},
},
},
},
],
attendees: { some: { noShow: false } },
status: BookingStatus.ACCEPTED,
eventTypeId,
};
const allBookings = await prisma.booking.findMany({
where: whereClause,
select: {
id: true,
attendees: true,
userId: true,
createdAt: true,
status: true,
},
orderBy: {
createdAt: "desc",
},
});
return allBookings;
}
static async findBookingByUidAndUserId({ bookingUid, userId }: { bookingUid: string; userId: number }) {
return await prisma.booking.findFirst({
where: {
@@ -457,6 +457,7 @@ export class EventTypeRepository {
onlyShowFirstAvailableSlot: true,
durationLimits: true,
assignAllTeamMembers: true,
isRRWeightsEnabled: true,
rescheduleWithSameRoundRobinHost: true,
successRedirectUrl: true,
forwardParamsSuccessRedirect: true,
@@ -524,6 +525,8 @@ export class EventTypeRepository {
isFixed: true,
userId: true,
priority: true,
weight: true,
weightAdjustment: true,
},
},
userId: true,
+8 -4
View File
@@ -33,7 +33,7 @@ export const buildPerson = (person?: Partial<Person>): Person => {
export const buildBooking = (
booking?: Partial<Booking> & { references?: Partial<BookingReference>[] }
): Booking & { references?: Partial<BookingReference>[] } => {
): Booking & { references?: Partial<BookingReference>[]; attendees?: [] } => {
const uid = faker.datatype.uuid();
return {
id: faker.datatype.number(),
@@ -70,6 +70,7 @@ export const buildBooking = (
rating: null,
noShowHost: null,
ratingFeedback: null,
attendees: [],
...booking,
};
};
@@ -126,6 +127,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
parentId: null,
profileId: null,
secondaryEmailId: null,
isRRWeightsEnabled: false,
eventTypeColor: null,
...eventType,
};
@@ -250,8 +252,8 @@ type UserPayload = Prisma.UserGetPayload<{
};
}>;
export const buildUser = <T extends Partial<UserPayload>>(
user?: T & { priority?: number }
): UserPayload & { priority: number | null } => {
user?: T & { priority?: number; weight?: number; weightAdjustment?: number }
): UserPayload & { priority: number; weight: number; weightAdjustment: number } => {
return {
locked: false,
smsLockState: "UNLOCKED",
@@ -298,7 +300,9 @@ export const buildUser = <T extends Partial<UserPayload>>(
allowSEOIndexing: null,
receiveMonthlyDigestEmail: null,
movedToProfileId: null,
priority: user?.priority ?? null,
priority: user?.priority ?? 2,
weight: user?.weight ?? 100,
weightAdjustment: user?.weightAdjustment ?? 0,
isPlatformManaged: false,
...user,
};
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "isRRWeightsEnabled" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Host" ADD COLUMN "weight" INTEGER,
ADD COLUMN "weightAdjustment" INTEGER;
+13 -8
View File
@@ -43,12 +43,15 @@ enum PeriodType {
}
model Host {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
eventTypeId Int
isFixed Boolean @default(false)
priority Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
eventTypeId Int
isFixed Boolean @default(false)
priority Int?
weight Int?
// amount of fake bookings to be added when calculating the weight (for new hosts, OOO, etc.)
weightAdjustment Int?
@@id([userId, eventTypeId])
@@index([userId])
@@ -137,9 +140,11 @@ model EventType {
assignAllTeamMembers Boolean @default(false)
useEventTypeDestinationCalendarEmail Boolean @default(false)
aiPhoneCallConfig AIPhoneCallConfiguration?
isRRWeightsEnabled Boolean @default(false)
/// @zod.custom(imports.eventTypeColor)
eventTypeColor Json?
rescheduleWithSameRoundRobinHost Boolean @default(false)
eventTypeColor Json?
rescheduleWithSameRoundRobinHost Boolean @default(false)
secondaryEmailId Int?
secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade)
+1
View File
@@ -670,6 +670,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
lockTimeZoneToggleOnBookingPage: true,
requiresBookerEmailVerification: true,
assignAllTeamMembers: true,
isRRWeightsEnabled: true,
eventTypeColor: true,
rescheduleWithSameRoundRobinHost: true,
};
@@ -55,13 +55,15 @@ export const EventTypeUpdateInput = _EventTypeModel
userId: z.number(),
profileId: z.number().or(z.null()).optional(),
isFixed: z.boolean().optional(),
priority: z.number().optional().nullable(),
priority: z.number().min(0).max(4).optional().nullable(),
weight: z.number().min(0).optional().nullable(),
})
)
.optional(),
schedule: z.number().nullable().optional(),
hashedLink: z.string(),
assignAllTeamMembers: z.boolean().optional(),
isRRWeightsEnabled: z.boolean().optional(),
})
.partial()
.extend({
@@ -20,7 +20,12 @@ import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import { setDestinationCalendarHandler } from "../../loggedInViewer/setDestinationCalendar.handler";
import type { TUpdateInputSchema } from "./update.schema";
import { ensureUniqueBookingFields, handleCustomInputs, handlePeriodType } from "./util";
import {
addWeightAdjustmentToNewHosts,
ensureUniqueBookingFields,
handleCustomInputs,
handlePeriodType,
} from "./util";
type SessionUser = NonNullable<TrpcSessionUser>;
type User = {
@@ -67,6 +72,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
offsetStart,
secondaryEmailId,
aiPhoneCallConfig,
isRRWeightsEnabled,
...rest
} = input;
@@ -74,6 +80,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
where: { id },
select: {
title: true,
isRRWeightsEnabled: true,
aiPhoneCallConfig: {
select: {
generalPrompt: true,
@@ -137,6 +144,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const data: Prisma.EventTypeUpdateInput = {
...rest,
bookingFields,
isRRWeightsEnabled,
metadata: rest.metadata === null ? Prisma.DbNull : (rest.metadata as Prisma.InputJsonObject),
eventTypeColor: eventTypeColor === null ? Prisma.DbNull : (eventTypeColor as Prisma.InputJsonObject),
};
@@ -248,14 +256,28 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
code: "FORBIDDEN",
});
}
// weights were already enabled or are enabled now
const isWeightsEnabled =
isRRWeightsEnabled || (typeof isRRWeightsEnabled === "undefined" && eventType.isRRWeightsEnabled);
const hostsWithWeightAdjustment = await addWeightAdjustmentToNewHosts({
hosts,
isWeightsEnabled,
eventTypeId: id,
prisma: ctx.prisma,
});
data.hosts = {
deleteMany: {},
create: hosts.map((host) => {
create: hostsWithWeightAdjustment.map((host) => {
const { ...rest } = host;
return {
...rest,
isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed,
priority: host.priority ?? 2, // default to medium priority
weight: host.weight ?? 100,
weightAdjustment: host.weightAdjustment,
};
}),
};
@@ -1,8 +1,10 @@
import { z } from "zod";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import type { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole, PeriodType } from "@calcom/prisma/enums";
import type { CustomInputSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -152,6 +154,129 @@ export function ensureUniqueBookingFields(fields: z.infer<typeof EventTypeUpdate
}, {} as Record<string, true>);
}
type Host = {
userId: number;
isFixed?: boolean | undefined;
priority?: number | null | undefined;
weight?: number | null | undefined;
};
type User = {
id: number;
email: string;
};
export async function addWeightAdjustmentToNewHosts({
hosts,
isWeightsEnabled,
eventTypeId,
prisma,
}: {
hosts: Host[];
isWeightsEnabled: boolean;
eventTypeId: number;
prisma: PrismaClient;
}): Promise<(Host & { weightAdjustment?: number })[]> {
if (!isWeightsEnabled) return hosts;
// to also have the user email to check for attendees
const usersWithHostData = await prisma.user.findMany({
where: {
id: {
in: hosts.map((host) => host.userId),
},
},
select: {
email: true,
id: true,
hosts: {
where: {
eventTypeId,
},
select: {
isFixed: true,
weightAdjustment: true,
priority: true,
weight: true,
},
},
},
});
const hostsWithUserData = usersWithHostData.map((user) => {
// user.hosts[0] is the previous host data from the db
// hostData is the new host data
const hostData = hosts.find((host) => host.userId === user.id);
return {
isNewRRHost: !hostData?.isFixed && (!user.hosts.length || user.hosts[0].isFixed),
isFixed: hostData?.isFixed ?? false,
weightAdjustment: hostData?.isFixed ? 0 : user.hosts[0]?.weightAdjustment ?? 0,
priority: hostData?.priority ?? 2,
weight: hostData?.weight ?? 100,
user: {
id: user.id,
email: user.email,
},
};
});
const ongoingRRHosts = hostsWithUserData.filter((host) => !host.isFixed && !host.isNewRRHost);
const allRRHosts = hosts.filter((host) => !host.isFixed);
if (ongoingRRHosts.length === allRRHosts.length) {
//no new RR host was added
return hostsWithUserData.map((host) => ({
userId: host.user.id,
isFixed: host.isFixed,
priority: host.priority,
weight: host.weight,
weightAdjustment: host.weightAdjustment,
}));
}
const ongoingHostBookings = await BookingRepository.getAllBookingsForRoundRobin({
eventTypeId,
users: ongoingRRHosts.map((host) => {
return { id: host.user.id, email: host.user.email };
}),
});
const { ongoingHostsWeightAdjustment, ongoingHostsWeights } = ongoingRRHosts.reduce(
(acc, host) => {
acc.ongoingHostsWeightAdjustment += host.weightAdjustment ?? 0;
acc.ongoingHostsWeights += host.weight ?? 0;
return acc;
},
{ ongoingHostsWeightAdjustment: 0, ongoingHostsWeights: 0 }
);
const hostsWithWeightAdjustments = await Promise.all(
hostsWithUserData.map(async (host) => {
let weightAdjustment = !host.isFixed ? host.weightAdjustment : 0;
if (host.isNewRRHost) {
// host can already have bookings, if they ever was assigned before
const existingBookings = await BookingRepository.getAllBookingsForRoundRobin({
eventTypeId,
users: [{ id: host.user.id, email: host.user.email }],
});
const proportionalNrOfBookings =
((ongoingHostBookings.length + ongoingHostsWeightAdjustment) / ongoingHostsWeights) * host.weight;
weightAdjustment = proportionalNrOfBookings - existingBookings.length;
}
return {
userId: host.user.id,
isFixed: host.isFixed,
priority: host.priority,
weight: host.weight,
weightAdjustment: weightAdjustment > 0 ? Math.floor(weightAdjustment) : 0,
};
})
);
return hostsWithWeightAdjustments;
}
export const mapEventType = async (eventType: EventType) => ({
...eventType,
safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined,