feat: Implements maxLeadThreshold (#17643)

* feat: Implements maxLeadThreshold which puts a maximum to the amount of lead a given host is allowed to be ahead

* Refactor getLuckyUser + implement in handleNewBooking + move logic to repo

* Decoupled @prisma from findQualifiedHosts

* Fix deep type error

* Add maxLeadThreshold to builder.ts

* Removed a console.log :O + fix handleChildrenEventTypes input

* Add distribution UI to enable balanced distribution/maximize availability

* 0-1-2 instead of 0-1-2-3 (3 bookings instead of 4)
This commit is contained in:
Alex van Andel
2024-11-15 15:54:02 +00:00
committed by GitHub
parent c6aa57177a
commit f76696032e
26 changed files with 626 additions and 123 deletions
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
@@ -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),
@@ -53,6 +53,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
requiresBookerEmailVerification: true,
maxLeadThreshold: true,
minimumBookingNotice: true,
userId: true,
price: true,
@@ -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<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
createdAt?: Date;
})[];
type EventType = Pick<NewBookingEventType, "hosts" | "users" | "id" | "userId" | "schedulingType">;
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({
@@ -46,13 +46,19 @@ export const loadUsers = async ({
const loadUsersByEventType = async (eventType: EventType): Promise<NewBookingEventType["users"]> => {
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) => {
@@ -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) => {
@@ -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,
@@ -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<EventTypeSetupProps, "teamMembers" | "team" | "eventType">;
@@ -333,7 +333,6 @@ const Hosts = ({
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
}) => {
const { t } = useLocale();
const {
control,
setValue,
@@ -498,6 +497,44 @@ export const EventTeamAssignmentTab = ({ team, teamMembers, eventType }: EventTe
/>
</div>
</div>
<div className="border-subtle mt-4 flex flex-col rounded-md">
<div className="border-subtle rounded-t-md border p-6 pb-5">
<Label className="mb-1 text-sm font-semibold">{t("rr_distribution_method")}</Label>
<p className="text-subtle max-w-full break-words text-sm leading-tight">
{t("rr_distribution_method_description")}
</p>
</div>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<Controller
name="maxLeadThreshold"
render={({ field: { value, onChange } }) => (
<RadioArea.Group
onValueChange={(val) => {
if (val === "loadBalancing") onChange(3);
else onChange(null);
}}
className="mt-1 flex flex-col gap-4">
<RadioArea.Item
value="maximizeAvailability"
checked={value === null}
className="w-full text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("rr_distribution_method_availability_title")}</strong>
<p>{t("rr_distribution_method_availability_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value="loadBalancing"
checked={value !== null}
className="text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("rr_distribution_method_balanced_title")}</strong>
<p>{t("rr_distribution_method_balanced_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
)}
/>
</div>
</div>
<Hosts
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
@@ -137,6 +137,7 @@ export type FormValues = {
forwardParamsSuccessRedirect: boolean | null;
secondaryEmailId?: number;
isRRWeightsEnabled: boolean;
maxLeadThreshold?: number;
};
export type LocationFormValues = Pick<FormValues, "id" | "locations" | "bookingFields" | "seatsPerTimeSlot">;
@@ -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 },
});
});
});
@@ -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 <T = Record<string, unknown>>({
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<number, number>;
},
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 <T = Record<string, unknown>>({
hosts,
maxLeadThreshold,
eventTypeId,
}: {
hosts: ({ isFixed: boolean; createdAt: Date; user: { id: number; email: string } } & T)[];
maxLeadThreshold: number | null;
eventTypeId: number;
}): Promise<Omit<T, "leadOffset">[]> => {
if (maxLeadThreshold === null) return hosts;
// Calculate offsets for non-fixed hosts only once
const computedRoundRobinHosts = await computeLeadOffsets<T>({
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<T, "leadOffset">;
} 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<T, "leadOffset"> => host !== null);
};
@@ -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();
});
});
@@ -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<string, unknown>
>(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;
};
+1
View File
@@ -110,6 +110,7 @@ const commons = {
useEventTypeDestinationCalendarEmail: false,
secondaryEmailId: null,
secondaryEmail: null,
maxLeadThreshold: null,
};
export const dynamicEvent = {
+1
View File
@@ -56,4 +56,5 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
durationLimits: true,
eventTypeColor: true,
hideCalendarEventDetails: true,
maxLeadThreshold: true,
});
@@ -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,
+10 -10
View File
@@ -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, ...T[]];
type GetLuckyUserAvailableUsersType = NonEmptyArray<ReturnType<typeof buildUser>>;
@@ -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,
+16 -33
View File
@@ -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<Booking, "id" | "createdAt" | "userId" | "status"> & {
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<T>
) {
>({ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams<T>) {
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,
});
}
+78 -41
View File
@@ -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,
@@ -610,6 +610,7 @@ export class EventTypeRepository {
},
},
secondaryEmailId: true,
maxLeadThreshold: true,
});
return await prisma.eventType.findFirst({
+1
View File
@@ -119,6 +119,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
seatsPerTimeSlot: null,
seatsShowAttendees: null,
seatsShowAvailabilityCount: null,
maxLeadThreshold: null,
schedulingType: null,
scheduleId: null,
bookingLimits: null,
@@ -124,6 +124,7 @@ export const useEventTypeForm = ({
schedulerName: eventType.aiPhoneCallConfig?.schedulerName,
},
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
maxLeadThreshold: eventType.maxLeadThreshold,
};
}, [eventType, periodDates]);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "maxLeadThreshold" INTEGER;
+1
View File
@@ -149,6 +149,7 @@ model EventType {
useEventTypeDestinationCalendarEmail Boolean @default(false)
aiPhoneCallConfig AIPhoneCallConfiguration?
isRRWeightsEnabled Boolean @default(false)
maxLeadThreshold Int?
/// @zod.custom(imports.eventTypeColor)
eventTypeColor Json?
+1
View File
@@ -696,6 +696,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
isRRWeightsEnabled: true,
eventTypeColor: true,
rescheduleWithSameRoundRobinHost: true,
maxLeadThreshold: true,
};
// All properties that are defined as unlocked based on all managed props
@@ -13,6 +13,7 @@ import dayjs from "@calcom/dayjs";
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
import { findQualifiedHosts } from "@calcom/lib/bookings/findQualifiedHosts";
import { getRoutedHostsWithContactOwnerAndFixedHosts } from "@calcom/lib/bookings/getRoutedUsers";
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns";
@@ -180,6 +181,7 @@ export async function getEventType(
rescheduleWithSameRoundRobinHost: true,
periodDays: true,
metadata: true,
maxLeadThreshold: true,
team: {
select: {
id: true,
@@ -223,6 +225,7 @@ export async function getEventType(
hosts: {
select: {
isFixed: true,
createdAt: true,
user: {
select: {
credentials: { select: credentialForCalendarServiceSelect },
@@ -390,12 +393,15 @@ export function getUsersWithCredentialsConsideringContactOwner({
return allHosts;
}
const contactOwnerAndFixedHosts = hosts.reduce((usersArray, host) => {
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<I
throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" });
}
const eventHosts: {
isFixed: boolean;
email: string;
user: (typeof eventType.hosts)[number]["user"];
}[] =
eventType.hosts?.length && eventType.schedulingType
? eventType.hosts.map((host) => ({
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<GetAvailabilityUser>, eventType);
const contactOwnerEmailFromInput = input.teamMemberEmail ?? null;
const skipContactOwner = input.skipContactOwner;