diff --git a/apps/web/components/eventtype/EventTeamTab.tsx b/apps/web/components/eventtype/EventTeamTab.tsx index a5dc455b19dffa..b3cca481675e52 100644 --- a/apps/web/components/eventtype/EventTeamTab.tsx +++ b/apps/web/components/eventtype/EventTeamTab.tsx @@ -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(); + const { setValue, getValues, control } = useFormContext(); + + const isRRWeightsEnabled = useWatch({ + control, + name: "isRRWeightsEnabled", + }); return (
@@ -210,7 +220,26 @@ const RoundRobinHosts = ({

{t("round_robin_helper")}

-
+
+ {!assignAllTeamMembers && ( + + name="isRRWeightsEnabled" + render={({ field: { value, onChange } }) => ( + { + onChange(active); + + const rrHosts = getValues("hosts").filter((host) => !host.isFixed); + const sortedRRHosts = rrHosts.sort((a, b) => sortHosts(a, b, active)); + setValue("hosts", sortedRRHosts); + }} + /> + )} + /> + )} + 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); + }} />
@@ -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} diff --git a/apps/web/modules/event-types/views/event-types-single-view.tsx b/apps/web/modules/event-types/views/event-types-single-view.tsx index ee64f07cb6f985..d3a1a3e0d26307 100644 --- a/apps/web/modules/event-types/views/event-types-single-view.tsx +++ b/apps/web/modules/event-types/views/event-types-single-view.tsx @@ -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({ @@ -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; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 1972e02d06fdc3..566d93457890d6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", + "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}})", diff --git a/apps/web/test/lib/CheckForEmptyAssignment.test.ts b/apps/web/test/lib/CheckForEmptyAssignment.test.ts index e8508e70897ea4..eba9c22853b342 100644 --- a/apps/web/test/lib/CheckForEmptyAssignment.test.ts +++ b/apps/web/test/lib/CheckForEmptyAssignment.test.ts @@ -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); diff --git a/apps/web/test/lib/team-event-types.test.ts b/apps/web/test/lib/team-event-types.test.ts index e7cb1584ea3223..72df7a81522901 100644 --- a/apps/web/test/lib/team-event-types.test.ts +++ b/apps/web/test/lib/team-event-types.test.ts @@ -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); + }); +}); diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 035ed74826373b..48868bdafa5ec1 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -185,6 +185,7 @@ type WhiteListedBookingProps = { credentialId?: number | null; })[]; bookingSeat?: Prisma.BookingSeatCreateInput[]; + createdAt?: string; }; type InputBooking = Partial> & WhiteListedBookingProps; diff --git a/packages/app-store/routing-forms/components/SingleForm.tsx b/packages/app-store/routing-forms/components/SingleForm.tsx index aa10c4acd539a9..357cadb5f92596 100644 --- a/packages/app-store/routing-forms/components/SingleForm.tsx +++ b/packages/app-store/routing-forms/components/SingleForm.tsx @@ -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( diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index ccf4a7912bf357..5df4daacfe3a86 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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 diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 12b9a723aef7a2..ccd0841b7872a2 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -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: { diff --git a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts index a7117bec7e8482..1e2a5a0e46e228 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts @@ -31,10 +31,12 @@ export const loadUsers = async (eventType: EventType, dynamicUserList: string[], const loadUsersByEventType = async (eventType: EventType): Promise => { 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; }; diff --git a/packages/features/bookings/lib/handleNewBooking/types.ts b/packages/features/bookings/lib/handleNewBooking/types.ts index cb4e7fb2d6d573..2b42e2de13fad4 100644 --- a/packages/features/bookings/lib/handleNewBooking/types.ts +++ b/packages/features/bookings/lib/handleNewBooking/types.ts @@ -57,6 +57,8 @@ export type IsFixedAwareUser = User & { credentials: CredentialPayload[]; organization?: { slug: string }; priority?: number; + weight?: number; + weightAdjustment?: number; }; export type NewBookingEventType = AwaitedGetDefaultEvent | getEventTypeResponse; diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index 6f5c7ee67fcabf..cc58d445e8b66a 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -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"); diff --git a/packages/features/eventtypes/components/AddMembersWithSwitch.tsx b/packages/features/eventtypes/components/AddMembersWithSwitch.tsx index 2ee0fbef6532b0..f273770eeb9e87 100644 --- a/packages/features/eventtypes/components/AddMembersWithSwitch.tsx +++ b/packages/features/eventtypes/components/AddMembersWithSwitch.tsx @@ -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; helperText?: React.ReactNode | string; + isRRWeightsEnabled?: boolean; } & Omit>, "onChange" | "value">) => { return (
@@ -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} />
@@ -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(); return (
-
+
{automaticAddAllEnabled ? (
) : ( <> diff --git a/packages/features/eventtypes/components/CheckedTeamSelect.tsx b/packages/features/eventtypes/components/CheckedTeamSelect.tsx index 58ac75a9f2c2f6..094b086dac0cfa 100644 --- a/packages/features/eventtypes/components/CheckedTeamSelect.tsx +++ b/packages/features/eventtypes/components/CheckedTeamSelect.tsx @@ -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, "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 = ({

{option.label}

{option && !option.isFixed ? ( - - - + <> + + + + {isRRWeightsEnabled ? ( + + ) : ( + <> + )} + ) : ( <> )} @@ -89,7 +98,7 @@ export const CheckedTeamSelect = ({ 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" />
@@ -97,12 +106,20 @@ export const CheckedTeamSelect = ({ ))} {currentOption && !currentOption.isFixed ? ( - + <> + + + ) : ( <> )} @@ -110,67 +127,6 @@ export const CheckedTeamSelect = ({ ); }; -interface IPriiorityDialog { - isOpenDialog: boolean; - setIsOpenDialog: Dispatch>; - option: CheckedSelectOption; - onChange: (value: readonly CheckedSelectOption[]) => void; -} - -const PriorityDialog = (props: IPriiorityDialog) => { - const { t } = useLocale(); - const { isOpenDialog, setIsOpenDialog, option, onChange } = props; - const { getValues } = useFormContext(); - - 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 ( - - -
- - setNewPriority(value ?? priorityOptions[2])} + options={priorityOptions} + /> +
+ + + + + +
+
+ ); +}; + +export const weightDescription = ( + + Weights determine how meetings are distributed among hosts. + + Learn more + + +); + +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(); + const [newWeight, setNewWeight] = useState(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 ( + + +
+ +
+ setNewWeight(parseInt(e.target.value))} + addOnSuffix={<>%} + /> +
+
+ + + + +
+
+ ); +}; diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 4aea36d1de8898..3a9040cd682354 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -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; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 6c6cb244f589db..f28cebcaf1f4c9 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -104,6 +104,7 @@ const commons = { metadata: EventTypeMetaDataSchema.parse({}), bookingFields: [], assignAllTeamMembers: false, + isRRWeightsEnabled: false, rescheduleWithSameRoundRobinHost: false, useEventTypeDestinationCalendarEmail: false, secondaryEmailId: null, diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts index 29d68c42ab9b69..a1dab4460cc28b 100644 --- a/packages/lib/server/eventTypeSelect.ts +++ b/packages/lib/server/eventTypeSelect.ts @@ -43,6 +43,7 @@ export const eventTypeSelect = Prisma.validator()({ instantMeetingExpiryTimeOffsetInSeconds: true, aiPhoneCallConfig: true, assignAllTeamMembers: true, + isRRWeightsEnabled: true, rescheduleWithSameRoundRobinHost: true, recurringEvent: true, locations: true, diff --git a/packages/lib/server/getLuckyUser.integration-test.ts b/packages/lib/server/getLuckyUser.integration-test.ts index b563dcb1db459b..454c4a7db67ef0 100644 --- a/packages/lib/server/getLuckyUser.integration-test.ts +++ b/packages/lib/server/getLuckyUser.integration-test.ts @@ -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); }); diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index f679f1f4d45fba..e9e336f440fed4 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -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>({ - availableUsers, - eventTypeId, -}: { +type PartialBooking = Pick & { + attendees: { email: string | null }[]; +}; + +type PartialUser = Pick; + +interface GetLuckyUserParams { availableUsers: T[]; - eventTypeId: number; -}) { + eventType: { id: number; isRRWeightsEnabled: boolean }; + allRRHosts: { + user: { id: number; email: string }; + weight?: number | null; + weightAdjustment?: number | null; + }[]; +} + +async function leastRecentlyBookedUser({ + availableUsers, + eventType, + bookingsOfAvailableUsers, +}: GetLuckyUserParams & { 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>({ createdAt: true, }, where: { - eventTypeId, + eventTypeId: eventType.id, + status: BookingStatus.ACCEPTED, attendees: { some: { noShow: false, @@ -56,48 +74,19 @@ async function leastRecentlyBookedUser>({ {} ); - const bookings = await prisma.booking.findMany({ - where: { - AND: [ - { - eventTypeId, - }, - { - attendees: { - some: { - email: { - in: availableUsers.map((user) => user.email), - }, - noShow: false, - }, - }, - }, - ], - }, - select: { - id: true, - createdAt: true, - attendees: { - select: { - email: true, - }, - }, + 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; }, - 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>({ return leastRecentlyBookedUser; } -function getUsersWithHighestPriority & { priority?: number | null }>({ +function getUsersWithHighestPriority({ availableUsers, }: { availableUsers: T[]; @@ -130,18 +119,130 @@ function getUsersWithHighestPriority & { pr ); } +async function getUsersBasedOnWeights< + T extends PartialUser & { + weight?: number | null; + weightAdjustment?: number | null; + } +>({ + availableUsers, + bookingsOfAvailableUsers, + allRRHosts, + eventType, +}: GetLuckyUserParams & { 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 & { 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 ) { + 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({ availableUsers: highestPriorityUsers, eventTypeId }); + let possibleLuckyUsers = availableUsers; + if (eventType.isRRWeightsEnabled) { + possibleLuckyUsers = await getUsersBasedOnWeights({ + ...getLuckyUserParams, + bookingsOfAvailableUsers, + }); + } + const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers: possibleLuckyUsers }); + + return leastRecentlyBookedUser({ + ...getLuckyUserParams, + availableUsers: highestPriorityUsers, + bookingsOfAvailableUsers, + }); } } diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index b58d75a86bc968..8e054088fd8482 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -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: { diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index e1a6110c6c9655..f60e0a9f6bbb9d 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -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, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 7e33ea50614662..48e8053276098b 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -33,7 +33,7 @@ export const buildPerson = (person?: Partial): Person => { export const buildBooking = ( booking?: Partial & { references?: Partial[] } -): Booking & { references?: Partial[] } => { +): Booking & { references?: Partial[]; 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 => { parentId: null, profileId: null, secondaryEmailId: null, + isRRWeightsEnabled: false, eventTypeColor: null, ...eventType, }; @@ -250,8 +252,8 @@ type UserPayload = Prisma.UserGetPayload<{ }; }>; export const buildUser = >( - 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 = >( 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, }; diff --git a/packages/prisma/migrations/20240626171118_add_weights_to_host/migration.sql b/packages/prisma/migrations/20240626171118_add_weights_to_host/migration.sql new file mode 100644 index 00000000000000..a06969c16417eb --- /dev/null +++ b/packages/prisma/migrations/20240626171118_add_weights_to_host/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index cb005a2d39c595..3d8f8d9c6bb8f1 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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) diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index eca0100aab6bb3..78b249c1d671e0 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -670,6 +670,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit; 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, }; }), }; diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts index 1418fe952c1296..a4891c05a75aee 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/util.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts @@ -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); } +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,