diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 0b57e66ce2..712687d90f 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -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"); diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 884eb018f1..07bfcdf85c 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -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: [ diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index a30ad16c14..4cc96113a6 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -180,7 +180,7 @@ function buildLuckyUsersWithJustContactOwner({ availableUsers: IsFixedAwareUser[]; fixedUserPool: IsFixedAwareUser[]; }) { - const luckyUsers: Awaited> = []; + const luckyUsers: Awaited>["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; diff --git a/packages/features/bookings/lib/handleNewBooking/getLocationValuesForDb.ts b/packages/features/bookings/lib/handleNewBooking/getLocationValuesForDb.ts index 5efa56e02f..dec930700a 100644 --- a/packages/features/bookings/lib/handleNewBooking/getLocationValuesForDb.ts +++ b/packages/features/bookings/lib/handleNewBooking/getLocationValuesForDb.ts @@ -2,7 +2,7 @@ import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; import type { loadAndValidateUsers } from "./loadAndValidateUsers"; -type Users = Awaited>; +type Users = Awaited>["qualifiedRRUsers"]; const sortUsersByDynamicList = (users: Users, dynamicUserList: string[]) => { return users.sort((a, b) => { diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts index b010b9cba5..37298924d9 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts @@ -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; 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 { + 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, + }; } diff --git a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts index 021260d8cb..71052cedaa 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts @@ -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 ({ ...user, diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts index 47f0c71a86..205efe22d1 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts @@ -2193,14 +2193,7 @@ describe("handleNewBooking", () => { }, }, }, - users: [ - { - id: 101, - }, - { - id: 102, - }, - ], + hosts: [{ userId: 101 }, { userId: 102 }], }, ], organizer, diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index e8fb2e6021..7670df5863 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -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 = { diff --git a/packages/lib/bookings/filterHostsByLeadThreshold.test.ts b/packages/lib/bookings/filterHostsByLeadThreshold.test.ts index f8ca3ceac2..cd57cf9b3b 100644 --- a/packages/lib/bookings/filterHostsByLeadThreshold.test.ts +++ b/packages/lib/bookings/filterHostsByLeadThreshold.test.ts @@ -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 }); }); diff --git a/packages/lib/bookings/filterHostsByLeadThreshold.ts b/packages/lib/bookings/filterHostsByLeadThreshold.ts index 85bf915fe7..dd598cab9a 100644 --- a/packages/lib/bookings/filterHostsByLeadThreshold.ts +++ b/packages/lib/bookings/filterHostsByLeadThreshold.ts @@ -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; - return dates.reduce((mostRecent, current) => (current > mostRecent ? current : mostRecent)); +type BaseHost = { + isFixed: boolean; + createdAt: Date; + priority?: number | null; + weight?: number | null; + weightAdjustment?: number | null; + user: User; +}; + +type PerUserData = Awaited>["perUserData"]; +type WeightedPerUserData = Omit & { + weights: NonNullable; + calibrations: NonNullable; + bookingShortfalls: NonNullable; +}; + +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 >({ - hosts, - eventTypeId, -}: { - eventTypeId: number; - hosts: (T & { - isFixed: false; - createdAt: Date; - user: { - id: number; - email: string; - }; - })[]; -}) => { - if (!hosts.length) return []; - // use either the beginning of the month, of the most recently added host; whichever is most recent - const startDate = getMostRecentDate([...hosts.map(({ createdAt }) => createdAt), START_OF_MONTH]); - // we need booking data now, this cannot be queried ahead of time as it requires knowing the most recent host date - // data only available after the initial call. - const bookingCounts = await BookingRepository.groupByActiveBookingCounts({ - startDate, - users: hosts.map((host) => ({ - ...host.user, - })), - eventTypeId, - }); - const { minBookingCount, bookingCountMap } = bookingCounts.reduce( - ( - acc: { - minBookingCount: number; - bookingCountMap: Record; - }, - booking - ) => { - // satisfy TS, obviously as we've where'd on userId it cannot be null but try telling TS that.. - if (!booking.userId) return acc; - acc.minBookingCount = Math.min(acc.minBookingCount, booking._count._all || Infinity); - acc.bookingCountMap[booking.userId] = booking._count._all; - return acc; - }, - { - minBookingCount: bookingCounts.length < hosts.length ? 0 : Infinity, - bookingCountMap: {}, +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 >({ +export const filterHostsByLeadThreshold = async >({ hosts, maxLeadThreshold, - eventTypeId, + eventType, + routingFormResponse, }: { - hosts: ({ isFixed: boolean; createdAt: Date; user: { id: number; email: string } } & T)[]; + hosts: T[]; maxLeadThreshold: number | null; - eventTypeId: number; -}): Promise[]> => { - if (maxLeadThreshold === null) return hosts; - // Calculate offsets for non-fixed hosts only once - const computedRoundRobinHosts = await computeLeadOffsets({ - eventTypeId, - hosts: hosts.filter((host) => !host.isFixed).map((host) => ({ ...host, isFixed: false as const })), + 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; - } else { - // Apply lead threshold filtering on round-robin hosts - const roundRobinHost = { ...computedRoundRobinHosts[roundRobinIndex++] }; - if ( - _filterHostByLeadThreshold({ - host: roundRobinHost, - maxLeadThreshold, - }) - ) { - // cleanup with destructure to remove leadOffset - const { leadOffset, ...roundRobinHostWithoutLeadOffset } = roundRobinHost; - return roundRobinHostWithoutLeadOffset; - } - return null; - } - }) - .filter((host): host is Omit => host !== null); + + 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; }; diff --git a/packages/lib/bookings/filterHostsBySameRoundRobinHost.test.ts b/packages/lib/bookings/filterHostsBySameRoundRobinHost.test.ts new file mode 100644 index 0000000000..cd246856b8 --- /dev/null +++ b/packages/lib/bookings/filterHostsBySameRoundRobinHost.test.ts @@ -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]]); + }); +}); diff --git a/packages/lib/bookings/filterHostsBySameRoundRobinHost.ts b/packages/lib/bookings/filterHostsBySameRoundRobinHost.ts new file mode 100644 index 0000000000..76208c0427 --- /dev/null +++ b/packages/lib/bookings/filterHostsBySameRoundRobinHost.ts @@ -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); +}; diff --git a/packages/lib/bookings/findQualifiedHosts.test.ts b/packages/lib/bookings/findQualifiedHosts.test.ts index 1b517c57fd..297a6023e8 100644 --- a/packages/lib/bookings/findQualifiedHosts.test.ts +++ b/packages/lib/bookings/findQualifiedHosts.test.ts @@ -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: [], + }); + }); }); diff --git a/packages/lib/bookings/findQualifiedHosts.ts b/packages/lib/bookings/findQualifiedHosts.ts index d8304bab3a..309d3482b8 100644 --- a/packages/lib/bookings/findQualifiedHosts.ts +++ b/packages/lib/bookings/findQualifiedHosts.ts @@ -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 >( +type Host = { + 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(currentValue: T[], newValue: T[]): T[] { + return newValue.length > 0 ? newValue : currentValue; +} + +function getFallBackWithContactOwner( + fallbackHosts: T[], + contactOwner: T +) { + if (fallbackHosts.find((host) => host.user.id === contactOwner.user.id)) { + return fallbackHosts; + } + + return [...fallbackHosts, contactOwner]; +} + +const isRoundRobinHost = (host: T): host is T & { isFixed: false } => { + return host.isFixed === false; +}; + +const isFixedHost = (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 +>({ + 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[]; 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, + }; }; diff --git a/packages/lib/bookings/getRoutedUsers.test.ts b/packages/lib/bookings/getRoutedUsers.test.ts index 9bf10ffdf0..3f65f40710 100644 --- a/packages/lib/bookings/getRoutedUsers.test.ts +++ b/packages/lib/bookings/getRoutedUsers.test.ts @@ -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 = [ diff --git a/packages/lib/bookings/getRoutedUsers.ts b/packages/lib/bookings/getRoutedUsers.ts index 6f6e29e7af..37f701557c 100644 --- a/packages/lib/bookings/getRoutedUsers.ts +++ b/packages/lib/bookings/getRoutedUsers.ts @@ -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: User; }; -type EventType = { +export type EventType = { assignAllTeamMembers: boolean; assignRRMembersUsingSegment: boolean; rrSegmentQueryValue: AttributesQueryValue | null | undefined; @@ -119,7 +92,6 @@ export function getNormalizedHosts ({ isFixed: host.isFixed, - email: host.user.email, user: host.user, priority: host.priority, weight: host.weight, @@ -142,14 +114,15 @@ export function getNormalizedHosts({ 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({ ...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; } diff --git a/packages/lib/bookings/routing/utils.ts b/packages/lib/bookings/routing/utils.ts index 5eacc5594b..220a29b490 100644 --- a/packages/lib/bookings/routing/utils.ts +++ b/packages/lib/bookings/routing/utils.ts @@ -10,7 +10,7 @@ export function isRerouting({ rescheduleUid: string | null; routedTeamMemberIds: number[] | null; }) { - return !!rescheduleUid && !!routedTeamMemberIds; + return !!rescheduleUid && !!routedTeamMemberIds?.length; } export function shouldIgnoreContactOwner({ diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index 1b353bac4b..d1d79e6ab9 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -26,7 +26,7 @@ type PartialBooking = Pick & }; type PartialUser = Pick; -type RoutingFormResponse = { +export type RoutingFormResponse = { response: Prisma.JsonValue; chosenRouteId: string | null; form: { diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index b1ccfc2fe7..30f6e3c6e9 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -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 }, diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 79e36b064c..0d82f05497 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -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 diff --git a/packages/trpc/server/routers/viewer/slots/util.test.ts b/packages/trpc/server/routers/viewer/slots/util.test.ts deleted file mode 100644 index b3ede3d45e..0000000000 --- a/packages/trpc/server/routers/viewer/slots/util.test.ts +++ /dev/null @@ -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", - ]); - }); -}); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 6cb83350a4..3812962ddb 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -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, - 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, + { + 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 ({ + hostsAfterSegmentMatching: allHosts.map((host) => ({ userId: host.user.id, })), }, @@ -759,7 +781,7 @@ async function getExistingBookings( in: "ACCEPTED"[]; }; }, - usersWithCredentials: ReturnType, + usersWithCredentials: ReturnType, allUserIds: number[] ) { const bookingsSelect = Prisma.validator()({ @@ -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; startTime: ReturnType; 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, });