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:
co-authored by
CarinaWolli
Peer Richelsen
Carina Wollendorfer
parent
03346f04ed
commit
649faf4ff6
@@ -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,
|
||||
|
||||
+1
-8
@@ -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);
|
||||
};
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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 = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function isRerouting({
|
||||
rescheduleUid: string | null;
|
||||
routedTeamMemberIds: number[] | null;
|
||||
}) {
|
||||
return !!rescheduleUid && !!routedTeamMemberIds;
|
||||
return !!rescheduleUid && !!routedTeamMemberIds?.length;
|
||||
}
|
||||
|
||||
export function shouldIgnoreContactOwner({
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user