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:
co-authored by
CarinaWolli
Alex van Andel
parent
d60f165223
commit
cd311f076c
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">;
|
||||
|
||||
@@ -104,6 +104,7 @@ const commons = {
|
||||
metadata: EventTypeMetaDataSchema.parse({}),
|
||||
bookingFields: [],
|
||||
assignAllTeamMembers: false,
|
||||
isRRWeightsEnabled: false,
|
||||
rescheduleWithSameRoundRobinHost: false,
|
||||
useEventTypeDestinationCalendarEmail: false,
|
||||
secondaryEmailId: null,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user