fix: Fairness when routing (#18550)

* Partial push of new filters

* Let's always return the passed host array when a filter doesn't need to be applied

* fix: isFixed typing in same round robin host check

* Add main bulk of rewritten logic for routed fairness support

* Remove console.log

* Add getAttributeWithEnabledWeights to AttributeRepository

* Add OOO entry repository

* Use getOrderedListOfLuckyUsers in lead threshold filtering

* Shortfall already includes weight

* test against minShortfall

* first attempt to fix fallbacks

* Added testcase updates

* Partially fix filterHosts test

* Some test updates

* Removed email field from normalization

* fix no available users found error

* fix unit test

* remove fallback from same host reschedule return

* fixes and improvements for findQualifiedHosts

* include fallback logic in handleNewBooking

* use fallback in handleNewBooking

* fix for contact owner in handleNewBooking

* WIP - comment

* pass routingFormResponseId

* fix type error

* pass routingFormResponse

* fixes in treshold logic

* remove reason

* merge qualified and fallback availabilities

* fix type error

* add and fix tests

* clean up test file

* fix filterHostsBySameRoundRobinHost tests

* fix findQualifiedHosts.test.ts

* fix getRoutedUsers.test.ts

* fix tests

* fix isRerouting check

* fix start time for two weeks check

* fix fallback hosts

* fix test

* fallback hosts should also include qualified hosts

* fix type error

* fix typos

* add contact owner to fallbacks

* fix typo

* fix qualifiedRRUsers

* set fixed users when no hosts

* add tets + code clean up

* add test for reschedule with same host

* fix typo

* add more tests for findQualifiedHosts

* remove skip from test

* add comment

* remove unused code

* fix fallback users in loadAndValidateUsers

* improve naming for fallback

* rename missed fallback variable

* fix reschedule with same rr host in handleNewBooking

* fixes for fallback slots

* add more tests + small fixes for contact owner

* fix tests and type errors

* add logs for fallback

* add type:json to logger

* Fix small merge issue

* fix end time if two weeks are cut off

* add more tests for contact owner availability check

* add new logic for two weeks fallback

* fix reschedule same RR host test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
This commit is contained in:
Alex van Andel
2025-02-18 14:59:48 +00:00
committed by GitHub
co-authored by CarinaWolli Peer Richelsen Carina Wollendorfer
parent 03346f04ed
commit 649faf4ff6
22 changed files with 1971 additions and 704 deletions
+437 -30
View File
@@ -311,11 +311,244 @@ describe("getSchedule", () => {
}
);
});
test("correctly get slots for event with only round robin hosts - When no availability is found", async () => {
vi.setSystemTime("2024-05-21T00:00:13Z");
test("correctly get slots when contact owner has no availability in first two weeks", async () => {
vi.setSystemTime("2024-05-01T00:00:13Z");
const plus1DateString = "2024-05-22";
const plus2DateString = "2024-05-23";
const startTime = "2024-05-01";
const endTime = "2024-06-01";
const dateWithinTwoWeeks = "2024-05-10";
const dateAfterTwoWeeks = "2024-05-22";
const crmCredential = {
id: 1,
type: "salesforce_crm",
key: {
clientId: "test-client-id",
},
userId: 1,
teamId: null,
appId: "salesforce",
invalid: false,
user: { email: "test@test.com" },
};
await createCredentials([crmCredential]);
mockCrmApp("salesforce", {
getContacts: [
{
id: "contact-id",
email: "test@test.com",
ownerEmail: "example@example.com",
},
],
createContacts: [{ id: "contact-id", email: "test@test.com" }],
});
await createBookingScenario({
eventTypes: [
{
id: 1,
slotInterval: 60,
length: 60,
hosts: [
{
userId: 101,
isFixed: false,
},
{
userId: 102,
isFixed: false,
},
],
schedulingType: "ROUND_ROBIN",
metadata: {
apps: {
salesforce: {
enabled: true,
appCategories: ["crm"],
roundRobinLeadSkip: true,
},
},
},
},
],
users: [
{
...TestData.users.example,
email: "example@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHoursWithFirstTwoWeeksUnavailable(startTime)],
},
{
...TestData.users.example,
email: "example1@example.com",
id: 102,
schedules: [TestData.schedules.IstMorningShift],
defaultScheduleId: 2,
},
],
bookings: [],
});
const scheduleWithLeadSkip = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${startTime}T18:30:00.000Z`,
endTime: `${endTime}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
teamMemberEmail: "example@example.com",
orgSlug: null,
},
});
// example@example.com isn't available in the first two weeks, so we fallback to all hosts
expect(scheduleWithLeadSkip).toHaveTimeSlots(
[
`04:30:00.000Z`,
`05:30:00.000Z`,
`06:30:00.000Z`,
`07:30:00.000Z`,
`08:30:00.000Z`,
`09:30:00.000Z`,
`10:30:00.000Z`,
`11:30:00.000Z`,
// `12:30:00.000Z`,
// `13:30:00.000Z`,
// `14:30:00.000Z`,
// `15:30:00.000Z`,
],
{
dateString: dateWithinTwoWeeks,
}
);
// slots from from both hosts because example@example.com isn't available in the first two weeks, so we fallback to all hosts
expect(scheduleWithLeadSkip).toHaveTimeSlots(
[
`04:30:00.000Z`,
`05:30:00.000Z`,
`06:30:00.000Z`,
`07:30:00.000Z`,
`08:30:00.000Z`,
`09:30:00.000Z`,
`10:30:00.000Z`,
`11:30:00.000Z`,
`12:30:00.000Z`,
`13:30:00.000Z`,
`14:30:00.000Z`,
`15:30:00.000Z`,
],
{
dateString: dateAfterTwoWeeks,
}
);
});
test("correctly get slots for event when contact owner has no availability for the whole month but is available the first two weeks", async () => {
vi.setSystemTime("2024-05-01T00:00:13Z");
const startTime = "2024-07-01";
const endTime = "2024-07-31";
const crmCredential = {
id: 1,
type: "salesforce_crm",
key: {
clientId: "test-client-id",
},
userId: 1,
teamId: null,
appId: "salesforce",
invalid: false,
user: { email: "test@test.com" },
};
await createCredentials([crmCredential]);
mockCrmApp("salesforce", {
getContacts: [
{
id: "contact-id",
email: "test@test.com",
ownerEmail: "example@example.com",
},
],
createContacts: [{ id: "contact-id", email: "test@test.com" }],
});
await createBookingScenario({
eventTypes: [
{
id: 1,
slotInterval: 60,
length: 60,
hosts: [
{
userId: 101,
isFixed: false,
},
{
userId: 102,
isFixed: false,
},
],
schedulingType: "ROUND_ROBIN",
metadata: {
apps: {
salesforce: {
enabled: true,
appCategories: ["crm"],
roundRobinLeadSkip: true,
},
},
},
},
],
users: [
{
...TestData.users.example,
email: "example@example.com",
id: 101,
schedules: [TestData.schedules.IstNotAvailableForFullMonth("2024-07")], // not available full July
},
{
...TestData.users.example,
email: "example1@example.com",
id: 102,
schedules: [TestData.schedules.IstMorningShift],
defaultScheduleId: 2,
},
],
bookings: [],
});
const scheduleWithLeadSkip = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${startTime}T18:30:00.000Z`,
endTime: `${endTime}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
teamMemberEmail: "example@example.com",
orgSlug: null,
},
});
// slots from example@example.com because even though user isn't available in July, user is available in the first two weeks
expect(scheduleWithLeadSkip).toHaveDateDisabled({
dateString: "2024-07-05",
});
});
test("correctly get slots for event when contact owner has no availability at all", async () => {
vi.setSystemTime("2024-05-01T00:00:13Z");
const startTime = "2024-07-01";
const endTime = "2024-07-31";
const crmCredential = {
id: 1,
@@ -393,8 +626,8 @@ describe("getSchedule", () => {
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
startTime: `${startTime}T18:30:00.000Z`,
endTime: `${endTime}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
teamMemberEmail: "example@example.com",
@@ -402,25 +635,8 @@ describe("getSchedule", () => {
},
});
// only slots where example@example.com is available
expect(scheduleWithLeadSkip).toHaveDateDisabled({
dateString: plus2DateString,
});
const scheduleWithoutLeadSkip = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
orgSlug: null,
},
});
// slots where either one of the rr hosts is available
expect(scheduleWithoutLeadSkip).toHaveTimeSlots(
// slots from example1@example.com because example@example.com isn't available also not the first two weeks
expect(scheduleWithLeadSkip).toHaveTimeSlots(
[
`04:30:00.000Z`,
`05:30:00.000Z`,
@@ -430,16 +646,207 @@ describe("getSchedule", () => {
`09:30:00.000Z`,
`10:30:00.000Z`,
`11:30:00.000Z`,
`12:30:00.000Z`,
`13:30:00.000Z`,
`14:30:00.000Z`,
`15:30:00.000Z`,
],
{
dateString: plus2DateString,
dateString: "2024-07-05",
}
);
});
test("correctly gets slot for event when contact owner is available in the first two weeks and the next month is loaded (within the two weeks)", async () => {
vi.setSystemTime("2024-05-23T00:00:13Z");
const startTime = "2024-06-01";
const endTime = "2024-06-31";
const crmCredential = {
id: 1,
type: "salesforce_crm",
key: {
clientId: "test-client-id",
},
userId: 1,
teamId: null,
appId: "salesforce",
invalid: false,
user: { email: "test@test.com" },
};
await createCredentials([crmCredential]);
mockCrmApp("salesforce", {
getContacts: [
{
id: "contact-id",
email: "test@test.com",
ownerEmail: "example@example.com",
},
],
createContacts: [{ id: "contact-id", email: "test@test.com" }],
});
await createBookingScenario({
eventTypes: [
{
id: 1,
slotInterval: 60,
length: 60,
hosts: [
{
userId: 101,
isFixed: false,
},
{
userId: 102,
isFixed: false,
},
],
schedulingType: "ROUND_ROBIN",
metadata: {
apps: {
salesforce: {
enabled: true,
appCategories: ["crm"],
roundRobinLeadSkip: true,
},
},
},
},
],
users: [
{
...TestData.users.example,
email: "example@example.com",
id: 101,
schedules: [TestData.schedules.IstNotAvailableForFullMonth("2024-06")],
},
{
...TestData.users.example,
email: "example1@example.com",
id: 102,
schedules: [TestData.schedules.IstMorningShift],
defaultScheduleId: 2,
},
],
bookings: [],
});
const scheduleWithLeadSkip = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${startTime}T18:30:00.000Z`,
endTime: `${endTime}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
teamMemberEmail: "example@example.com",
orgSlug: null,
},
});
// slots for example@example.com, because user is available in the first two weeks (prev month)
expect(scheduleWithLeadSkip).toHaveDateDisabled({
dateString: "2024-06-03",
});
});
test("correctly gets slot for event when contact owner is available in the first two weeks and it's end of the month", async () => {
vi.setSystemTime("2024-05-23T00:00:13Z");
const startTime = "2024-05-23";
const endTime = "2024-05-31";
const crmCredential = {
id: 1,
type: "salesforce_crm",
key: {
clientId: "test-client-id",
},
userId: 1,
teamId: null,
appId: "salesforce",
invalid: false,
user: { email: "test@test.com" },
};
await createCredentials([crmCredential]);
mockCrmApp("salesforce", {
getContacts: [
{
id: "contact-id",
email: "test@test.com",
ownerEmail: "example@example.com",
},
],
createContacts: [{ id: "contact-id", email: "test@test.com" }],
});
await createBookingScenario({
eventTypes: [
{
id: 1,
slotInterval: 60,
length: 60,
hosts: [
{
userId: 101,
isFixed: false,
},
{
userId: 102,
isFixed: false,
},
],
schedulingType: "ROUND_ROBIN",
metadata: {
apps: {
salesforce: {
enabled: true,
appCategories: ["crm"],
roundRobinLeadSkip: true,
},
},
},
},
],
users: [
{
...TestData.users.example,
email: "example@example.com",
id: 101,
schedules: [TestData.schedules.IstNotAvailableForFullMonth("2024-05")],
},
{
...TestData.users.example,
email: "example1@example.com",
id: 102,
schedules: [TestData.schedules.IstMorningShift],
defaultScheduleId: 2,
},
],
bookings: [],
});
const scheduleWithLeadSkip = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${startTime}T18:30:00.000Z`,
endTime: `${endTime}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
teamMemberEmail: "example@example.com",
orgSlug: null,
},
});
// slots for example@example.com, because user is available in the first two weeks (next month)
expect(scheduleWithLeadSkip).toHaveDateDisabled({
dateString: "2024-05-29",
});
});
test("Negative case(i.e. skipContactOwner is true) - when teamMemberEmail is provided but not used", async () => {
vi.setSystemTime("2024-05-21T00:00:13Z");
@@ -1169,6 +1169,76 @@ export const TestData = {
availability: [],
timeZone: Timezones["+5:30"],
},
IstNotAvailableForFullMonth: (monthYear: string) => {
const [year, month] = monthYear.split("-").map(Number); // Expecting format 'YYYY-MM'
const startOfMonth = new Date(year, month - 1, 1);
const endOfMonth = new Date(year, month, 0);
const availability: {
days: number[];
startTime: Date;
endTime: Date;
date: string | null;
}[] = [
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T18:00:00.000Z"),
endTime: new Date("1970-01-01T22:00:00.000Z"),
date: null,
},
];
// Generate unavailable dates for the entire month
const currentDate = new Date(startOfMonth);
while (currentDate <= endOfMonth) {
const dateString = currentDate.toISOString().split("T")[0];
availability.push({
days: [],
startTime: new Date(`${dateString}T00:00:00.000Z`),
endTime: new Date(`${dateString}T00:00:00.000Z`),
date: dateString,
});
currentDate.setDate(currentDate.getDate() + 1);
}
return {
name: "Unavailable for the entire month, then available from 18:00AM to 22:00 IST",
availability,
timeZone: Timezones["+5:30"],
};
},
IstWorkHoursWithFirstTwoWeeksUnavailable: (dateString: string) => {
const date = new Date(dateString);
const availability: {
days: number[];
startTime: Date;
endTime: Date;
date: string | null;
}[] = [
{
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T18:00:00.000Z"),
endTime: new Date("1970-01-01T22:00:00.000Z"),
date: null,
},
];
// Generate dateoverride for each day in thes first two weeks
for (let i = 0; i < 15; i++) {
const dateString = date.toISOString().split("T")[0];
availability.push({
days: [],
startTime: new Date(`${dateString}T00:00:00.000Z`),
endTime: new Date(`${dateString}T00:00:00.000Z`),
date: dateString,
});
date.setDate(date.getDate() + 1);
}
return {
name: "Unavailable for the first two weeks, then available from 18:00 to 22:00 IST",
availability,
timeZone: Timezones["+5:30"],
};
},
IstWorkHoursWithDateOverride: (dateString: string) => ({
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT but with a Date Override for 2PM to 6PM IST(in GST time it is 8:30AM to 12:30PM)",
availability: [
@@ -180,7 +180,7 @@ function buildLuckyUsersWithJustContactOwner({
availableUsers: IsFixedAwareUser[];
fixedUserPool: IsFixedAwareUser[];
}) {
const luckyUsers: Awaited<ReturnType<typeof loadAndValidateUsers>> = [];
const luckyUsers: Awaited<ReturnType<typeof loadAndValidateUsers>>["qualifiedRRUsers"] = [];
if (!contactOwnerEmail) {
return luckyUsers;
}
@@ -491,19 +491,43 @@ async function handler(
const contactOwnerEmail = skipContactOwner ? null : contactOwnerFromReq;
const allHostUsers = await monitorCallbackAsync(loadAndValidateUsers, {
req,
eventType,
eventTypeId,
dynamicUserList,
logger: loggerWithEventDetails,
routedTeamMemberIds: routedTeamMemberIds ?? null,
contactOwnerEmail,
isSameHostReschedule: !!(eventType.rescheduleWithSameRoundRobinHost && reqBody.rescheduleUid),
});
let routingFormResponse = null;
if (routedTeamMemberIds) {
routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
where: {
id: routingFormResponseId,
},
select: {
response: true,
form: {
select: {
routes: true,
fields: true,
},
},
chosenRouteId: true,
},
});
}
const { qualifiedRRUsers, additionalFallbackRRUsers, fixedUsers } = await monitorCallbackAsync(
loadAndValidateUsers,
{
req,
eventType,
eventTypeId,
dynamicUserList,
logger: loggerWithEventDetails,
routedTeamMemberIds: routedTeamMemberIds ?? null,
contactOwnerEmail,
rescheduleUid: reqBody.rescheduleUid || null,
routingFormResponse,
}
);
// We filter out users but ensure allHostUsers remain same.
let users = allHostUsers;
let users = [...qualifiedRRUsers, ...additionalFallbackRRUsers, ...fixedUsers];
let { locationBodyString, organizerOrFirstDynamicGroupMemberDefaultLocationUrl } = getLocationValuesForDb(
dynamicUserList,
@@ -601,19 +625,58 @@ async function handler(
}
if (!req.body.allRecurringDates || req.body.isFirstRecurringSlot) {
const availableUsers = await ensureAvailableUsers(
eventTypeWithUsers,
{
dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(),
dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking,
},
loggerWithEventDetails,
shouldServeCache
);
let availableUsers: IsFixedAwareUser[] = [];
try {
availableUsers = await ensureAvailableUsers(
{ ...eventTypeWithUsers, users: [...qualifiedRRUsers, ...fixedUsers] as IsFixedAwareUser[] },
{
dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(),
dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking,
},
loggerWithEventDetails,
shouldServeCache
);
} catch {
if (additionalFallbackRRUsers.length) {
loggerWithEventDetails.debug(
"Qualified users not available, check for fallback users",
safeStringify({
qualifiedRRUsers: qualifiedRRUsers.map((user) => user.id),
additionalFallbackRRUsers: additionalFallbackRRUsers.map((user) => user.id),
})
);
// can happen when contact owner not available for 2 weeks or fairness would block at least 2 weeks
// use fallback instead
availableUsers = await ensureAvailableUsers(
{
...eventTypeWithUsers,
users: [...additionalFallbackRRUsers, ...fixedUsers] as IsFixedAwareUser[],
},
{
dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(),
dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(),
timeZone: reqBody.timeZone,
originalRescheduledBooking,
},
loggerWithEventDetails,
shouldServeCache
);
} else {
loggerWithEventDetails.debug(
"Qualified users not available, no fallback users",
safeStringify({
qualifiedRRUsers: qualifiedRRUsers.map((user) => user.id),
})
);
throw new Error(ErrorCode.NoAvailableUsersFound);
}
}
const luckyUserPool: IsFixedAwareUser[] = [];
const fixedUserPool: IsFixedAwareUser[] = [];
availableUsers.forEach((user) => {
user.isFixed ? fixedUserPool.push(user) : luckyUserPool.push(user);
});
@@ -628,11 +691,7 @@ async function handler(
})
);
const luckyUsers: typeof users = buildLuckyUsersWithJustContactOwner({
contactOwnerEmail: contactOwnerEmail,
availableUsers,
fixedUserPool,
});
const luckyUsers: typeof users = [];
// loop through all non-fixed hosts and get the lucky users
// This logic doesn't run when contactOwner is used because in that case, luckUsers.length === 1
@@ -644,50 +703,18 @@ async function handler(
if (freeUsers.length === 0) break;
assertNonEmptyArray(freeUsers); // make sure TypeScript knows it too wih an assertion; the error will never be thrown.
// freeUsers is ensured
const originalRescheduledBookingUserId =
originalRescheduledBooking && originalRescheduledBooking.userId;
const shouldUseSameRRHost =
!!originalRescheduledBookingUserId &&
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
eventType.rescheduleWithSameRoundRobinHost &&
// If it is rerouting, we should not force reschedule with same host.
// It will be unexpected plus could cause unavailable slots as original host might not be part of routedTeamMemberIds
!isReroutingCase;
const userIdsSet = new Set(users.map((user) => user.id));
let routingFormResponse;
if (routedTeamMemberIds) {
routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
where: {
id: routingFormResponseId,
},
select: {
response: true,
form: {
select: {
routes: true,
fields: true,
},
},
chosenRouteId: true,
},
});
}
const newLuckyUser = shouldUseSameRRHost
? freeUsers.find((user) => user.id === originalRescheduledBookingUserId)
: await getLuckyUser({
// find a lucky user that is not already in the luckyUsers array
availableUsers: freeUsers,
allRRHosts: eventTypeWithUsers.hosts.filter(
(host) => !host.isFixed && userIdsSet.has(host.user.id)
), // users part of virtual queue
eventType,
routingFormResponse: routingFormResponse ?? null,
});
const newLuckyUser = await getLuckyUser({
// find a lucky user that is not already in the luckyUsers array
availableUsers: freeUsers,
allRRHosts: eventTypeWithUsers.hosts.filter(
(host) => !host.isFixed && userIdsSet.has(host.user.id)
), // users part of virtual queue
eventType,
routingFormResponse,
});
if (!newLuckyUser) {
break; // prevent infinite loop
}
@@ -1267,7 +1294,7 @@ async function handler(
endTime: reqBody.end,
contactOwnerFromReq,
contactOwnerEmail,
allHostUsers,
allHostUsers: [...qualifiedRRUsers, ...additionalFallbackRRUsers, ...fixedUsers],
isManagedEventType,
});
booking = dryRunBooking;
@@ -2,7 +2,7 @@ import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { loadAndValidateUsers } from "./loadAndValidateUsers";
type Users = Awaited<ReturnType<typeof loadAndValidateUsers>>;
type Users = Awaited<ReturnType<typeof loadAndValidateUsers>>["qualifiedRRUsers"];
const sortUsersByDynamicList = (users: Users, dynamicUserList: string[]) => {
return users.sort((a, b) => {
@@ -2,10 +2,11 @@ import type { Prisma } from "@prisma/client";
import type { IncomingMessage } from "http";
import type { Logger } from "tslog";
import { filterHostsByLeadThreshold } from "@calcom/lib/bookings/filterHostsByLeadThreshold";
import { findQualifiedHosts } from "@calcom/lib/bookings/findQualifiedHosts";
import { HttpError } from "@calcom/lib/http-error";
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { RoutingFormResponse } from "@calcom/lib/server/getLuckyUser";
import { withSelectedCalendars } from "@calcom/lib/server/repository/user";
import { userSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma";
@@ -33,6 +34,8 @@ type EventType = Pick<
| "assignAllTeamMembers"
| "assignRRMembersUsingSegment"
| "rrSegmentQueryValue"
| "isRRWeightsEnabled"
| "rescheduleWithSameRoundRobinHost"
>;
type InputProps = {
@@ -43,7 +46,8 @@ type InputProps = {
logger: Logger<unknown>;
routedTeamMemberIds: number[] | null;
contactOwnerEmail: string | null;
isSameHostReschedule: boolean;
rescheduleUid: string | null;
routingFormResponse: RoutingFormResponse | null;
};
export async function loadAndValidateUsers({
@@ -54,8 +58,9 @@ export async function loadAndValidateUsers({
logger,
routedTeamMemberIds,
contactOwnerEmail,
isSameHostReschedule,
}: InputProps): Promise<Users> {
rescheduleUid,
routingFormResponse,
}: InputProps): Promise<{ qualifiedRRUsers: Users; additionalFallbackRRUsers: Users; fixedUsers: Users }> {
let users: Users = await loadUsers({
eventType,
dynamicUserList,
@@ -104,21 +109,32 @@ export async function loadAndValidateUsers({
? false
: 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: isSameHostReschedule ? null : eventType.maxLeadThreshold,
const { qualifiedRRHosts, allFallbackRRHosts, fixedHosts } = await findQualifiedHosts({
eventType,
routedTeamMemberIds: routedTeamMemberIds || [],
rescheduleUid,
contactOwnerEmail,
routingFormResponse,
});
if (qualifiedHosts.length) {
let qualifiedRRUsers: Users = [];
let allFallbackRRUsers: Users = [];
let fixedUsers: Users = [];
if (qualifiedRRHosts.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));
const qualifiedHostIds = new Set(qualifiedRRHosts.map((qualifiedHost) => qualifiedHost.user.id));
qualifiedRRUsers = users.filter((user) => qualifiedHostIds.has(user.id));
}
if (allFallbackRRHosts?.length) {
const fallbackHostIds = new Set(allFallbackRRHosts.map((fallbackHost) => fallbackHost.user.id));
allFallbackRRUsers = users.filter((user) => fallbackHostIds.has(user.id));
}
if (fixedHosts?.length) {
const fixedHostIds = new Set(fixedHosts.map((fixedHost) => fixedHost.user.id));
fixedUsers = users.filter((user) => fixedHostIds.has(user.id));
}
logger.debug(
@@ -128,5 +144,13 @@ export async function loadAndValidateUsers({
})
);
return users;
const additionalFallbackRRUsers = allFallbackRRUsers.filter(
(fallbackUser) => !qualifiedRRUsers.find((qualifiedUser) => qualifiedUser.id === fallbackUser.id)
);
return {
qualifiedRRUsers,
additionalFallbackRRUsers, // without qualified
fixedUsers: !qualifiedRRUsers.length && !fixedUsers.length ? users : fixedUsers,
};
}
@@ -49,7 +49,17 @@ export const loadUsers = async ({
? await loadUsersByEventType(eventType)
: await loadDynamicUsers(dynamicUserList, currentOrgDomain);
return getRoutedUsersWithContactOwnerAndFixedUsers({ users, routedTeamMemberIds, contactOwnerEmail });
const routedUsers = getRoutedUsersWithContactOwnerAndFixedUsers({
users,
routedTeamMemberIds,
contactOwnerEmail,
});
if (routedUsers.length) {
return routedUsers;
}
return users;
} catch (error) {
log.error("Unable to load users", safeStringify(error));
if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -65,7 +75,7 @@ const loadUsersByEventType = async (eventType: EventType): Promise<NewBookingEve
});
const matchingHosts = await findMatchingHostsWithEventSegment({
eventType,
normalizedHosts: hosts ?? fallbackHosts,
hosts: hosts ?? fallbackHosts,
});
return matchingHosts.map(({ user, isFixed, priority, weight, createdAt }) => ({
...user,
@@ -2193,14 +2193,7 @@ describe("handleNewBooking", () => {
},
},
},
users: [
{
id: 101,
},
{
id: 102,
},
],
hosts: [{ userId: 101 }, { userId: 102 }],
},
],
organizer,
@@ -50,6 +50,12 @@ export const useSchedule = ({
const skipContactOwner = searchParams ? searchParams.get("cal.skipContactOwner") === "true" : false;
const _cacheParam = searchParams?.get("cal.cache");
const shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined;
const routingFormResponseIdParam = searchParams?.get("cal.routingFormResponseId");
const email = searchParams?.get("email");
const routingFormResponseId = routingFormResponseIdParam
? parseInt(routingFormResponseIdParam, 10)
: undefined;
const input = {
isTeamEvent,
@@ -71,6 +77,8 @@ export const useSchedule = ({
routedTeamMemberIds,
skipContactOwner,
shouldServeCache,
routingFormResponseId,
email,
};
const options = {
@@ -1,13 +1,9 @@
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 { getOrderedListOfLuckyUsers } from "../server/getLuckyUser";
import { filterHostsByLeadThreshold, errorCodes } from "./filterHostsByLeadThreshold";
// Import the original Prisma client
@@ -21,129 +17,177 @@ const prismaMock = {
// Use `vi.spyOn` to make `prisma.booking.groupBy` call the mock instead
vi.spyOn(prisma.booking, "groupBy").mockImplementation(prismaMock.booking.groupBy);
vi.mock("../server/getLuckyUser", () => ({
getOrderedListOfLuckyUsers: vi.fn(),
}));
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: [
{
userId: {
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,
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 1,
email: "member1-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
_count: { _all: true },
];
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: null,
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {
parentId: null,
},
},
routingFormResponse: null,
})
).resolves.toStrictEqual(hosts);
});
it("throws error when maxLeadThreshold = 0, 0 ahead makes no sense.", async () => {
expect(
filterHostsByLeadThreshold({
hosts: [],
maxLeadThreshold: 0,
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {
parentId: null,
},
},
routingFormResponse: null,
})
).rejects.toThrow(errorCodes.MAX_LEAD_THRESHOLD_FALSY);
});
it("correctly disqualifies a host when the lead offset is exceeding the threshold without weights", async () => {
const hosts = [
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 1,
email: "member1-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 2,
email: "member2-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
getOrderedListOfLuckyUsers.mockResolvedValue({
perUserData: {
bookingsCount: { 1: 10, 2: 6 },
},
});
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: 3,
eventType: {
id: 1,
isRRWeightsEnabled: false,
team: {
parentId: null,
},
},
routingFormResponse: null,
})
).resolves.toStrictEqual([hosts[1]]); // host 1 (host[0]) disqualified
});
it("correctly disqualifies a host when the lead offset is exceeding the threshold with weights", async () => {
const hosts = [
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 1,
email: "member1-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 2,
email: "member2-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false as const,
createdAt: new Date(),
user: {
id: 3,
email: "member3-acme@example.com",
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
getOrderedListOfLuckyUsers.mockResolvedValue({
perUserData: {
bookingsCount: { 1: 7, 2: 5, 3: 0 },
weights: { 1: 100, 2: 50, 3: 20 },
bookingShortfalls: { 1: 1, 2: -3, 3: 0 },
calibrations: { 1: 1, 2: 2, 3: 1 },
},
});
const test = filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: 3,
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {
parentId: null,
},
},
routingFormResponse: null,
});
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: 3,
eventType: {
id: 1,
isRRWeightsEnabled: true,
team: {
parentId: null,
},
},
routingFormResponse: null,
})
).resolves.toStrictEqual([hosts[0], hosts[2]]); // host 2 (host[1]) disqualified
});
});
@@ -1,129 +1,156 @@
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import logger from "@calcom/lib/logger";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { CredentialPayload } from "@calcom/types/Credential";
const START_OF_MONTH = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
import type { RoutingFormResponse } from "../server/getLuckyUser";
import { getOrderedListOfLuckyUsers } from "../server/getLuckyUser";
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.");
type BaseUser = {
id: number;
email: string;
credentials: CredentialPayload[];
userLevelSelectedCalendars: SelectedCalendar[];
} & Record<string, unknown>;
return dates.reduce((mostRecent, current) => (current > mostRecent ? current : mostRecent));
type BaseHost<User extends BaseUser> = {
isFixed: boolean;
createdAt: Date;
priority?: number | null;
weight?: number | null;
weightAdjustment?: number | null;
user: User;
};
type PerUserData = Awaited<ReturnType<typeof getOrderedListOfLuckyUsers>>["perUserData"];
type WeightedPerUserData = Omit<PerUserData, "weights" | "calibrations" | "bookingShortfalls"> & {
weights: NonNullable<PerUserData["weights"]>;
calibrations: NonNullable<PerUserData["calibrations"]>;
bookingShortfalls: NonNullable<PerUserData["bookingShortfalls"]>;
};
const log = logger.getSubLogger({ name: "filterHostsByLeadThreshold" });
function filterHostsByLeadThresholdWithWeights(perUserData: WeightedPerUserData, maxLeadThreshold: number) {
const filteredUserIds: number[] = [];
// negative shortfall means the host should receive negative bookings, so they are overbooked
const maxShortfall = Math.max(...Object.values(perUserData.bookingShortfalls)); // least amount of bookings
for (const userIdStr in perUserData.bookingShortfalls) {
const shortfall = perUserData.bookingShortfalls[userIdStr];
// if user's shortfall is more than
if (maxShortfall - shortfall > maxLeadThreshold) {
log.debug(
`Host ${userIdStr} has been filtered out because the amount of bookings made him exceed the thresholds. Shortfall: ${shortfall}, Max Shortfall: ${maxShortfall}`
);
} else {
filteredUserIds.push(parseInt(userIdStr, 10));
}
}
return filteredUserIds;
}
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: {},
function filterHostsByLeadThresholdWithoutWeights(perUserData: PerUserData, maxLeadThreshold: number) {
const filteredUserIds: number[] = [];
const bookingsArray = Object.values(perUserData.bookingsCount);
const minBookings = Math.min(...bookingsArray);
for (const userIdStr in perUserData.bookingsCount) {
const bookingsCount = perUserData.bookingsCount[userIdStr];
if (bookingsCount - minBookings > maxLeadThreshold) {
log.debug(
`Host ${userIdStr} has been filtered out because the given data made them exceed the thresholds. BookingsCount: ${bookingsCount}, MinBookings: ${minBookings}`
);
} else {
filteredUserIds.push(parseInt(userIdStr, 10));
log.debug(
`Host Allowed ${userIdStr} has been filtered out because the given data made them exceed the thresholds. BookingsCount: ${bookingsCount}, MinBookings: ${minBookings}, MaxLeadThreshold: ${maxLeadThreshold}`
);
}
);
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.
};
return filteredUserIds;
}
/*
* Filter the hosts by lead threshold, disqualifying hosts that have exceeded the maximum
*
* NOTE: This function cleans up the leadOffset value so can't be used afterwards.
*
* @throws errorCodes.MAX_LEAD_THRESHOLD_FALSY
*/
export const filterHostsByLeadThreshold = async <T = Record<string, unknown>>({
export const filterHostsByLeadThreshold = async <T extends BaseHost<BaseUser>>({
hosts,
maxLeadThreshold,
eventTypeId,
eventType,
routingFormResponse,
}: {
hosts: ({ isFixed: boolean; createdAt: Date; user: { id: number; email: string } } & T)[];
hosts: 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 })),
eventType: {
id: number;
isRRWeightsEnabled: boolean;
team: {
parentId?: number | null;
} | null;
};
routingFormResponse: RoutingFormResponse | null;
}) => {
if (maxLeadThreshold === 0) {
throw new Error(errorCodes.MAX_LEAD_THRESHOLD_FALSY);
}
if (maxLeadThreshold === null) {
return hosts; // don't apply filter.
}
// this needs the routing forms response too, because it needs to know what queue we are in
const orderedLuckyUsers = await getOrderedListOfLuckyUsers({
availableUsers: [
{
...hosts[0].user,
weight: hosts[0].weight ?? null,
priority: hosts[0].priority ?? null,
},
...hosts.slice(1).map((host) => ({
...host.user,
weight: host.weight ?? null,
priority: host.priority ?? null,
})),
],
eventType,
allRRHosts: hosts,
routingFormResponse,
});
// 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);
const perUserData = orderedLuckyUsers["perUserData"];
let filteredUserIds: number[];
if (eventType.isRRWeightsEnabled) {
// Check if any of the required data is null
if (
perUserData.calibrations === null ||
perUserData.weights === null ||
perUserData.bookingShortfalls === null
) {
throw new Error("Calibrations, weights, or booking shortfalls are null");
}
filteredUserIds = filterHostsByLeadThresholdWithWeights(
{
bookingsCount: perUserData.bookingsCount,
bookingShortfalls: perUserData.bookingShortfalls,
calibrations: perUserData.calibrations,
weights: perUserData.weights,
},
maxLeadThreshold
);
} else {
filteredUserIds = filterHostsByLeadThresholdWithoutWeights(perUserData, maxLeadThreshold);
}
const filteredHosts = hosts.filter((host) => filteredUserIds.includes(host.user.id));
return filteredHosts;
};
@@ -0,0 +1,67 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import prisma from "@calcom/prisma";
import { filterHostsBySameRoundRobinHost } from "./filterHostsBySameRoundRobinHost";
// Mocking setup
const prismaMock = {
booking: {
findFirst: vi.fn(), // Mock the findFirst method
},
};
// Use `vi.spyOn` to make `prisma.booking.groupBy` call the mock instead
vi.spyOn(prisma.booking, "findFirst").mockImplementation(prismaMock.booking.findFirst);
afterEach(() => {
// Clear call history before each test to avoid cross-test interference
prismaMock.booking.findFirst.mockClear();
});
describe("filterHostsBySameRoundRobinHost", () => {
it("skips filter if rescheduleWithSameRoundRobinHost set to false", async () => {
const hosts = [
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
];
expect(
filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: false,
routedTeamMemberIds: null,
})
).resolves.toStrictEqual(hosts);
});
it("skips filter if rerouting", async () => {
const hosts = [
{ isFixed: true as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
];
expect(
filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: true,
routedTeamMemberIds: [23],
})
).resolves.toStrictEqual(hosts);
});
it("correctly selects the same host if the filter applies and the host is in the RR users", async () => {
prismaMock.booking.findFirst.mockResolvedValue({ userId: 1 });
const hosts = [
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
{ isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "example2@acme.com" } },
];
// Same host should be selected
expect(
filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: true,
routedTeamMemberIds: null,
})
).resolves.toStrictEqual([hosts[0]]);
});
});
@@ -0,0 +1,42 @@
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { isRerouting } from "./routing/utils";
export const filterHostsBySameRoundRobinHost = async <
T extends {
isFixed: false; // ensure no fixed hosts are passed.
user: { id: number };
}
>({
hosts,
rescheduleUid,
rescheduleWithSameRoundRobinHost,
routedTeamMemberIds,
}: {
hosts: T[];
rescheduleUid: string | null;
rescheduleWithSameRoundRobinHost: boolean;
routedTeamMemberIds: number[] | null;
}) => {
if (
!rescheduleUid ||
!rescheduleWithSameRoundRobinHost ||
isRerouting({ rescheduleUid, routedTeamMemberIds })
) {
return hosts;
}
const originalRescheduledBooking = await prisma.booking.findFirst({
where: {
uid: rescheduleUid,
status: {
in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING],
},
},
select: {
userId: true,
},
});
return hosts.filter((host) => host.user.id === originalRescheduledBooking?.userId || 0);
};
+588 -12
View File
@@ -1,18 +1,24 @@
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
import { vi, it, describe, expect, afterEach } from "vitest";
import type { Mock } from "vitest";
import { SchedulingType } from "@calcom/prisma/enums";
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
import { findQualifiedHosts } from "./findQualifiedHosts";
import * as getRoutedUsers from "./getRoutedUsers";
// Mock the filterHostsByLeadThreshold function
vi.mock("./filterHostsByLeadThreshold", () => ({
filterHostsByLeadThreshold: vi.fn(),
}));
vi.mock("./filterHostsByLeadThreshold", () => {
return {
filterHostsByLeadThreshold: vi.fn(),
};
});
// Clear call history after each test
afterEach(() => {
(filterHostsByLeadThreshold as vi.Mock).mockClear();
(filterHostsByLeadThreshold as Mock).mockClear();
});
describe("findQualifiedHosts", async () => {
@@ -24,7 +30,11 @@ describe("findQualifiedHosts", async () => {
user: {
id: 2,
email: "hellouser2@email.com",
credentials: [],
userLevelSelectedCalendars: [],
},
priority: undefined,
weight: undefined,
},
{
isFixed: false,
@@ -32,12 +42,32 @@ describe("findQualifiedHosts", async () => {
user: {
id: 1,
email: "hellouser@email.com",
credentials: [],
userLevelSelectedCalendars: [],
},
priority: undefined,
weight: undefined,
},
{
isFixed: false,
createdAt: new Date(),
user: {
id: 3,
email: "hellouser3@email.com",
credentials: [],
userLevelSelectedCalendars: [],
},
priority: undefined,
weight: undefined,
},
];
const rrHosts = hosts.filter((host) => !host.isFixed);
const fixedHosts = hosts.filter((host) => host.isFixed);
const rrHostsAfterFairness = [rrHosts[2]];
// Configure the mock return value
(filterHostsByLeadThreshold as vi.Mock).mockResolvedValue(hosts);
(filterHostsByLeadThreshold as Mock).mockResolvedValue(rrHostsAfterFairness);
// Define the input for the test
const eventType = {
@@ -46,13 +76,32 @@ describe("findQualifiedHosts", async () => {
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
// Call the function under test
const result = await findQualifiedHosts(eventType);
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual(hosts);
expect(result).toStrictEqual({
qualifiedRRHosts: rrHostsAfterFairness,
fixedHosts,
allFallbackRRHosts: rrHosts,
});
});
it("should return hosts after valid input with users", async () => {
@@ -60,10 +109,14 @@ describe("findQualifiedHosts", async () => {
{
email: "hello@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
{
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
];
@@ -74,21 +127,544 @@ describe("findQualifiedHosts", async () => {
users,
schedulingType: null,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
// Call the function under test
const result = await findQualifiedHosts(eventType);
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual(
users.map((user) => ({
expect(result).toEqual({
qualifiedRRHosts: [],
fixedHosts: users.map((user) => ({
user: user,
isFixed: true,
email: user.email,
createdAt: null,
}))
);
})),
});
expect(filterHostsByLeadThreshold).not.toHaveBeenCalled();
});
it("should return only the crm contact owner match & other users + contact owner as fallback ", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
weight: 100,
priority: 2,
createdAt: oneYearAgo,
isFixed: false,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
weight: 100,
priority: 2,
createdAt: oneYearAgo,
isFixed: false,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
weight: 100,
priority: 2,
createdAt: oneYearAgo,
isFixed: false,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
(filterHostsByLeadThreshold as Mock).mockResolvedValue(hosts);
// Define the input for the test
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
contactOwnerEmail: "hello1@gmail.com",
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [
{
...hosts[0],
},
],
allFallbackRRHosts: hosts,
fixedHosts: [],
});
});
// it("should return only the crm contact owner match & other users + contact owner as fallback (with routing and segment filtering)", async () => {
// });
it("should return only routed members + contact owner as fallback for crm contact owner match", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
(filterHostsByLeadThreshold as Mock).mockResolvedValue([hosts[0], hosts[1]]);
// Define the input for the test
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [1],
rescheduleUid: null,
contactOwnerEmail: "hello3@gmail.com",
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[2]],
allFallbackRRHosts: [hosts[0], hosts[2]],
fixedHosts: [],
});
});
it("if it's a reschedule with same host, it should only return this host and the fixed hosts", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: true,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
prismaMock.booking.findFirst.mockResolvedValue({ userId: 2 });
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [],
rescheduleUid: "recheduleUid",
contactOwnerEmail: null,
routingFormResponse: null,
});
expect(result).toEqual({
qualifiedRRHosts: [hosts[1]],
fixedHosts: [hosts[2]],
});
});
it("should return early if segment matching results in only one host", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
vi.spyOn(getRoutedUsers, "findMatchingHostsWithEventSegment").mockImplementation(async () => [hosts[0]]);
const filterSpy = vi.spyOn(Array.prototype, "filter");
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [0, 1, 2],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// check if we returned early after segment matching only returned one host
expect(filterSpy).toHaveBeenCalledTimes(2);
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[0]],
fixedHosts: [],
});
});
it("should filter for segment matching and routed team member ids", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
vi.spyOn(getRoutedUsers, "findMatchingHostsWithEventSegment").mockImplementation(async () => [
hosts[0],
hosts[1],
]);
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [2, 3],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[1]],
fixedHosts: [],
});
});
it("should filter for fairness and return fallback with segment filtering and routed team member ids", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
vi.spyOn(getRoutedUsers, "findMatchingHostsWithEventSegment").mockImplementation(async () => [
hosts[0],
hosts[1],
hosts[2],
]);
const rrHostsAfterFairness = [hosts[2]];
// Configure the mock return value
(filterHostsByLeadThreshold as Mock).mockResolvedValue(rrHostsAfterFairness);
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [2, 3],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[2]],
allFallbackRRHosts: [hosts[1], hosts[2]],
fixedHosts: [],
});
});
});
+185 -22
View File
@@ -1,38 +1,201 @@
import { getNormalizedHosts } from "@calcom/lib/bookings/getRoutedUsers";
import { findMatchingHostsWithEventSegment, getNormalizedHosts } from "@calcom/lib/bookings/getRoutedUsers";
import type { EventType } from "@calcom/lib/bookings/getRoutedUsers";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { SchedulingType } from "@calcom/prisma/enums";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { RoutingFormResponse } from "../server/getLuckyUser";
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
import { filterHostsBySameRoundRobinHost } from "./filterHostsBySameRoundRobinHost";
export const findQualifiedHosts = async <T extends { email: string; id: number } & Record<string, unknown>>(
type Host<T> = {
isFixed: boolean;
createdAt: Date;
priority?: number | null;
weight?: number | null;
} & {
user: T;
};
// In case we don't have any matching team members, we return all the RR hosts, as we always want the team event to be bookable.
// Each filter is filtered down, but we never return 0-length.
// TODO: We should notify about it to the organizer somehow.
function applyFilterWithFallback<T>(currentValue: T[], newValue: T[]): T[] {
return newValue.length > 0 ? newValue : currentValue;
}
function getFallBackWithContactOwner<T extends { user: { id: number } }>(
fallbackHosts: T[],
contactOwner: T
) {
if (fallbackHosts.find((host) => host.user.id === contactOwner.user.id)) {
return fallbackHosts;
}
return [...fallbackHosts, contactOwner];
}
const isRoundRobinHost = <T extends { isFixed: boolean }>(host: T): host is T & { isFixed: false } => {
return host.isFixed === false;
};
const isFixedHost = <T extends { isFixed: boolean }>(host: T): host is T & { isFixed: false } => {
return host.isFixed;
};
export const findQualifiedHosts = async <
T extends {
email: string;
id: number;
credentials: CredentialPayload[];
userLevelSelectedCalendars: SelectedCalendar[];
} & Record<string, unknown>
>({
eventType,
rescheduleUid,
routedTeamMemberIds,
contactOwnerEmail,
routingFormResponse,
}: {
eventType: {
id: number;
maxLeadThreshold: number | null;
hosts?: ({ isFixed: boolean; createdAt: Date; priority?: number | null; weight?: number | null } & {
user: T;
})[];
hosts?: Host<T>[];
users: T[];
schedulingType: SchedulingType | null;
isRRWeightsEnabled: boolean;
rescheduleWithSameRoundRobinHost: boolean;
},
isReschedule: boolean
): Promise<
{
} & EventType;
rescheduleUid: string | null;
routedTeamMemberIds: number[];
contactOwnerEmail: string | null;
routingFormResponse: RoutingFormResponse | null;
}): Promise<{
qualifiedRRHosts: {
isFixed: boolean;
email: string;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
user: T;
}[]
> => {
const { hosts, fallbackHosts } = getNormalizedHosts({ eventType });
const qualifiedHosts = hosts
? await filterHostsByLeadThreshold({
eventTypeId: eventType.id,
hosts,
maxLeadThreshold:
isReschedule && eventType.rescheduleWithSameRoundRobinHost ? null : eventType.maxLeadThreshold,
})
: fallbackHosts;
return qualifiedHosts;
}[];
fixedHosts: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
user: T;
}[];
// all hosts we want to fallback to including the qualifiedRRHosts (fairness + crm contact owner)
allFallbackRRHosts?: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
user: T;
}[];
}> => {
const { hosts: normalizedHosts, fallbackHosts: fallbackUsers } = getNormalizedHosts({ eventType });
// not a team event type, or some other reason - segment matching isn't necessary.
if (!normalizedHosts) {
const fixedHosts = fallbackUsers.filter(isFixedHost);
const roundRobinHosts = fallbackUsers.filter(isRoundRobinHost);
return { qualifiedRRHosts: roundRobinHosts, fixedHosts };
}
const fixedHosts = normalizedHosts.filter(isFixedHost);
const roundRobinHosts = normalizedHosts.filter(isRoundRobinHost);
// If it is rerouting, we should not force reschedule with same host.
const hostsAfterRescheduleWithSameRoundRobinHost = applyFilterWithFallback(
roundRobinHosts,
await filterHostsBySameRoundRobinHost({
hosts: roundRobinHosts,
rescheduleUid,
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
routedTeamMemberIds,
})
);
if (hostsAfterRescheduleWithSameRoundRobinHost.length === 1) {
return {
qualifiedRRHosts: hostsAfterRescheduleWithSameRoundRobinHost,
fixedHosts,
};
}
const hostsAfterSegmentMatching = applyFilterWithFallback(
roundRobinHosts,
(await findMatchingHostsWithEventSegment({
eventType,
hosts: roundRobinHosts,
})) as typeof roundRobinHosts
);
if (hostsAfterSegmentMatching.length === 1) {
return {
qualifiedRRHosts: hostsAfterSegmentMatching,
fixedHosts,
};
}
//if segement matching doesn't return any hosts we fall back to all round robin hosts
const officalRRHosts = hostsAfterSegmentMatching.length ? hostsAfterSegmentMatching : roundRobinHosts;
const hostsAfterContactOwnerMatching = applyFilterWithFallback(
officalRRHosts,
officalRRHosts.filter((host) => host.user.email === contactOwnerEmail)
);
const hostsAfterRoutedTeamMemberIdsMatching = applyFilterWithFallback(
officalRRHosts,
officalRRHosts.filter((host) => routedTeamMemberIds.includes(host.user.id))
);
if (hostsAfterRoutedTeamMemberIdsMatching.length === 1) {
if (hostsAfterContactOwnerMatching.length === 1) {
return {
qualifiedRRHosts: hostsAfterContactOwnerMatching,
allFallbackRRHosts: getFallBackWithContactOwner(
hostsAfterRoutedTeamMemberIdsMatching,
hostsAfterContactOwnerMatching[0]
),
fixedHosts,
};
}
return {
qualifiedRRHosts: hostsAfterRoutedTeamMemberIdsMatching,
fixedHosts,
};
}
const hostsAfterFairnessMatching = applyFilterWithFallback(
hostsAfterRoutedTeamMemberIdsMatching,
await filterHostsByLeadThreshold({
eventType,
hosts: hostsAfterRoutedTeamMemberIdsMatching,
maxLeadThreshold: eventType.maxLeadThreshold,
routingFormResponse,
})
);
if (hostsAfterContactOwnerMatching.length === 1) {
return {
qualifiedRRHosts: hostsAfterContactOwnerMatching,
allFallbackRRHosts: getFallBackWithContactOwner(
hostsAfterFairnessMatching,
hostsAfterContactOwnerMatching[0]
),
fixedHosts,
};
}
return {
qualifiedRRHosts: hostsAfterFairnessMatching,
// only if fairness filtering is active
allFallbackRRHosts:
hostsAfterFairnessMatching.length !== hostsAfterRoutedTeamMemberIdsMatching.length
? hostsAfterRoutedTeamMemberIdsMatching
: undefined,
fixedHosts,
};
};
+1 -65
View File
@@ -1,70 +1,6 @@
import { describe, it, expect } from "vitest";
import {
getRoutedHostsWithContactOwnerAndFixedHosts,
getRoutedUsersWithContactOwnerAndFixedUsers,
} from "./getRoutedUsers";
describe("getRoutedHostsWithContactOwnerAndFixedHosts", () => {
const hosts = [
{ user: { id: 1, email: "user1@example.com" }, isFixed: false },
{ user: { id: 2, email: "user2@example.com" }, isFixed: true },
{ user: { id: 3, email: "user3@example.com" }, isFixed: false },
{ user: { id: 4, email: "owner@example.com" }, isFixed: false },
];
const hostsWithoutFixedHosts = hosts.map((host) => ({ ...host, isFixed: false }));
it("should return all hosts when routedTeamMemberIds is null", () => {
const result = getRoutedHostsWithContactOwnerAndFixedHosts({
routedTeamMemberIds: null,
hosts,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual(hosts);
});
it("should return all hosts when routedTeamMemberIds is empty - We don't want to enter a scenario where we have no team members to be booked", () => {
const result = getRoutedHostsWithContactOwnerAndFixedHosts({
routedTeamMemberIds: [],
hosts,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual(hosts);
});
it("should filter hosts based on routedTeamMemberIds, isFixed, and contactOwnerEmail", () => {
const result = getRoutedHostsWithContactOwnerAndFixedHosts({
routedTeamMemberIds: [1, 3],
hosts,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual([
{ user: { id: 1, email: "user1@example.com" }, isFixed: false },
{ user: { id: 2, email: "user2@example.com" }, isFixed: true },
{ user: { id: 3, email: "user3@example.com" }, isFixed: false },
{ user: { id: 4, email: "owner@example.com" }, isFixed: false },
]);
});
it("should return an empty array when no hosts match the criteria", () => {
const result = getRoutedHostsWithContactOwnerAndFixedHosts({
routedTeamMemberIds: [5],
hosts: hostsWithoutFixedHosts,
contactOwnerEmail: "nonexistent@example.com",
});
expect(result).toEqual([]);
});
it("should return fixed hosts even if routedTeamMemberIds and contactOwnerEmail are invalid ", () => {
const result = getRoutedHostsWithContactOwnerAndFixedHosts({
routedTeamMemberIds: [5],
hosts,
contactOwnerEmail: "nonexistent@example.com",
});
expect(result).toEqual([{ user: { id: 2, email: "user2@example.com" }, isFixed: true }]);
});
});
import { getRoutedUsersWithContactOwnerAndFixedUsers } from "./getRoutedUsers";
describe("getRoutedUsersWithContactOwnerAndFixedUsers", () => {
const users = [
+7 -45
View File
@@ -6,33 +6,6 @@ import { SchedulingType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["[getRoutedUsers]"] });
export const getRoutedHostsWithContactOwnerAndFixedHosts = <
T extends { user: { id: number; email: string }; isFixed?: boolean }
>({
routedTeamMemberIds,
hosts,
contactOwnerEmail,
}: {
routedTeamMemberIds: number[] | null;
hosts: T[];
contactOwnerEmail: string | null;
}) => {
// We don't want to enter a scenario where we have no team members to be booked
// So, let's just fallback to regular flow if no routedTeamMemberIds are provided
if (!routedTeamMemberIds || !routedTeamMemberIds.length) {
return hosts;
}
log.debug(
"filtering hosts as per routedTeamMemberIds",
safeStringify({ routedTeamMemberIds, contactOwnerEmail })
);
return hosts.filter(
(host) =>
routedTeamMemberIds.includes(host.user.id) || host.isFixed || host.user.email === contactOwnerEmail
);
};
export const getRoutedUsersWithContactOwnerAndFixedUsers = <
T extends { id: number; isFixed?: boolean; email: string }
>({
@@ -99,7 +72,7 @@ type BaseHost<User extends BaseUser> = {
user: User;
};
type EventType = {
export type EventType = {
assignAllTeamMembers: boolean;
assignRRMembersUsingSegment: boolean;
rrSegmentQueryValue: AttributesQueryValue | null | undefined;
@@ -119,7 +92,6 @@ export function getNormalizedHosts<User extends BaseUser, Host extends BaseHost<
return {
hosts: eventType.hosts.map((host) => ({
isFixed: host.isFixed,
email: host.user.email,
user: host.user,
priority: host.priority,
weight: host.weight,
@@ -142,14 +114,15 @@ export function getNormalizedHosts<User extends BaseUser, Host extends BaseHost<
}
}
// We don't allow fixed hosts when segment matching is enabled
// If this ever changes, we need to update this function and return fixed hosts
export async function findMatchingHostsWithEventSegment<User extends BaseUser>({
eventType,
normalizedHosts,
hosts,
}: {
eventType: EventType;
normalizedHosts: {
hosts: {
isFixed: boolean;
email: string;
user: User;
priority?: number | null;
weight?: number | null;
@@ -160,20 +133,9 @@ export async function findMatchingHostsWithEventSegment<User extends BaseUser>({
...eventType,
rrSegmentQueryValue: eventType.rrSegmentQueryValue ?? null,
});
const fixedHosts = normalizedHosts.filter((host) => host.isFixed);
const unsegmentedRoundRobinHosts = normalizedHosts.filter((host) => !host.isFixed);
const segmentedRoundRobinHosts = unsegmentedRoundRobinHosts.filter((host) => {
const segmentedRoundRobinHosts = hosts.filter((host) => {
if (!matchingRRTeamMembers) return true;
return matchingRRTeamMembers.includes(host.user.id);
});
// In case we don't have any matching team members, we return all the RR hosts, as we always want the team event to be bookable.
// TODO: We should notify about it to the organizer somehow.
const roundRobinHosts = segmentedRoundRobinHosts.length
? segmentedRoundRobinHosts
: unsegmentedRoundRobinHosts;
return [...fixedHosts, ...roundRobinHosts];
return segmentedRoundRobinHosts;
}
+1 -1
View File
@@ -10,7 +10,7 @@ export function isRerouting({
rescheduleUid: string | null;
routedTeamMemberIds: number[] | null;
}) {
return !!rescheduleUid && !!routedTeamMemberIds;
return !!rescheduleUid && !!routedTeamMemberIds?.length;
}
export function shouldIgnoreContactOwner({
+1 -1
View File
@@ -26,7 +26,7 @@ type PartialBooking = Pick<Booking, "id" | "createdAt" | "userId" | "status"> &
};
type PartialUser = Pick<User, "id" | "email">;
type RoutingFormResponse = {
export type RoutingFormResponse = {
response: Prisma.JsonValue;
chosenRouteId: string | null;
form: {
@@ -804,6 +804,7 @@ export class EventTypeRepository {
metadata: true,
assignRRMembersUsingSegment: true,
rrSegmentQueryValue: true,
isRRWeightsEnabled: true,
maxLeadThreshold: true,
useEventLevelSelectedCalendars: true,
team: {
@@ -851,6 +852,8 @@ export class EventTypeRepository {
select: {
isFixed: true,
createdAt: true,
weight: true,
priority: true,
user: {
select: {
credentials: { select: credentialForCalendarServiceSelect },
@@ -30,6 +30,8 @@ export const getScheduleSchema = z
_enableTroubleshooter: z.boolean().optional(),
_bypassCalendarBusyTimes: z.boolean().optional(),
_shouldServeCache: z.boolean().optional(),
routingFormResponseId: z.number().optional(),
email: z.string().nullish(),
})
.transform((val) => {
// Need this so we can pass a single username in the query string form public API
@@ -1,90 +0,0 @@
import { describe, expect, it } from "vitest";
import type { GetAvailabilityUser } from "@calcom/core/getUserAvailability";
import { getUsersWithCredentialsConsideringContactOwner } from "./util";
describe("getUsersWithCredentialsConsideringContactOwner", () => {
const createMockUser = (email: string): GetAvailabilityUser => ({
id: Math.random(),
email,
name: email.split("@")[0],
username: email.split("@")[0],
timeZone: "UTC",
weekStart: "Monday",
defaultScheduleId: null,
bufferTime: 0,
locale: "en",
});
it("should return all hosts when contact owner does not exist", () => {
const hosts = [
{ isFixed: true, user: createMockUser("host1@example.com") },
{ isFixed: false, user: createMockUser("host2@example.com") },
];
const result = getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail: "nonexistent@example.com",
hosts,
});
expect(result).toHaveLength(2);
expect(result.map((u) => u.email)).toEqual(["host1@example.com", "host2@example.com"]);
});
it("should return all hosts when contact owner is a fixed host", () => {
const hosts = [
{ isFixed: true, user: createMockUser("host1@example.com") },
{ isFixed: false, user: createMockUser("host2@example.com") },
];
const result = getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail: "host1@example.com",
hosts,
});
expect(result).toHaveLength(2);
expect(result.map((u) => u.email)).toEqual(["host1@example.com", "host2@example.com"]);
});
it("should return contact owner and fixed hosts when contact owner is not fixed", () => {
const hosts = [
{ isFixed: true, user: createMockUser("host1@example.com") },
{ isFixed: false, user: createMockUser("host2@example.com") },
{ isFixed: false, user: createMockUser("contactowner@example.com") },
];
const result = getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail: "contactowner@example.com",
hosts,
});
expect(result).toHaveLength(2);
expect(result.map((u) => u.email)).toEqual(["host1@example.com", "contactowner@example.com"]);
});
it("should return all hosts when contactOwnerEmail is null", () => {
const hosts = [
{ isFixed: true, user: createMockUser("host1@example.com") },
{ isFixed: false, user: createMockUser("host2@example.com") },
];
const result = getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail: null,
hosts,
});
expect(result).toHaveLength(2);
expect(result.map((u) => u.email)).toEqual(["host1@example.com", "host2@example.com"]);
});
it("should return only fixed hosts when there's no contact owner", () => {
const hosts = [
{ isFixed: true, user: createMockUser("host1@example.com") },
{ isFixed: false, user: createMockUser("host2@example.com") },
{ isFixed: true, user: createMockUser("host3@example.com") },
];
const result = getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail: null,
hosts,
});
expect(result).toHaveLength(3);
expect(result.map((u) => u.email)).toEqual([
"host1@example.com",
"host2@example.com",
"host3@example.com",
]);
});
});
+110 -114
View File
@@ -19,11 +19,7 @@ import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEvent
import { getShouldServeCache } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
import { findQualifiedHosts } from "@calcom/lib/bookings/findQualifiedHosts";
import {
findMatchingHostsWithEventSegment,
getRoutedHostsWithContactOwnerAndFixedHosts,
} from "@calcom/lib/bookings/getRoutedUsers";
import { isRerouting, shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
@@ -229,48 +225,15 @@ export interface IGetAvailableSlots {
troubleshooter?: any;
}
/**
* Returns (Contact Owner plus Fixed Hosts) OR All Hosts
*/
export function getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail,
export function getUsersWithCredentials({
hosts,
}: {
contactOwnerEmail: string | null | undefined;
hosts: {
isFixed?: boolean;
user: GetAvailabilityUser;
}[];
}) {
const contactOwnerHost = hosts.find((host) => host.user.email === contactOwnerEmail);
/**
* It could still have contact owner, if it was one of the assigned hosts of the event.
* In case of routedTeamMemberIds, hosts will only have the Routed Team Members and in that case, contact owner would be here only if it matches
*/
const allHosts = hosts.map(({ isFixed, user }) => ({ isFixed, ...user }));
const contactOwnerExists = contactOwnerEmail && contactOwnerHost;
if (!contactOwnerExists) {
return allHosts;
}
if (contactOwnerHost?.isFixed) {
// If contact owner is a fixed host, we return all hosts which also includes contact owner
return allHosts;
}
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;
},
[]
);
return contactOwnerAndFixedHosts;
return hosts.map(({ isFixed, user }) => ({ isFixed, ...user }));
}
const getStartTime = (startTimeInput: string, timeZone?: string, minimumBookingNotice?: number) => {
@@ -291,6 +254,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
_enableTroubleshooter: enableTroubleshooter = false,
_bypassCalendarBusyTimes: bypassBusyCalendarTimes = false,
_shouldServeCache,
routingFormResponseId,
} = input;
const orgDetails = input?.orgSlug
? {
@@ -342,19 +306,11 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
if (!startTime.isValid() || !endTime.isValid()) {
throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" });
}
const eventHosts = await monitorCallbackAsync(
findQualifiedHosts<GetAvailabilityUser>,
eventType,
!!input.rescheduleUid
);
const hostsAfterSegmentMatching = await findMatchingHostsWithEventSegment({
eventType,
normalizedHosts: eventHosts,
});
// when an empty array is given we should prefer to have it handled as if this wasn't given at all
// we don't want to return no availability in this case.
const routedTeamMemberIds = input.routedTeamMemberIds ?? [];
const contactOwnerEmailFromInput = input.teamMemberEmail ?? null;
const routedTeamMemberIds = input.routedTeamMemberIds ?? null;
const skipContactOwner = shouldIgnoreContactOwner({
skipContactOwner: input.skipContactOwner ?? null,
rescheduleUid: input.rescheduleUid ?? null,
@@ -362,53 +318,119 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
});
const contactOwnerEmail = skipContactOwner ? null : contactOwnerEmailFromInput;
const routedHostsWithContactOwnerAndFixedHosts = getRoutedHostsWithContactOwnerAndFixedHosts({
hosts: hostsAfterSegmentMatching,
routedTeamMemberIds,
contactOwnerEmail,
});
let routingFormResponse = null;
if (routingFormResponseId) {
routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
where: {
id: routingFormResponseId,
},
select: {
response: true,
form: {
select: {
routes: true,
fields: true,
},
},
chosenRouteId: true,
},
});
}
const { qualifiedRRHosts, allFallbackRRHosts, fixedHosts } = await monitorCallbackAsync(
findQualifiedHosts<GetAvailabilityUser>,
{
eventType,
rescheduleUid: input.rescheduleUid ?? null,
routedTeamMemberIds,
contactOwnerEmail,
routingFormResponse,
}
);
const allHosts = [...qualifiedRRHosts, ...fixedHosts];
const twoWeeksFromNow = dayjs().add(2, "week");
const hasFallbackRRHosts = allFallbackRRHosts && allFallbackRRHosts.length > qualifiedRRHosts.length;
let { allUsersAvailability, usersWithCredentials, currentSeats } = await calculateHostsAndAvailabilities({
input,
eventType,
hosts: routedHostsWithContactOwnerAndFixedHosts,
contactOwnerEmail,
hosts: allHosts,
loggerWithEventDetails,
startTime,
endTime,
// adjust start time so we can check for available slots in the first two weeks
startTime:
hasFallbackRRHosts && startTime.isBefore(twoWeeksFromNow)
? getStartTime(dayjs().format(), input.timeZone, eventType.minimumBookingNotice)
: startTime,
// adjust end time so we can check for available slots in the first two weeks
endTime:
hasFallbackRRHosts && endTime.isBefore(twoWeeksFromNow)
? getStartTime(twoWeeksFromNow.format(), input.timeZone, eventType.minimumBookingNotice)
: endTime,
bypassBusyCalendarTimes,
shouldServeCache,
});
let aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType);
// If contact skipping, determine if there's availability within two weeks
if (contactOwnerEmail && aggregatedAvailability.length > 0) {
const twoWeeksFromNow = dayjs().add(2, "week");
const diff = aggregatedAvailability[0].start.diff(twoWeeksFromNow, "day");
// Fairness and Contact Owner have fallbacks because we check for within 2 weeks
if (hasFallbackRRHosts) {
let diff = 0;
if (startTime.isBefore(twoWeeksFromNow)) {
//check if first two week have availability
diff =
aggregatedAvailability.length > 0 ? aggregatedAvailability[0].start.diff(twoWeeksFromNow, "day") : 1; // no aggregatedAvailability so we diff to +1
} else {
// if start time is not within first two weeks, check if there are any available slots
if (!aggregatedAvailability.length) {
// if no available slots check if first two weeks are available, otherwise fallback
const firstTwoWeeksAvailabilities = await calculateHostsAndAvailabilities({
input,
eventType,
hosts: [...qualifiedRRHosts, ...fixedHosts],
loggerWithEventDetails,
startTime: dayjs(),
endTime: twoWeeksFromNow,
bypassBusyCalendarTimes,
shouldServeCache,
});
if (
!getAggregatedAvailability(
firstTwoWeeksAvailabilities.allUsersAvailability,
eventType.schedulingType
).length
) {
diff = 1;
}
}
}
if (input.email) {
loggerWithEventDetails.info({
email: input.email,
contactOwnerEmail,
qualifiedRRHosts: qualifiedRRHosts.map((host) => host.user.id),
fallbackRRHosts: allFallbackRRHosts.map((host) => host.user.id),
fallBackActive: diff > 0,
});
}
if (diff > 0) {
const routedHostsAndFixedHosts = routedHostsWithContactOwnerAndFixedHosts.filter(
(host) => host.email !== contactOwnerEmail
);
if (routedHostsAndFixedHosts.length > 0) {
// if the first available slot is more than 2 weeks from now, round robin as normal
({ allUsersAvailability, usersWithCredentials, currentSeats } = await calculateHostsAndAvailabilities(
{
input,
eventType,
hosts: routedHostsAndFixedHosts,
contactOwnerEmail,
loggerWithEventDetails,
startTime,
endTime,
bypassBusyCalendarTimes,
shouldServeCache,
}
));
aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType);
}
// if the first available slot is more than 2 weeks from now, round robin as normal
({ allUsersAvailability, usersWithCredentials, currentSeats } = await calculateHostsAndAvailabilities({
input,
eventType,
hosts: [...allFallbackRRHosts, ...fixedHosts],
loggerWithEventDetails,
startTime,
endTime,
bypassBusyCalendarTimes,
shouldServeCache,
}));
aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType);
}
}
@@ -697,7 +719,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
userId: user.id,
};
}),
hostsAfterSegmentMatching: hostsAfterSegmentMatching.map((host) => ({
hostsAfterSegmentMatching: allHosts.map((host) => ({
userId: host.user.id,
})),
},
@@ -759,7 +781,7 @@ async function getExistingBookings(
in: "ACCEPTED"[];
};
},
usersWithCredentials: ReturnType<typeof getUsersWithCredentialsConsideringContactOwner>,
usersWithCredentials: ReturnType<typeof getUsersWithCredentials>,
allUserIds: number[]
) {
const bookingsSelect = Prisma.validator<Prisma.BookingSelect>()({
@@ -930,7 +952,6 @@ const calculateHostsAndAvailabilities = async ({
input,
eventType,
hosts,
contactOwnerEmail,
loggerWithEventDetails,
startTime,
endTime,
@@ -943,38 +964,13 @@ const calculateHostsAndAvailabilities = async ({
isFixed?: boolean;
user: GetAvailabilityUser;
}[];
contactOwnerEmail: string | null;
loggerWithEventDetails: Logger<unknown>;
startTime: ReturnType<typeof getStartTime>;
endTime: Dayjs;
bypassBusyCalendarTimes: boolean;
shouldServeCache?: boolean;
}) => {
const routedTeamMemberIds = input.routedTeamMemberIds ?? null;
if (
input.rescheduleUid &&
eventType.rescheduleWithSameRoundRobinHost &&
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
// If it is rerouting, we should not force reschedule with same host.
// It will be unexpected plus could cause unavailable slots as original host might not be part of routedTeamMemberIds
!isRerouting({ rescheduleUid: input.rescheduleUid, routedTeamMemberIds })
) {
const originalRescheduledBooking = await prisma.booking.findFirst({
where: {
uid: input.rescheduleUid,
status: {
in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING],
},
},
select: {
userId: true,
},
});
hosts = hosts.filter((host) => host.user.id === originalRescheduledBooking?.userId || 0);
}
const usersWithCredentials = monitorCallbackSync(getUsersWithCredentialsConsideringContactOwner, {
contactOwnerEmail,
const usersWithCredentials = monitorCallbackSync(getUsersWithCredentials, {
hosts,
});