diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index dfccb51525..8e23e00f79 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2770,6 +2770,12 @@ "salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account", "salesforce_option": "Salesforce Option", "lookup_field_name": "Lookup Field Name", + "rr_distribution_method": "Distribution", + "rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.", + "rr_distribution_method_availability_title": "Maximize availability", + "rr_distribution_method_availability_description": "Allows bookers to book meetings whenever a host is available. Use this to maximize the number of potential meetings booked and when the even distribution of meetings across hosts is less important.", + "rr_distribution_method_balanced_title": "Load balancing", + "rr_distribution_method_balanced_description": "We will monitor how many bookings have been made with each host and compare this with others, disabling some hosts that are too far ahead so bookings are evenly distributed.", "exclude_emails_that_contain" : "Exclude emails that contain ...", "exclude_emails_match_found_error_message" : "Please enter a valid work email address", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 5cc7931274..4c8fb1cbbc 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -57,7 +57,7 @@ import logger from "@calcom/lib/logger"; import { handlePayment } from "@calcom/lib/payment/handlePayment"; import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { DistributionMethod, getLuckyUser } from "@calcom/lib/server/getLuckyUser"; +import { getLuckyUser } from "@calcom/lib/server/getLuckyUser"; import { getTranslation } from "@calcom/lib/server/i18n"; import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; @@ -469,7 +469,7 @@ async function handler( const newLuckyUser = isSameRoundRobinHost ? freeUsers.find((user) => user.id === originalRescheduledBookingUserId) - : await getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + : await getLuckyUser({ // find a lucky user that is not already in the luckyUsers array availableUsers: freeUsers, allRRHosts: eventTypeWithUsers.hosts.filter((host) => !host.isFixed), diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index f1a969c5d1..f508894ae7 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -53,6 +53,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { lockTimeZoneToggleOnBookingPage: true, requiresConfirmation: true, requiresBookerEmailVerification: true, + maxLeadThreshold: true, minimumBookingNotice: true, userId: true, price: true, diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts index e51c5e5b6e..d51bb7e683 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { IncomingMessage } from "http"; import type { Logger } from "tslog"; +import { filterHostsByLeadThreshold } from "@calcom/lib/bookings/filterHostsByLeadThreshold"; import { HttpError } from "@calcom/lib/http-error"; import { getPiiFreeUser } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -16,9 +17,13 @@ import type { NewBookingEventType } from "./types"; type Users = (Awaited>[number] & { isFixed?: boolean; metadata?: Prisma.JsonValue; + createdAt?: Date; })[]; -type EventType = Pick; +type EventType = Pick< + NewBookingEventType, + "hosts" | "users" | "id" | "userId" | "schedulingType" | "maxLeadThreshold" +>; type InputProps = { req: IncomingMessage; @@ -79,7 +84,7 @@ export async function loadAndValidateUsers({ } if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" }); - + // map fixed users users = users.map((user) => ({ ...user, isFixed: @@ -88,6 +93,22 @@ export async function loadAndValidateUsers({ : user.isFixed || eventType.schedulingType !== SchedulingType.ROUND_ROBIN, })); + const qualifiedHosts = await filterHostsByLeadThreshold({ + eventTypeId: eventType.id, + hosts: eventType.hosts.map((host) => ({ + isFixed: host.isFixed, + createdAt: host.createdAt, + email: host.user.email, + user: host.user, + })), + maxLeadThreshold: eventType.maxLeadThreshold, + }); + if (qualifiedHosts.length) { + // remove users that are not in the qualified hosts array + const qualifiedHostIds = new Set(qualifiedHosts.map((qualifiedHost) => qualifiedHost.user.id)); + users = users.filter((user) => qualifiedHostIds.has(user.id)); + } + logger.debug( "Concerned users", safeStringify({ diff --git a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts index 6d91f35b84..fe8c890663 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts @@ -46,13 +46,19 @@ export const loadUsers = async ({ const loadUsersByEventType = async (eventType: EventType): Promise => { const hosts = eventType.hosts || []; - const users = hosts.map(({ user, isFixed, priority, weight }) => ({ + const users = hosts.map(({ user, isFixed, priority, weight, createdAt }) => ({ ...user, isFixed, priority, weight, + createdAt, })); - return users.length ? users : eventType.users; + return users.length + ? users + : eventType.users.map((user) => ({ + ...user, + createdAt: null, + })); }; const loadDynamicUsers = async (dynamicUserList: string[], currentOrgDomain: string | null) => { diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts index cd8abecf10..ca2408a0e8 100644 --- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts +++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts @@ -225,7 +225,6 @@ export default async function handleChildrenEventTypes({ const updatePayloadFiltered = Object.entries(updatePayload) .filter(([key, _]) => key !== "children") .reduce((newObj, [key, value]) => ({ ...newObj, [key]: value }), {}); - console.log({ unlockedFieldProps }); // Update event types for old users const oldEventTypes = await prisma.$transaction( oldUserIds.map((userId) => { diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index 841ca1bce8..405952e8c5 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -21,7 +21,6 @@ import { SENDER_NAME } from "@calcom/lib/constants"; import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import logger from "@calcom/lib/logger"; import { getLuckyUser } from "@calcom/lib/server"; -import { DistributionMethod } from "@calcom/lib/server/getLuckyUser"; import { getTranslation } from "@calcom/lib/server/i18n"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; @@ -126,7 +125,7 @@ export const roundRobinReassignment = async ({ roundRobinReassignLogger ); - const reassignedRRHost = await getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + const reassignedRRHost = await getLuckyUser({ availableUsers, eventType: { id: eventType.id, diff --git a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx index 64dab3d059..7fc2eeaa80 100644 --- a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx +++ b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -19,7 +19,7 @@ import type { } from "@calcom/features/eventtypes/lib/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { SchedulingType } from "@calcom/prisma/enums"; -import { Label, Select, SettingsToggle } from "@calcom/ui"; +import { Label, Select, SettingsToggle, RadioGroup as RadioArea } from "@calcom/ui"; export type EventTeamAssignmentTabBaseProps = Pick; @@ -333,7 +333,6 @@ const Hosts = ({ assignAllTeamMembers: boolean; setAssignAllTeamMembers: Dispatch>; }) => { - const { t } = useLocale(); const { control, setValue, @@ -498,6 +497,44 @@ export const EventTeamAssignmentTab = ({ team, teamMembers, eventType }: EventTe /> +
+
+ +

+ {t("rr_distribution_method_description")} +

+
+
+ ( + { + if (val === "loadBalancing") onChange(3); + else onChange(null); + }} + className="mt-1 flex flex-col gap-4"> + + {t("rr_distribution_method_availability_title")} +

{t("rr_distribution_method_availability_description")}

+
+ + {t("rr_distribution_method_balanced_title")} +

{t("rr_distribution_method_balanced_description")}

+
+
+ )} + /> +
+
; diff --git a/packages/lib/bookings/filterHostsByLeadThreshold.test.ts b/packages/lib/bookings/filterHostsByLeadThreshold.test.ts new file mode 100644 index 0000000000..eae55c09a4 --- /dev/null +++ b/packages/lib/bookings/filterHostsByLeadThreshold.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; + +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import { + filterHostsByLeadThreshold, + errorCodes, + _filterHostByLeadThreshold, +} from "./filterHostsByLeadThreshold"; + +// Import the original Prisma client + +// Mocking setup +const prismaMock = { + booking: { + groupBy: vi.fn(), // Mock the groupBy method + }, +}; + +// Use `vi.spyOn` to make `prisma.booking.groupBy` call the mock instead +vi.spyOn(prisma.booking, "groupBy").mockImplementation(prismaMock.booking.groupBy); + +afterEach(() => { + // Clear call history before each test to avoid cross-test interference + prismaMock.booking.groupBy.mockClear(); +}); + +describe("filterHostByLeadThreshold", () => { + it("skips filter if host is fixed", async () => { + const hosts = [{ isFixed: true, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } }]; + expect( + filterHostsByLeadThreshold({ + hosts, + maxLeadThreshold: 3, + eventTypeId: 1, + }) + ).resolves.toStrictEqual(hosts); + }); + it("skips filter if lead threshold is null", async () => { + const hosts = [{ isFixed: false, createdAt: new Date(), user: { id: 1 } }]; + expect( + filterHostsByLeadThreshold({ + hosts, + maxLeadThreshold: null, + eventTypeId: 1, + }) + ).resolves.toStrictEqual(hosts); + }); + it("throws error when maxLeadThreshold = 0, 0 ahead makes no sense.", () => { + expect(() => + _filterHostByLeadThreshold({ + host: { leadOffset: 3 }, + maxLeadThreshold: 0, + }) + ).toThrow(errorCodes.MAX_LEAD_THRESHOLD_FALSY); + }); + + it("correctly disqualifies a host when the lead offset is exceeding the threshold", async () => { + prismaMock.booking.groupBy.mockResolvedValue([ + { userId: 1, _count: { _all: 5 } }, + { userId: 2, _count: { _all: 10 } }, + ]); + const hosts = [ + { isFixed: false, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } }, + { isFixed: false, createdAt: new Date(), user: { id: 2, email: "example2@acme.com" } }, + ]; + // host is not disqualified as the threshold of 11 is not exceeded. + expect( + filterHostsByLeadThreshold({ + hosts, + maxLeadThreshold: 11, + eventTypeId: 1, + }) + ).resolves.toStrictEqual(hosts); + // with a reduced threshold of 3 the second host (t=10) is disqualified + expect( + filterHostsByLeadThreshold({ + hosts, + maxLeadThreshold: 3, + eventTypeId: 1, + }) + ).resolves.toStrictEqual([hosts.find(({ user: { id: userId } }) => userId === 1)]); + // double check that lead thresholds are disabled when maxLeadThreshold=null as I'm paranoid. + expect( + filterHostsByLeadThreshold({ + hosts, + maxLeadThreshold: null, + eventTypeId: 1, + }) + ).resolves.toStrictEqual(hosts); + }); + + it("ignores fixed users towards fairness disqualification", async () => { + prismaMock.booking.groupBy.mockResolvedValue([ + { userId: 1, _count: { _all: 5 } }, + { userId: 2, _count: { _all: 10 } }, + ]); + const hosts = [ + // fixed users do not count towards disqualification. + { isFixed: true, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } }, + { isFixed: false, createdAt: new Date(), user: { id: 2, email: "example2@acme.com" } }, + ]; + // with a reduced threshold of 3 the second host (t=10) is disqualified + expect( + filterHostsByLeadThreshold({ + hosts, + maxLeadThreshold: 3, + eventTypeId: 1, + }) + ).resolves.toStrictEqual([hosts.find(({ user: { id: userId } }) => userId === 1)]); + expect(prismaMock.booking.groupBy).toHaveBeenCalledWith({ + by: ["userId"], + where: { + OR: [ + { + user: { + id: { + in: [2], + }, + }, + OR: [ + { + noShowHost: false, + }, + { + noShowHost: null, + }, + ], + }, + { + attendees: { + some: { + email: { + in: ["example2@acme.com"], + }, + }, + }, + }, + ], + attendees: { some: { noShow: false } }, + status: BookingStatus.ACCEPTED, + eventTypeId: 1, + createdAt: { + gte: hosts[1].createdAt, + }, + }, + _count: { _all: true }, + }); + }); +}); diff --git a/packages/lib/bookings/filterHostsByLeadThreshold.ts b/packages/lib/bookings/filterHostsByLeadThreshold.ts new file mode 100644 index 0000000000..85bf915fe7 --- /dev/null +++ b/packages/lib/bookings/filterHostsByLeadThreshold.ts @@ -0,0 +1,129 @@ +import { BookingRepository } from "@calcom/lib/server/repository/booking"; + +const START_OF_MONTH = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1)); + +export const errorCodes = { + MAX_LEAD_THRESHOLD_FALSY: "Max lead threshold should be null or > 1, not 0.", +} as const; + +function getMostRecentDate(dates: Date[]): Date { + if (dates.length === 0) + throw new Error("Array of date length provided in getMostRecentDate should not be empty."); + + return dates.reduce((mostRecent, current) => (current > mostRecent ? current : mostRecent)); +} + +const computeLeadOffsets = async >({ + hosts, + eventTypeId, +}: { + eventTypeId: number; + hosts: (T & { + isFixed: false; + createdAt: Date; + user: { + id: number; + email: string; + }; + })[]; +}) => { + if (!hosts.length) return []; + // use either the beginning of the month, of the most recently added host; whichever is most recent + const startDate = getMostRecentDate([...hosts.map(({ createdAt }) => createdAt), START_OF_MONTH]); + // we need booking data now, this cannot be queried ahead of time as it requires knowing the most recent host date + // data only available after the initial call. + const bookingCounts = await BookingRepository.groupByActiveBookingCounts({ + startDate, + users: hosts.map((host) => ({ + ...host.user, + })), + eventTypeId, + }); + const { minBookingCount, bookingCountMap } = bookingCounts.reduce( + ( + acc: { + minBookingCount: number; + bookingCountMap: Record; + }, + booking + ) => { + // satisfy TS, obviously as we've where'd on userId it cannot be null but try telling TS that.. + if (!booking.userId) return acc; + acc.minBookingCount = Math.min(acc.minBookingCount, booking._count._all || Infinity); + acc.bookingCountMap[booking.userId] = booking._count._all; + return acc; + }, + { + minBookingCount: bookingCounts.length < hosts.length ? 0 : Infinity, + bookingCountMap: {}, + } + ); + + return hosts.map((host) => { + const leadOffset = (bookingCountMap[host.user.id] || 0) - minBookingCount; + return { + ...host, + leadOffset, + }; + }); +}; + +export const _filterHostByLeadThreshold = ({ + host, + maxLeadThreshold, +}: { + host: { leadOffset: number }; + maxLeadThreshold: number; +}) => { + // it's possible that maxLeadThreshold is given as 0, which makes '0' sense. + if (!maxLeadThreshold) { + throw new Error(errorCodes.MAX_LEAD_THRESHOLD_FALSY); + } + return host.leadOffset < maxLeadThreshold; // leadOffset is 0-indexed. +}; + +/* + * Filter the hosts by lead threshold, disqualifying hosts that have exceeded the maximum + * + * @throws errorCodes.MAX_LEAD_THRESHOLD_FALSY + */ +export const filterHostsByLeadThreshold = async >({ + hosts, + maxLeadThreshold, + eventTypeId, +}: { + hosts: ({ isFixed: boolean; createdAt: Date; user: { id: number; email: string } } & T)[]; + maxLeadThreshold: number | null; + eventTypeId: number; +}): Promise[]> => { + if (maxLeadThreshold === null) return hosts; + // Calculate offsets for non-fixed hosts only once + const computedRoundRobinHosts = await computeLeadOffsets({ + eventTypeId, + hosts: hosts.filter((host) => !host.isFixed).map((host) => ({ ...host, isFixed: false as const })), + }); + // Track indices of non-fixed hosts for easy mapping back to the original order + let roundRobinIndex = 0; + return hosts + .map((host) => { + if (host.isFixed) { + // Return fixed hosts as they are + return host as Omit; + } else { + // Apply lead threshold filtering on round-robin hosts + const roundRobinHost = { ...computedRoundRobinHosts[roundRobinIndex++] }; + if ( + _filterHostByLeadThreshold({ + host: roundRobinHost, + maxLeadThreshold, + }) + ) { + // cleanup with destructure to remove leadOffset + const { leadOffset, ...roundRobinHostWithoutLeadOffset } = roundRobinHost; + return roundRobinHostWithoutLeadOffset; + } + return null; + } + }) + .filter((host): host is Omit => host !== null); +}; diff --git a/packages/lib/bookings/findQualifiedHosts.test.ts b/packages/lib/bookings/findQualifiedHosts.test.ts new file mode 100644 index 0000000000..5ea8d1755c --- /dev/null +++ b/packages/lib/bookings/findQualifiedHosts.test.ts @@ -0,0 +1,93 @@ +import { vi, it, describe, expect, afterEach } from "vitest"; + +import { SchedulingType } from "@calcom/prisma/enums"; + +import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold"; +import { findQualifiedHosts } from "./findQualifiedHosts"; + +// Mock the filterHostsByLeadThreshold function +vi.mock("./filterHostsByLeadThreshold", () => ({ + filterHostsByLeadThreshold: vi.fn(), +})); + +// Clear call history after each test +afterEach(() => { + (filterHostsByLeadThreshold as vi.Mock).mockClear(); +}); + +describe("findQualifiedHosts", async () => { + it("should return qualified hosts based on mock of filterHostsByLeadThreshold", async () => { + const hosts = [ + { + isFixed: true, + createdAt: new Date(), + user: { + id: 2, + email: "hellouser2@email.com", + }, + }, + { + isFixed: false, + createdAt: new Date(), + user: { + id: 1, + email: "hellouser@email.com", + }, + }, + ]; + + // Configure the mock return value + (filterHostsByLeadThreshold as vi.Mock).mockResolvedValue(hosts); + + // Define the input for the test + const eventType = { + id: 1, + hosts, + users: [], + schedulingType: SchedulingType.ROUND_ROBIN, + maxLeadThreshold: null, + }; + + // Call the function under test + const result = await findQualifiedHosts(eventType); + + // Verify the result + expect(result).toEqual(hosts); + }); + + it("should return hosts after valid input with users", async () => { + const users = [ + { + email: "hello@gmail.com", + id: 1, + }, + { + email: "hello2@gmail.com", + id: 2, + }, + ]; + + // Define the input for the test + const eventType = { + id: 1, + hosts: [], + users, + schedulingType: null, + maxLeadThreshold: null, + }; + + // Call the function under test + const result = await findQualifiedHosts(eventType); + + // Verify the result + expect(result).toEqual( + users.map((user) => ({ + user: user, + isFixed: true, + email: user.email, + })) + ); + + expect(filterHostsByLeadThreshold).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/lib/bookings/findQualifiedHosts.ts b/packages/lib/bookings/findQualifiedHosts.ts new file mode 100644 index 0000000000..a4da0617a3 --- /dev/null +++ b/packages/lib/bookings/findQualifiedHosts.ts @@ -0,0 +1,42 @@ +import { SchedulingType } from "@calcom/prisma/enums"; + +import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold"; + +export const findQualifiedHosts = async < + T extends { email: string; id: number } & Record +>(eventType: { + id: number; + maxLeadThreshold: number | null; + hosts?: ({ isFixed: boolean; createdAt: Date } & { + user: T; + })[]; + users?: T[]; + schedulingType: SchedulingType | null; +}): Promise< + { + isFixed: boolean; + email: string; + user: T; + }[] +> => { + const hosts = + eventType.hosts?.length && eventType.schedulingType + ? await filterHostsByLeadThreshold({ + eventTypeId: eventType.id, + hosts: eventType.hosts.map((host) => ({ + isFixed: host.isFixed, + createdAt: host.createdAt, + email: host.user.email, + user: host.user, + })), + maxLeadThreshold: eventType.maxLeadThreshold, + }) + : (eventType.users || []).map((user) => { + return { + isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE, + email: user.email, + user: user, + }; + }); + return hosts; +}; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 62a6f80851..2723c9c5d9 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -110,6 +110,7 @@ const commons = { useEventTypeDestinationCalendarEmail: false, secondaryEmailId: null, secondaryEmail: null, + maxLeadThreshold: null, }; export const dynamicEvent = { diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts index 898c26e9d5..6613958d98 100644 --- a/packages/lib/server/eventTypeSelect.ts +++ b/packages/lib/server/eventTypeSelect.ts @@ -56,4 +56,5 @@ export const eventTypeSelect = Prisma.validator()({ durationLimits: true, eventTypeColor: true, hideCalendarEventDetails: true, + maxLeadThreshold: true, }); diff --git a/packages/lib/server/getLuckyUser.integration-test.ts b/packages/lib/server/getLuckyUser.integration-test.ts index 0feb2320c4..66e9a85b31 100644 --- a/packages/lib/server/getLuckyUser.integration-test.ts +++ b/packages/lib/server/getLuckyUser.integration-test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest"; import prisma from "@calcom/prisma"; -import { DistributionMethod, getLuckyUser } from "./getLuckyUser"; +import { getLuckyUser } from "./getLuckyUser"; describe("getLuckyUser Integration tests", () => { describe("should not consider no show bookings for round robin: ", () => { @@ -102,7 +102,7 @@ describe("getLuckyUser Integration tests", () => { userIds.push(organizerThatShowedUp.id, organizerThatDidntShowUp.id); expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: [organizerThatShowedUp, organizerThatDidntShowUp], eventType: { id: eventTypeId, @@ -172,7 +172,7 @@ describe("getLuckyUser Integration tests", () => { userIds.push(organizerWhoseAttendeeShowedUp.id, organizerWhoseAttendeeDidntShowUp.id); expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: [organizerWhoseAttendeeShowedUp, organizerWhoseAttendeeDidntShowUp], eventType: { id: eventTypeId, @@ -291,7 +291,7 @@ describe("getLuckyUser Integration tests", () => { ); expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: [ organizerWhoseAttendeeShowedUp, fixedHostOrganizerWhoseAttendeeDidNotShowUp, @@ -366,7 +366,7 @@ describe("getLuckyUser Integration tests", () => { userIds.push(user1.id, user2.id); expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: [user1, user2], eventType: { id: eventTypeId, diff --git a/packages/lib/server/getLuckyUser.test.ts b/packages/lib/server/getLuckyUser.test.ts index 73a47e56c2..73370755bb 100644 --- a/packages/lib/server/getLuckyUser.test.ts +++ b/packages/lib/server/getLuckyUser.test.ts @@ -5,7 +5,7 @@ import { expect, it, describe } from "vitest"; import dayjs from "@calcom/dayjs"; import { buildUser, buildBooking } from "@calcom/lib/test/builder"; -import { DistributionMethod, getLuckyUser } from "./getLuckyUser"; +import { getLuckyUser } from "./getLuckyUser"; type NonEmptyArray = [T, ...T[]]; type GetLuckyUserAvailableUsersType = NonEmptyArray>; @@ -44,7 +44,7 @@ it("can find lucky user with maximize availability", async () => { prismaMock.booking.findMany.mockResolvedValue([]); await expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: users, eventType: { id: 1, @@ -91,7 +91,7 @@ it("can find lucky user with maximize availability and priority ranking", async // both users have medium priority (one user has no priority set, default to medium) so pick least recently booked await expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: users, eventType: { id: 1, @@ -146,7 +146,7 @@ it("can find lucky user with maximize availability and priority ranking", async prismaMock.host.findMany.mockResolvedValue([]); // pick the user with the highest priority await expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: usersWithPriorities, eventType: { id: 1, @@ -206,7 +206,7 @@ it("can find lucky user with maximize availability and priority ranking", async // pick the least recently booked user of the two with the highest priority await expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: usersWithSamePriorities, eventType: { id: 1, @@ -292,7 +292,7 @@ describe("maximize availability and weights", () => { ]; await expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: users, eventType: { id: 1, @@ -386,7 +386,7 @@ describe("maximize availability and weights", () => { ]; await expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: users, eventType: { id: 1, @@ -480,7 +480,7 @@ describe("maximize availability and weights", () => { ]; await expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: users, eventType: { id: 1, @@ -560,7 +560,7 @@ describe("maximize availability and weights", () => { }), ]); await expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: users, eventType: { id: 1, @@ -583,7 +583,7 @@ describe("maximize availability and weights", () => { }), ]); await expect( - getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, { + getLuckyUser({ availableUsers: users, eventType: { id: 1, diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index 2dc048ebaf..b6af30bd58 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -5,13 +5,6 @@ import prisma from "@calcom/prisma"; import type { Booking } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; -export enum DistributionMethod { - PRIORITIZE_AVAILABILITY = "PRIORITIZE_AVAILABILITY", - // BALANCED_ASSIGNMENT = "BALANCED_ASSIGNMENT", - // ROUND_ROBIN (for fairness, rotating through assignees) - // LOAD_BALANCED (ensuring an even workload) -} - type PartialBooking = Pick & { attendees: { email: string | null }[]; }; @@ -291,17 +284,12 @@ async function filterUsersBasedOnWeights< return remainingUsersAfterWeightFilter; } -// TODO: Configure distributionAlgorithm from the event type configuration -// TODO: Add 'MAXIMIZE_FAIRNESS' algorithm. export async function getLuckyUser< T extends PartialUser & { priority?: number | null; weight?: number | null; } ->( - distributionMethod: DistributionMethod = DistributionMethod.PRIORITIZE_AVAILABILITY, - { availableUsers, ...getLuckyUserParams }: GetLuckyUserParams -) { +>({ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams) { const { eventType } = getLuckyUserParams; // there is only one user if (availableUsers.length === 1) { @@ -315,25 +303,20 @@ export async function getLuckyUser< startDate: startOfMonth, endDate: new Date(), }); - - switch (distributionMethod) { - case DistributionMethod.PRIORITIZE_AVAILABILITY: { - if (eventType.isRRWeightsEnabled) { - availableUsers = await filterUsersBasedOnWeights({ - ...getLuckyUserParams, - availableUsers, - bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers, - }); - } - const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers }); - // No need to round-robin through the only user, return early also. - if (highestPriorityUsers.length === 1) return highestPriorityUsers[0]; - // TS is happy. - return leastRecentlyBookedUser({ - ...getLuckyUserParams, - availableUsers: highestPriorityUsers, - bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers, - }); - } + if (eventType.isRRWeightsEnabled) { + availableUsers = await filterUsersBasedOnWeights({ + ...getLuckyUserParams, + availableUsers, + bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers, + }); } + const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers }); + // No need to round-robin through the only user, return early also. + if (highestPriorityUsers.length === 1) return highestPriorityUsers[0]; + // TS is happy. + return leastRecentlyBookedUser({ + ...getLuckyUserParams, + availableUsers: highestPriorityUsers, + bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers, + }); } diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 9522d2ec11..fcce0ffdf2 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -22,6 +22,56 @@ type TeamBookingsParamsWithCount = TeamBookingsParamsBase & { type TeamBookingsParamsWithoutCount = TeamBookingsParamsBase; +const buildWhereClauseForActiveBookings = ({ + eventTypeId, + startDate, + endDate, + users, +}: { + eventTypeId: number; + startDate?: Date; + endDate?: Date; + users: { id: number; email: string }[]; +}): 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, + ...(startDate || endDate + ? { + createdAt: { + ...(startDate ? { gte: startDate } : {}), + ...(endDate ? { lte: endDate } : {}), + }, + } + : {}), +}); + export class BookingRepository { static async getBookingAttendees(bookingId: number) { return await prisma.attendee.findMany({ @@ -74,6 +124,28 @@ export class BookingRepository { }); } + static async groupByActiveBookingCounts({ + users, + eventTypeId, + startDate, + }: { + users: { id: number; email: string }[]; + eventTypeId: number; + startDate?: Date; + }) { + return await prisma.booking.groupBy({ + by: ["userId"], + where: buildWhereClauseForActiveBookings({ + users, + eventTypeId, + startDate, + }), + _count: { + _all: true, + }, + }); + } + static async getAllBookingsForRoundRobin({ users, eventTypeId, @@ -85,48 +157,13 @@ export class BookingRepository { startDate?: Date; endDate?: Date; }) { - 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, - ...(startDate && endDate - ? { - createdAt: { - gte: startDate, - lte: endDate, - }, - } - : {}), - }; - const allBookings = await prisma.booking.findMany({ - where: whereClause, + where: buildWhereClauseForActiveBookings({ + eventTypeId, + startDate, + endDate, + users, + }), select: { id: true, attendees: true, diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index 9456b09450..e2992a806d 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -610,6 +610,7 @@ export class EventTypeRepository { }, }, secondaryEmailId: true, + maxLeadThreshold: true, }); return await prisma.eventType.findFirst({ diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 0e65e59c87..b1219af561 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -119,6 +119,7 @@ export const buildEventType = (eventType?: Partial): EventType => { seatsPerTimeSlot: null, seatsShowAttendees: null, seatsShowAvailabilityCount: null, + maxLeadThreshold: null, schedulingType: null, scheduleId: null, bookingLimits: null, diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 8db29cd78b..6ec614fe4a 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -124,6 +124,7 @@ export const useEventTypeForm = ({ schedulerName: eventType.aiPhoneCallConfig?.schedulerName, }, isRRWeightsEnabled: eventType.isRRWeightsEnabled, + maxLeadThreshold: eventType.maxLeadThreshold, }; }, [eventType, periodDates]); diff --git a/packages/prisma/migrations/20241114154333_add_max_lead_threshold/migration.sql b/packages/prisma/migrations/20241114154333_add_max_lead_threshold/migration.sql new file mode 100644 index 0000000000..9dc3c9d3bf --- /dev/null +++ b/packages/prisma/migrations/20241114154333_add_max_lead_threshold/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "maxLeadThreshold" INTEGER; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index af66a130c3..1745f9416f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -149,6 +149,7 @@ model EventType { useEventTypeDestinationCalendarEmail Boolean @default(false) aiPhoneCallConfig AIPhoneCallConfiguration? isRRWeightsEnabled Boolean @default(false) + maxLeadThreshold Int? /// @zod.custom(imports.eventTypeColor) eventTypeColor Json? diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 5bece0d056..82efb2ff33 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -696,6 +696,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit { - if (host.isFixed || host.user.email === contactOwnerEmail) - usersArray.push({ ...host.user, isFixed: host.isFixed }); + const contactOwnerAndFixedHosts = hosts.reduce( + (usersArray: (GetAvailabilityUser & { isFixed?: boolean })[], host) => { + if (host.isFixed || host.user.email === contactOwnerEmail) + usersArray.push({ ...host.user, isFixed: host.isFixed }); - return usersArray; - }, [] as (GetAvailabilityUser & { isFixed?: boolean })[]); + return usersArray; + }, + [] + ); return contactOwnerAndFixedHosts; } @@ -467,24 +473,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise ({ - isFixed: host.isFixed, - email: host.user.email, - user: host.user, - })) - : eventType.users.map((user) => { - return { - isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE, - email: user.email, - user: user, - }; - }); + const eventHosts = await monitorCallbackAsync(findQualifiedHosts, eventType); const contactOwnerEmailFromInput = input.teamMemberEmail ?? null; const skipContactOwner = input.skipContactOwner;