diff --git a/apps/web/components/dialog/RerouteDialog.tsx b/apps/web/components/dialog/RerouteDialog.tsx index 2ea7104ec0..a5612a8f43 100644 --- a/apps/web/components/dialog/RerouteDialog.tsx +++ b/apps/web/components/dialog/RerouteDialog.tsx @@ -775,9 +775,9 @@ const RerouteDialogContentAndFooterWithFormResponse = ({ >([]); const findTeamMembersMatchingAttributeLogicMutation = - trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({ + trpc.viewer.routingForms.findTeamMembersMatchingAttributeLogic.useMutation({ onSuccess(data) { - setTeamMembersMatchingAttributeLogic(data.result); + setTeamMembersMatchingAttributeLogic(data.result ? data.result.users : data.result); }, }); diff --git a/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx index f8b7a3ba25..721417aa96 100644 --- a/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx +++ b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx @@ -164,28 +164,6 @@ vi.mock("@calcom/trpc/react", () => ({ isPending: false, })), }, - findTeamMembersMatchingAttributeLogic: { - useMutation: vi.fn(({ onSuccess }) => { - return { - mutate: vi.fn(() => { - onSuccess({ - result: [ - { - id: 1, - name: "Matching User 1", - email: "matching-user-1@example.com", - }, - { - id: 2, - name: "Matching User 2", - email: "matching-user-2@example.com", - }, - ], - }); - }), - }; - }), - }, }, eventTypes: { get: { @@ -204,11 +182,37 @@ vi.mock("@calcom/trpc/react", () => ({ })), }, }, + routingForms: { + findTeamMembersMatchingAttributeLogic: { + useMutation: vi.fn(({ onSuccess }) => { + return { + mutate: vi.fn(() => { + onSuccess({ + result: { + users: [ + { + id: 1, + name: "Matching User 1", + email: "matching-user-1@example.com", + }, + { + id: 2, + name: "Matching User 2", + email: "matching-user-2@example.com", + }, + ], + }, + }); + }), + }; + }), + }, + }, }, }, })); -const mockMutateFn = vi.fn(({ __testOnSuccess }) => { +const mockReactQueryMutateFn = vi.fn(({ __testOnSuccess }) => { __testOnSuccess({ uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT", }); @@ -218,7 +222,7 @@ vi.mock("@tanstack/react-query", () => ({ useMutation: vi.fn(({ onSuccess }) => { return { mutate: vi.fn((payload) => { - mockMutateFn({ + mockReactQueryMutateFn({ ...payload, __testOnSuccess: onSuccess, }); @@ -441,7 +445,7 @@ describe("RerouteDialog", () => { ); clickVerifyNewRouteButton(); clickRescheduleWithSameTimeslotOfChosenEventButton(); - expect(mockMutateFn).toHaveBeenCalledWith( + expect(mockReactQueryMutateFn).toHaveBeenCalledWith( expect.objectContaining({ rescheduleUid: mockBooking.uid, // Shouldn't include the user who booked the booking diff --git a/apps/web/lib/getTeamMemberEmailFromCrm.ts b/apps/web/lib/getTeamMemberEmailFromCrm.ts new file mode 100644 index 0000000000..38c492201b --- /dev/null +++ b/apps/web/lib/getTeamMemberEmailFromCrm.ts @@ -0,0 +1,246 @@ +import type { ParsedUrlQuery } from "querystring"; + +import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip"; +import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants"; +import { enabledAppSlugs } from "@calcom/app-store/routing-forms/lib/enabledApps"; +import type { AttributeRoutingConfig, LocalRoute } from "@calcom/app-store/routing-forms/types/types"; +import { zodRoutes as routesSchema } from "@calcom/app-store/routing-forms/zod"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { SchedulingType } from "@calcom/prisma/enums"; + +const log = logger.getSubLogger({ prefix: ["getTeamMemberEmailFromCrm"] }); + +interface EventData { + id: number; + isInstantEvent: boolean; + schedulingType: SchedulingType | null; + metadata: Prisma.JsonValue | null; + length: number; +} + +function getRoutingFormResponseIdFromQuery(query: ParsedUrlQuery) { + const routingFormResponseIdAsNumber = Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]); + const routingFormResponseId = isNaN(routingFormResponseIdAsNumber) ? null : routingFormResponseIdAsNumber; + return routingFormResponseId; +} + +async function getAttributeRoutingConfig( + data: + | { + routingFormResponseId: number; + eventTypeId: number; + } + | { + route: Pick; + } +) { + if ("route" in data) { + return data.route.attributeRoutingConfig ?? null; + } + const { routingFormResponseId, eventTypeId } = data; + const routingFormQuery = await prisma.app_RoutingForms_Form.findFirst({ + where: { + responses: { + some: { + id: routingFormResponseId, + }, + }, + }, + select: { + routes: true, + }, + }); + if (!routingFormQuery || !routingFormQuery?.routes) return null; + const parsedRoutes = routesSchema.safeParse(routingFormQuery.routes); + + if (!parsedRoutes.success || !parsedRoutes.data) return null; + + // Find the route with the attributeRoutingConfig + // FIXME: There could be multiple routes with same action.eventTypeId, we should actually ensure we have the chosenRouteId in here and use that route. + const route = parsedRoutes.data.find((route) => { + if ("action" in route) { + return route.action.eventTypeId === eventTypeId; + } + }); + + if (!route || !("attributeRoutingConfig" in route)) return null; + // Get attributeRoutingConfig for the form + const attributeRoutingConfig = route.attributeRoutingConfig; + + if (!attributeRoutingConfig) return null; + return attributeRoutingConfig; +} + +function getEnabledRoutingFormAppSlugFromQuery(query: ParsedUrlQuery) { + // Determine if a routing form enabled app is in the query. Then pass it to the proper handler + // Routing form apps will have the format cal.appSlug + let enabledRoutingFormApp; + + for (const key of Object.keys(query)) { + const keySplit = key.split("."); + + const appSlug = keySplit[1]; + + if (enabledAppSlugs.includes(appSlug)) { + enabledRoutingFormApp = appSlug; + break; + } + } + + return enabledRoutingFormApp; +} + +/** + * Uses the owner of the contact directly from CRM + */ +async function getOwnerEmailFromCrm(eventData: EventData, email: string): Promise { + const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata); + if (!crmContactOwnerEmail) return null; + // Determine if the contactOwner is a part of the event type + const contactOwnerQuery = await prisma.user.findFirst({ + where: { + email: crmContactOwnerEmail, + hosts: { + some: { + eventTypeId: eventData.id, + }, + }, + }, + }); + if (!contactOwnerQuery) return null; + return crmContactOwnerEmail; +} + +/** + * Handles custom lookup field logic + */ +async function getTeamMemberEmailUsingRoutingFormHandler({ + bookerEmail, + eventTypeId, + attributeRoutingConfig, + crmAppSlug, +}: { + bookerEmail: string; + eventTypeId: number; + attributeRoutingConfig: AttributeRoutingConfig | null; + crmAppSlug: string; +}) { + const nullReturnValue = { email: null, skipContactOwner: false }; + + if (!attributeRoutingConfig) return nullReturnValue; + + // If the skipContactOwner is enabled then don't return an team member email + if (attributeRoutingConfig.skipContactOwner) return { ...nullReturnValue, skipContactOwner: true }; + + const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler")) + .default; + const appHandler = appBookingFormHandler[crmAppSlug]; + + if (!appHandler) return nullReturnValue; + + const { email: userEmail } = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId); + + if (!userEmail) return nullReturnValue; + + // Determine if the user is a part of the event type + const userQuery = await prisma.user.findFirst({ + where: { + email: userEmail, + hosts: { + some: { + eventTypeId: eventTypeId, + }, + }, + }, + }); + + if (!userQuery) return nullReturnValue; + + return { ...nullReturnValue, email: userEmail }; +} + +async function getTeamMemberEmailForResponseOrContact({ + bookerEmail, + eventTypeId, + eventData, + routingFormResponseId, + chosenRoute, + crmAppSlug, +}: { + bookerEmail: string; + eventTypeId: number; + eventData: EventData; + routingFormResponseId?: number | null; + /** + * If provided, we won't go look for the route from DB. + */ + chosenRoute?: LocalRoute; + crmAppSlug: string; +}) { + if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null; + + const attributeRoutingConfigGetterData = routingFormResponseId + ? { routingFormResponseId, eventTypeId } + : chosenRoute + ? { route: chosenRoute } + : null; + + // If we have found crmAppSlug, it means that the CRM App in the routing-form will handle the logic + if (attributeRoutingConfigGetterData && crmAppSlug) { + log.debug( + "Using CRM App handler in routing-forms", + safeStringify({ attributeRoutingConfigGetterData, crmAppSlug }) + ); + const attributeRoutingConfig = await getAttributeRoutingConfig(attributeRoutingConfigGetterData); + const { email, skipContactOwner } = await getTeamMemberEmailUsingRoutingFormHandler({ + bookerEmail, + eventTypeId, + attributeRoutingConfig, + crmAppSlug, + }); + + if (skipContactOwner) return null; + if (email) return email; + } else { + log.debug("Getting the contact owner email from CRM"); + return await getOwnerEmailFromCrm(eventData, bookerEmail); + } + + return null; +} + +export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query, + eventTypeId, + eventData, + chosenRoute, +}: { + query: ParsedUrlQuery; + eventTypeId: number; + eventData: EventData; + chosenRoute?: LocalRoute; +}) { + // Without email no lookup is possible + if (!query.email || typeof query.email !== "string") { + return null; + } + + log.debug("getTeamMemberEmailForResponseOrContactUsingUrlQuery", safeStringify({ query })); + + const crmAppSlug = getEnabledRoutingFormAppSlugFromQuery(query); + if (!crmAppSlug) return null; + + const routingFormResponseId = getRoutingFormResponseIdFromQuery(query); + + return await getTeamMemberEmailForResponseOrContact({ + bookerEmail: query.email, + eventTypeId, + eventData, + routingFormResponseId, + chosenRoute, + crmAppSlug, + }); +} diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 253105fecf..640cd7283b 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -1,12 +1,6 @@ -import type { Prisma } from "@prisma/client"; import type { GetServerSidePropsContext } from "next"; -import type { ParsedUrlQuery } from "querystring"; import { z } from "zod"; -import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip"; -import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants"; -import { enabledAppSlugs } from "@calcom/app-store/routing-forms/lib/enabledApps"; -import { zodRoutes as routesSchema } from "@calcom/app-store/routing-forms/zod"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; @@ -14,7 +8,6 @@ import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/org import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; import { RedirectType } from "@calcom/prisma/client"; -import { SchedulingType } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect"; @@ -93,7 +86,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const ssr = await ssrInit(context); const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true"; const isUnpublished = team.parent ? !team.parent.slug : !team.slug; - + const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import( + "@calcom/web/lib/getTeamMemberEmailFromCrm" + ); return { props: { eventData: { @@ -117,148 +112,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => isInstantMeeting: eventData && queryIsInstantMeeting ? true : false, themeBasis: null, orgBannerUrl: team.parent?.bannerUrl ?? "", - teamMemberEmail: await handleGettingTeamMemberEmail(query, eventTypeId, eventData), + teamMemberEmail: await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query, + eventTypeId, + eventData, + }), }, }; }; - -interface EventData { - id: number; - isInstantEvent: boolean; - schedulingType: SchedulingType | null; - metadata: Prisma.JsonValue | null; - length: number; -} - -async function handleGettingTeamMemberEmail( - query: ParsedUrlQuery, - eventTypeId: number, - eventData: EventData -) { - if ( - !query.email || - typeof query.email !== "string" || - eventData.schedulingType !== SchedulingType.ROUND_ROBIN - ) - return null; - - // Check if a routing form was completed and an routing form option is enabled - if ( - ROUTING_FORM_RESPONSE_ID_QUERY_STRING in query && - Object.values(query).some((value) => value === "true") - ) { - const { email, skipContactOwner } = await handleRoutingFormOption(query, eventTypeId); - - if (skipContactOwner) return null; - if (email) return email; - } else { - return await getTeamMemberEmail(eventData, query.email); - } - - return null; -} - -async function handleRoutingFormOption(query: ParsedUrlQuery, eventTypeId: number) { - const nullReturnValue = { email: null, skipContactOwner: false }; - - if (typeof query.email !== "string") return nullReturnValue; - - const routingFormQuery = await prisma.app_RoutingForms_Form.findFirst({ - where: { - responses: { - some: { - id: Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]), - }, - }, - }, - select: { - routes: true, - }, - }); - - if (!routingFormQuery || !routingFormQuery?.routes) return nullReturnValue; - - const parsedRoutes = routesSchema.safeParse(routingFormQuery.routes); - - if (!parsedRoutes.success || !parsedRoutes.data) return nullReturnValue; - - // Find the route with the attributeRoutingConfig - const route = parsedRoutes.data.find((route) => { - if ("action" in route) { - return route.action.eventTypeId === eventTypeId; - } - }); - - if (!route || !("attributeRoutingConfig" in route)) return nullReturnValue; - - // Get attributeRoutingConfig for the form - const attributeRoutingConfig = route.attributeRoutingConfig; - - if (!attributeRoutingConfig) return nullReturnValue; - - // If the skipContactOwner is enabled then don't return an team member email - if (attributeRoutingConfig?.skipContactOwner) return { ...nullReturnValue, skipContactOwner: true }; - - // Determine if a routing form enabled app is in the query. Then pass it to the proper handler - // Routing form apps will have the format cal.appSlug - let enabledRoutingFormApp; - - for (const key of Object.keys(query)) { - const keySplit = key.split("."); - - const appSlug = keySplit[1]; - - if (enabledAppSlugs.includes(appSlug)) { - enabledRoutingFormApp = appSlug; - break; - } - } - - if (!enabledRoutingFormApp) return nullReturnValue; - - const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler")) - .default; - const appHandler = appBookingFormHandler[enabledRoutingFormApp]; - - if (!appHandler) return nullReturnValue; - - const { email: userEmail } = await appHandler(query.email, attributeRoutingConfig, eventTypeId); - - if (!userEmail) return nullReturnValue; - - // Determine if the user is a part of the event type - const userQuery = await await prisma.user.findFirst({ - where: { - email: userEmail, - hosts: { - some: { - eventTypeId: eventTypeId, - }, - }, - }, - }); - - if (!userQuery) return nullReturnValue; - - return { ...nullReturnValue, email: userEmail }; -} - -async function getTeamMemberEmail(eventData: EventData, email: string): Promise { - // Pre-requisites - if (!eventData || !email || eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null; - const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata); - if (!crmContactOwnerEmail) return null; - // Determine if the contactOwner is a part of the event type - const contactOwnerQuery = await prisma.user.findFirst({ - where: { - email: crmContactOwnerEmail, - hosts: { - some: { - eventTypeId: eventData.id, - }, - }, - }, - }); - if (!contactOwnerQuery) return null; - return crmContactOwnerEmail; -} diff --git a/apps/web/pages/api/trpc/routingForms/[trpc].ts b/apps/web/pages/api/trpc/routingForms/[trpc].ts new file mode 100644 index 0000000000..10a59cd994 --- /dev/null +++ b/apps/web/pages/api/trpc/routingForms/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { routingFormsRouter } from "@calcom/trpc/server/routers/viewer/routing-forms/_router"; + +export default createNextApiHandler(routingFormsRouter); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 652b052920..501e748e1c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2680,6 +2680,8 @@ "booking_limits_updated_successfully": "Booking limits updated successfully", "you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking", "matching_members": "Matching members", + "matching_members_queue_using_attribute_weights": "Matching members queue (using attribute weights)", + "matching_members_queue_using_event_assignee_weights": "Matching members queue (using event assignee weights)", "no_matching_members": "No matching members. It will fallback to using the team members assigned to the event type.", "no_matching_members_will_fallback_to_all_assigned_members": "No matching members. It will fallback to using the team members assigned to the event type. Consider adding a fallback or correcting the logic of using_fallback_members", "hide_calendar_event_details": "Hide calendar event details on shared calendars", @@ -2729,6 +2731,8 @@ "add_new_field": "Add new field", "you_dont_have_access_to_reroute_this_booking": "You don't have access to reroute this booking", "form_response_not_found": "Form response not found", + "contact_owner": "Contact owner", + "contact_owner_not_found": "Not found", "using_fallback_members": "Using fallback members", "chosen_route": "Chosen Route", "attribute_logic_matched": "Attribute logic matched", @@ -2767,6 +2771,7 @@ "booking_start_date": "Booking start date", "booking_created_date": "Booking created date", "booking_reassigned_to_host": "Booking reassigned to {{host}}", + "no_contact_owner": "No contact owner", "routing_forms_created": "Routing Forms Created", "routing_forms_total_responses": "Total Responses", "routing_forms_total_responses_without_booking": "Total Responses Without Booking", diff --git a/packages/app-store/_utils/CRMRoundRobinSkip.ts b/packages/app-store/_utils/CRMRoundRobinSkip.ts index 8b9b53762c..be062bf7b2 100644 --- a/packages/app-store/_utils/CRMRoundRobinSkip.ts +++ b/packages/app-store/_utils/CRMRoundRobinSkip.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { z } from "zod"; import CrmManager from "@calcom/core/crmManager/crmManager"; +import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; import type { EventTypeAppMetadataSchema } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; @@ -11,14 +12,15 @@ export async function getCRMContactOwnerForRRLeadSkip( eventTypeMetadata: Prisma.JsonValue ): Promise { const parsedEventTypeMetadata = EventTypeMetaDataSchema.safeParse(eventTypeMetadata); - if (!parsedEventTypeMetadata.success || !parsedEventTypeMetadata.data?.apps) return; const crm = await getCRMManagerWithRRLeadSkip(parsedEventTypeMetadata.data.apps); if (!crm) return; - + const startTime = performance.now(); const contact = await crm.getContacts({ emails: bookerEmail, forRoundRobinSkip: true }); + const endTime = performance.now(); + logger.info(`Fetching from CRM took ${endTime - startTime}ms`); if (!contact?.length) return; return contact[0].ownerEmail; } diff --git a/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx b/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx index 729d8af16e..bd28ff0f83 100644 --- a/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx +++ b/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx @@ -68,7 +68,7 @@ vi.mock("@calcom/lib/hooks/useLocale", () => ({ })); let findTeamMembersMatchingAttributeLogicResponse: { - result: { email: string }[] | null; + result: { users: { email: string }[] } | null; checkedFallback: boolean; mainWarnings?: string[] | null; fallbackWarnings?: string[] | null; @@ -97,7 +97,7 @@ function mockFindTeamMembersMatchingAttributeLogicResponse( vi.mock("@calcom/trpc/react", () => ({ trpc: { viewer: { - appRoutingForms: { + routingForms: { findTeamMembersMatchingAttributeLogic: { useMutation: vi.fn(({ onSuccess }) => { return { @@ -210,7 +210,9 @@ describe("TestFormDialog", () => { it("suggests to add fallback when matching members is empty and fallback is not checked", async () => { mockEventTypeRedirectUrlMatchingRoute(); mockFindTeamMembersMatchingAttributeLogicResponse({ - result: [], + result: { + users: [], + }, checkedFallback: false, }); render( {}} />); @@ -264,7 +266,9 @@ describe("TestFormDialog", () => { it("should show No in main and fallback matched", async () => { mockEventTypeRedirectUrlMatchingRoute(); mockFindTeamMembersMatchingAttributeLogicResponse({ - result: [], + result: { + users: [], + }, checkedFallback: true, mainWarnings: null, fallbackWarnings: null, diff --git a/packages/app-store/routing-forms/components/SingleForm.tsx b/packages/app-store/routing-forms/components/SingleForm.tsx index e5528ec49b..51e1931a58 100644 --- a/packages/app-store/routing-forms/components/SingleForm.tsx +++ b/packages/app-store/routing-forms/components/SingleForm.tsx @@ -237,7 +237,16 @@ type SingleFormComponentProps = { }; type MembersMatchResultType = { + isUsingAttributeWeights: boolean; + eventTypeRedirectUrl: string | null; + contactOwnerEmail: string | null; teamMembersMatchingAttributeLogic: { id: number; name: string | null; email: string }[] | null; + perUserData: { + bookingsCount: Record; + bookingShortfalls: Record | null; + calibrations: Record | null; + weights: Record | null; + } | null; checkedFallback: boolean; mainWarnings: string[] | null; fallbackWarnings: string[] | null; @@ -273,7 +282,7 @@ const TeamMembersMatchResult = ({ return !membersMatchResult.checkedFallback ? t("yes") : t("no"); }; - const renderMatchingMembers = () => { + const renderQueue = () => { if (isNoLogicFound(membersMatchResult.teamMembersMatchingAttributeLogic)) { if (membersMatchResult.checkedFallback) { return ( @@ -291,12 +300,48 @@ const TeamMembersMatchResult = ({ ); } - const matchingMembers = membersMatchResult.teamMembersMatchingAttributeLogic.map( - (member) => member.email - ); + const matchingMembers = membersMatchResult.teamMembersMatchingAttributeLogic; - if (matchingMembers.length) { - return {matchingMembers.join(", ")}; + if (matchingMembers.length && membersMatchResult.perUserData) { + const perUserData = membersMatchResult.perUserData; + return ( + +
+ + + + + + + {membersMatchResult.perUserData.weights ? : null} + {membersMatchResult.perUserData.calibrations ? : null} + {membersMatchResult.perUserData.bookingShortfalls ? ( + + ) : null} + + + + {matchingMembers.map((member, index) => ( + + + + + {perUserData.weights ? ( + + ) : null} + {perUserData.calibrations ? ( + + ) : null} + {perUserData.bookingShortfalls ? ( + + ) : null} + + ))} + +
#EmailBookingsWeightCalibrationShortfall
{index + 1}{member.email}{perUserData.bookingsCount[member.id] ?? 0}{perUserData.weights[member.id] ?? 0}{perUserData.calibrations[member.id] ?? 0}{perUserData.bookingShortfalls[member.id] ?? 0}
+
+
+ ); } return ( @@ -328,8 +373,23 @@ const TeamMembersMatchResult = ({ /> )} -
- {t("matching_members")}: {renderMatchingMembers()} +
+ {membersMatchResult.contactOwnerEmail ? ( +
+ {t("contact_owner")}:{" "} + {membersMatchResult.contactOwnerEmail} +
+ ) : ( +
+ {t("contact_owner")}: Not found +
+ )} +
+ {membersMatchResult.isUsingAttributeWeights + ? t("matching_members_queue_using_attribute_weights") + : t("matching_members_queue_using_event_assignee_weights")} + {renderQueue()} +
); @@ -362,7 +422,7 @@ export const TestFormDialog = ({ const { t } = useLocale(); const [response, setResponse] = useState({}); const [chosenRoute, setChosenRoute] = useState(null); - const [eventTypeUrl, setEventTypeUrl] = useState(""); + const [eventTypeUrlWithoutParams, setEventTypeUrlWithoutParams] = useState(""); const searchParams = useCompatSearchParams(); const isTeamForm = !!form.teamId; const [membersMatchResult, setMembersMatchResult] = useState(null); @@ -371,10 +431,14 @@ export const TestFormDialog = ({ setMembersMatchResult(null); }; const findTeamMembersMatchingAttributeLogicMutation = - trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({ + trpc.viewer.routingForms.findTeamMembersMatchingAttributeLogic.useMutation({ onSuccess(data) { setMembersMatchResult({ - teamMembersMatchingAttributeLogic: data.result, + isUsingAttributeWeights: data.isUsingAttributeWeights, + eventTypeRedirectUrl: data.eventTypeRedirectUrl, + contactOwnerEmail: data.contactOwnerEmail, + teamMembersMatchingAttributeLogic: data.result ? data.result.users : data.result, + perUserData: data.result ? data.result.perUserData : null, checkedFallback: data.checkedFallback, mainWarnings: data.mainWarnings, fallbackWarnings: data.fallbackWarnings, @@ -391,27 +455,30 @@ export const TestFormDialog = ({ function testRouting() { const route = findMatchingRoute({ form, response }); + let eventTypeRedirectUrl: string | null = null; + if (route?.action?.type === "eventTypeRedirectUrl") { - setEventTypeUrl( - getAbsoluteEventTypeRedirectUrl({ - eventTypeRedirectUrl: route.action.value, - form, - allURLSearchParams: new URLSearchParams(), - }) - ); + eventTypeRedirectUrl = getAbsoluteEventTypeRedirectUrl({ + eventTypeRedirectUrl: route.action.value, + form, + allURLSearchParams: new URLSearchParams(), + }); + setEventTypeUrlWithoutParams(eventTypeRedirectUrl); } setChosenRoute(route || null); if (!route) return; - findTeamMembersMatchingAttributeLogicMutation.mutate({ - formId: form.id, - response, - route, - isPreview: true, - _enablePerf: searchParams.get("enablePerf") === "true", - }); + if (isTeamForm) { + findTeamMembersMatchingAttributeLogicMutation.mutate({ + formId: form.id, + response, + route, + isPreview: true, + _enablePerf: searchParams.get("enablePerf") === "true", + }); + } } const renderTestResult = () => { @@ -461,7 +528,14 @@ export const TestFormDialog = ({ ) : (
- + {chosenRoute.action.value} @@ -486,7 +560,7 @@ export const TestFormDialog = ({ return ( - +
[]; searchParams: URLSearchParams; - formResponseId: number; + formResponseId: number | null; teamMembersMatchingAttributeLogic: number[] | null; attributeRoutingConfig: AttributeRoutingConfig | null; reroutingFormResponses?: FormResponseValueOnly; @@ -152,3 +152,26 @@ export function getUrlSearchParamsToForwardForReroute({ reroutingFormResponses, }); } + +export function getUrlSearchParamsToForwardForTestPreview({ + formResponse, + fields, + attributeRoutingConfig, + teamMembersMatchingAttributeLogic, +}: Pick< + GetUrlSearchParamsToForwardOptions, + "formResponse" | "fields" | "attributeRoutingConfig" | "teamMembersMatchingAttributeLogic" +>) { + // There are no existing query params to forward in test preview. These are available only when doing the actual form submission + const searchParams = new URLSearchParams(); + searchParams.set("cal.isTestPreviewLink", "true"); + return getUrlSearchParamsToForward({ + formResponse, + fields, + attributeRoutingConfig, + teamMembersMatchingAttributeLogic, + // There is no form response being stored in test preview + formResponseId: null, + searchParams, + }); +} diff --git a/packages/app-store/routing-forms/trpc/_router.ts b/packages/app-store/routing-forms/trpc/_router.ts index 806634d921..1e6d158cf4 100644 --- a/packages/app-store/routing-forms/trpc/_router.ts +++ b/packages/app-store/routing-forms/trpc/_router.ts @@ -5,7 +5,6 @@ import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure"; import { router } from "@calcom/trpc/server/trpc"; import { ZDeleteFormInputSchema } from "./deleteForm.schema"; -import { ZFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema"; import { ZFormMutationInputSchema } from "./formMutation.schema"; import { ZFormQueryInputSchema } from "./formQuery.schema"; import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema"; @@ -97,16 +96,6 @@ const appRoutingForms = router({ ); return handler({ ctx, input }); }), - - findTeamMembersMatchingAttributeLogic: authedProcedure - .input(ZFindTeamMembersMatchingAttributeLogicInputSchema) - .mutation(async ({ ctx, input }) => { - const handler = await getHandler( - "findTeamMembersMatchingAttributeLogic", - () => import("./findTeamMembersMatchingAttributeLogic.handler") - ); - return handler({ ctx, input }); - }), }); export default appRoutingForms; diff --git a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts b/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts deleted file mode 100644 index 0608ba3b1b..0000000000 --- a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { ServerResponse } from "http"; -import type { NextApiResponse } from "next"; - -import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils"; -import { UserRepository } from "@calcom/lib/server/repository/user"; -import type { PrismaClient } from "@calcom/prisma"; -import { TRPCError } from "@calcom/trpc/server"; -import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; - -import { findTeamMembersMatchingAttributeLogicOfRoute } from "../lib/findTeamMembersMatchingAttributeLogicOfRoute"; -import { getSerializableForm } from "../lib/getSerializableForm"; -import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema"; - -interface FindTeamMembersMatchingAttributeLogicHandlerOptions { - ctx: { - prisma: PrismaClient; - user: NonNullable; - res: ServerResponse | NextApiResponse | undefined; - }; - input: TFindTeamMembersMatchingAttributeLogicInputSchema; -} - -export const findTeamMembersMatchingAttributeLogicHandler = async ({ - ctx, - input, -}: FindTeamMembersMatchingAttributeLogicHandlerOptions) => { - const { prisma, user } = ctx; - const { formId, response, route, isPreview, _enablePerf, _concurrency } = input; - - const form = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: formId, - ...entityPrismaWhereClause({ userId: user.id }), - }, - }); - - if (!form) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Form not found", - }); - } - - if (!form.teamId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This form is not associated with a team", - }); - } - - const serializableForm = await getSerializableForm({ form }); - - const { - teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult, - timeTaken: teamMembersMatchingAttributeLogicTimeTaken, - troubleshooter, - checkedFallback, - mainAttributeLogicBuildingWarnings: mainWarnings, - fallbackAttributeLogicBuildingWarnings: fallbackWarnings, - } = await findTeamMembersMatchingAttributeLogicOfRoute( - { - response, - route, - form: serializableForm, - teamId: form.teamId, - isPreview: !!isPreview, - }, - { - enablePerf: _enablePerf, - // Reuse same flag for enabling troubleshooter. We would normall use them together - enableTroubleshooter: _enablePerf, - concurrency: _concurrency, - } - ); - - if (!matchingTeamMembersWithResult) { - return { - troubleshooter, - checkedFallback, - mainWarnings, - fallbackWarnings, - result: null, - }; - } - const matchingTeamMembersIds = matchingTeamMembersWithResult.map((member) => member.userId); - const matchingTeamMembers = await UserRepository.findByIds({ ids: matchingTeamMembersIds }); - - console.log("_enablePerf, _concurrency", _enablePerf, _concurrency); - if (_enablePerf) { - const serverTimingHeader = getServerTimingHeader(teamMembersMatchingAttributeLogicTimeTaken); - ctx.res?.setHeader("Server-Timing", serverTimingHeader); - console.log("Server-Timing", serverTimingHeader); - } - - return { - troubleshooter, - checkedFallback, - mainWarnings, - fallbackWarnings, - result: matchingTeamMembers.map((user) => ({ - id: user.id, - name: user.name, - email: user.email, - })), - }; -}; - -function getServerTimingHeader(timeTaken: Record) { - const headerParts = Object.entries(timeTaken) - .map(([key, value]) => { - if (value !== null && value !== undefined) { - return `${key};dur=${value}`; - } - return null; - }) - .filter(Boolean); - - return headerParts.join(", "); -} - -export default findTeamMembersMatchingAttributeLogicHandler; diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts index 42a3a99a6c..fd63c3ac41 100644 --- a/packages/app-store/routing-forms/trpc/response.handler.ts +++ b/packages/app-store/routing-forms/trpc/response.handler.ts @@ -8,6 +8,7 @@ import type { PrismaClient } from "@calcom/prisma"; import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@calcom/trpc/server"; +// import { RoutingFormFieldType } from "../lib/FieldTypes"; import { findTeamMembersMatchingAttributeLogicOfRoute } from "../lib/findTeamMembersMatchingAttributeLogicOfRoute"; import { getSerializableForm } from "../lib/getSerializableForm"; import type { FormResponse } from "../types/types"; @@ -96,14 +97,6 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => }); } - const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({ - data: { - formId, - response: response, - chosenRouteId, - }, - }); - const settings = RoutingFormSettings.parse(form.settings); let userWithEmails: string[] = []; if (form.teamId && settings?.sendUpdatesTo?.length) { @@ -155,13 +148,81 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => ) : null; + // const chosenRouteName = `Route ${chosenRouteIndex + 1}`; + + // if (input.isPreview) { + // // Detect if response has value for a field that isn't in the field list + // const formFields = serializableFormWithFields.fields.map((field) => field.id); + // const extraFields = Object.keys(response).filter((fieldId) => !formFields.includes(fieldId)); + // const attributeRoutingConfig = + // "attributeRoutingConfig" in chosenRoute ? chosenRoute.attributeRoutingConfig ?? null : null; + + // let previewData = { + // teamMemberIdsMatchingAttributeLogic, + // chosenRoute: { + // name: chosenRouteName, + // action: "action" in chosenRoute ? chosenRoute.action : null, + // }, + // skipContactOwner: attributeRoutingConfig?.skipContactOwner ?? false, + // warnings: [] as string[], + // errors: [] as string[], + // }; + + // if (extraFields.length > 0) { + // // If response submitted directly through the /response.handler, it is useful to know which fields were non-existent + // // If we reach here through router, all extra fields are already removed from here + // previewData.warnings.push( + // `Response contains values for non-existent fields: ${extraFields.join(", ")}` + // ); + // } + + // // Check for values not present in options for SINGLE_SELECT and MULTISELECT fields + // serializableFormWithFields.fields.forEach((field) => { + // if ( + // field.type !== RoutingFormFieldType.SINGLE_SELECT && + // field.type !== RoutingFormFieldType.MULTI_SELECT + // ) { + // return; + // } + + // const fieldResponse = response[field.id]; + + // if (fieldResponse && fieldResponse.value) { + // const values = Array.isArray(fieldResponse.value) ? fieldResponse.value : [fieldResponse.value]; + // const invalidValues = values.filter( + // (value) => !field.options?.some((option) => option.id === value || option.label === value) + // ); + // if (invalidValues.length > 0) { + // previewData.errors.push(`Invalid value(s) for ${field.label}: ${invalidValues.join(", ")}`); + // } + // } + // }); + + // return { + // isPreview: true, + // previewData, + // formResponse: null, + // teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic, + // }; + // } + + const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({ + data: { + formId, + response: response, + chosenRouteId, + }, + }); + await onFormSubmission( { ...serializableFormWithFields, userWithEmails }, dbFormResponse.response as FormResponse, dbFormResponse.id, "action" in chosenRoute ? chosenRoute.action : undefined ); + return { + isPreview: false, formResponse: dbFormResponse, teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic, attributeRoutingConfig: diff --git a/packages/app-store/routing-forms/trpc/response.schema.ts b/packages/app-store/routing-forms/trpc/response.schema.ts index d232506438..861e721184 100644 --- a/packages/app-store/routing-forms/trpc/response.schema.ts +++ b/packages/app-store/routing-forms/trpc/response.schema.ts @@ -11,6 +11,7 @@ export const ZResponseInputSchema = z.object({ ), // TODO: There could be existing forms loaded that will not send chosenRouteId. Make it required later. chosenRouteId: z.string().optional(), + isPreview: z.boolean().optional(), }); export type TResponseInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts index 0f1174c2dd..1784d9a804 100644 --- a/packages/app-store/routing-forms/trpc/utils.ts +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -112,6 +112,7 @@ export async function onFormSubmission( throw new Error(`Field with id ${fieldId} not found`); } // Use the label lowercased as the key to identify a field. + // TODO: We seem to be using label from the response, Can we not use the field.label const key = form.fields.find((f) => f.id === fieldId)?.identifier || (fieldResponse.label as keyof typeof fieldResponsesByIdentifier); diff --git a/packages/app-store/routing-forms/zod.ts b/packages/app-store/routing-forms/zod.ts index bfdb1a7479..04a0012b13 100644 --- a/packages/app-store/routing-forms/zod.ts +++ b/packages/app-store/routing-forms/zod.ts @@ -200,6 +200,7 @@ export const appDataSchema = z.any(); export const appKeysSchema = z.object({}); // This is different from FormResponse in types.d.ts in that it has label optional. We don't seem to be using label at this point, so we might want to use this only while saving the response when Routing Form is submitted +// Record key is formFieldId export const routingFormResponseInDbSchema = z.record( z.object({ label: z.string().optional(), diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 6f798ce29f..eda60308f4 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -498,7 +498,7 @@ async function handler( (host) => !host.isFixed && userIdsSet.has(host.user.id) ), // users part of virtual queue eventType, - routingFormResponse, + routingFormResponse: routingFormResponse ?? null, }); if (!newLuckyUser) { break; // prevent infinite loop diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index cc610e9b26..bce27032c3 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -129,6 +129,7 @@ export const roundRobinReassignment = async ({ availableUsers, eventType, allRRHosts: eventType.hosts.filter((host) => !host.isFixed), // todo: only use hosts from virtual queue + routingFormResponse: null, }); const hasOrganizerChanged = !previousRRHost || booking.userId === previousRRHost?.id; diff --git a/packages/lib/server/getLuckyUser.integration-test.ts b/packages/lib/server/getLuckyUser.integration-test.ts index 66e9a85b31..edfbccf8f2 100644 --- a/packages/lib/server/getLuckyUser.integration-test.ts +++ b/packages/lib/server/getLuckyUser.integration-test.ts @@ -1,294 +1,357 @@ -import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest"; +import { describe, it, vi, expect, afterEach, beforeEach, beforeAll, afterAll } from "vitest"; import prisma from "@calcom/prisma"; -import { getLuckyUser } from "./getLuckyUser"; +import { getLuckyUser, getOrderedListOfLuckyUsers } from "./getLuckyUser"; + +let commonEventTypeId: number; +const userIds: number[] = []; + +async function deleteUsers() { + await prisma.user.deleteMany({ + where: { + id: { + in: userIds, + }, + }, + }); + userIds.splice(0, userIds.length); +} + +beforeAll(async () => { + const event = await prisma.eventType.create({ + data: { + title: "Test Event", + slug: "test-event", + length: 15, + }, + select: { + id: true, + }, + }); + commonEventTypeId = event.id; +}); + +afterEach(async () => { + await deleteUsers(); + vi.useRealTimers(); +}); + +afterAll(async () => { + await prisma.eventType.delete({ + where: { + id: commonEventTypeId, + }, + }); +}); + +type BookingPropsRelatedToLuckyUserAlgorithm = { + eventTypeId: number; + createdAt: Date; + noShowHost?: boolean | null; +}; + +type OptionalBookingProps = { + uid?: string; + title?: string; + startTime?: Date; + endTime?: Date; + attendees?: { + create: { + name: string; + email: string; + timeZone: string; + noShow?: boolean | null; + }[]; + }; +}; + +type UserProps = { + email: string; +}; + +const commonBookingData = { + startTime: new Date(), + endTime: new Date(), +}; + +const commonAttendeesData = [ + { + name: "test-attendee", + email: "test-attendee@example.com", + timeZone: "Asia/Calcutta", + }, +]; + +const createUserWithBookings = async ({ + user: { email }, + bookings, +}: { + user: UserProps; + bookings: (BookingPropsRelatedToLuckyUserAlgorithm & OptionalBookingProps)[]; +}) => { + const user = await prisma.user.create({ + data: { + email, + bookings: { + create: bookings.map(({ eventTypeId, ...booking }, index) => ({ + ...commonBookingData, + attendees: { + create: commonAttendeesData, + }, + uid: `uuid-${email}-booking${index + 1}`, + title: `${email} Booking ${index + 1}`, + eventTypeId, + ...booking, + })), + }, + }, + include: { + bookings: true, + }, + }); + userIds.push(user.id); + return user; +}; + +const createHostWithBookings = async ({ + user: userData, + bookings, + weight, + createdAt, +}: { + user: UserProps; + bookings: (BookingPropsRelatedToLuckyUserAlgorithm & OptionalBookingProps)[]; + weight?: number; + createdAt?: Date; +}) => { + const user = await createUserWithBookings({ user: userData, bookings }); + + const host = await prisma.host.create({ + data: { + user: { connect: { id: user.id } }, + eventType: { connect: { id: commonEventTypeId } }, + weight, + createdAt: createdAt ?? new Date(), + }, + include: { + user: { + include: { + bookings: true, + }, + }, + }, + }); + console.log({ + [user.id]: user.email, + }); + return { + ...host, + user: { + ...host.user, + weight, + }, + }; +}; + +function expectLuckyUsers(luckyUsers: { email: string }[], expectedLuckyUsers: { email: string }[]) { + expect(luckyUsers.map((user) => user.email)).toEqual(expectedLuckyUsers.map((user) => user.email)); +} describe("getLuckyUser Integration tests", () => { describe("should not consider no show bookings for round robin: ", () => { - let userIds: number[] = []; - let eventTypeId: number; - - beforeAll(async () => { - const event = await prisma.eventType.create({ - data: { - title: "Test Event", - slug: "test-event", - length: 15, - }, - select: { - id: true, - }, - }); - eventTypeId = event.id; - }); - - afterEach(async () => { - await prisma.user.deleteMany({ - where: { - id: { - in: userIds, - }, - }, - }); - - userIds = []; - }); - - afterAll(async () => { - await prisma.eventType.delete({ - where: { - id: eventTypeId, - }, - }); - }); - it("When a host is no show, that is chosen when competing with another host that showed up for the booking", async () => { - const organizerThatShowedUp = await prisma.user.create({ - data: { - email: "test-user1@example.com", - bookings: { - create: [ - { - uid: "uuid-test-user1-booking1", - createdAt: new Date("2022-01-25T05:30:00.000Z"), - title: "Test user 1 Booking", - startTime: new Date(), - endTime: new Date(), - eventTypeId, - attendees: { - create: [ - { - name: "test-attendee", - email: "test-attendee@example.com", - timeZone: "Asia/Calcutta", - }, - ], - }, - }, - ], - }, + const createOrganizerThatShowedUp = async (email: string) => { + return createHostWithBookings({ + user: { email }, + bookings: [{ eventTypeId: commonEventTypeId, createdAt: new Date("2022-01-25T05:30:00.000Z") }], + }); + }; + + const createOrganizerThatDidntShowUp = async (email: string) => { + return createHostWithBookings({ + user: { email }, + bookings: [ + { + eventTypeId: commonEventTypeId, + createdAt: new Date("2022-01-25T06:30:00.000Z"), + noShowHost: true, + }, + ], + }); + }; + + const organizerHostThatShowedUp = await createOrganizerThatShowedUp("test-user1@example.com"); + const organizerHostThatDidntShowUp = await createOrganizerThatDidntShowUp("test-user2@example.com"); + const organizerThatShowedUp = organizerHostThatShowedUp.user; + const organizerThatDidntShowUp = organizerHostThatDidntShowUp.user; + console.log({ + organizerHostThatShowedUp: { + id: organizerThatShowedUp.id, + email: organizerThatShowedUp.email, + bookings: JSON.stringify(organizerThatShowedUp.bookings), + }, + organizerThatDidntShowUp: { + id: organizerThatDidntShowUp.id, + email: organizerThatDidntShowUp.email, + bookings: JSON.stringify(organizerThatDidntShowUp.bookings), }, }); - - const organizerThatDidntShowUp = await prisma.user.create({ - data: { - email: "test-user2@example.com", - bookings: { - create: [ - { - uid: "uuid-test-user2-booking1", - title: "Test User 2 Booking", - createdAt: new Date("2022-01-25T06:30:00.000Z"), - noShowHost: true, - startTime: new Date(), - endTime: new Date(), - attendees: { - create: [ - { - name: "test-attendee", - email: "test-attendee@example.com", - timeZone: "Asia/Calcutta", - }, - ], - }, - eventTypeId, - }, - ], - }, + const luckyUser = await getLuckyUser({ + availableUsers: [organizerThatShowedUp, organizerThatDidntShowUp], + eventType: { + id: commonEventTypeId, + isRRWeightsEnabled: false, + team: {}, }, + allRRHosts: [], + routingFormResponse: null, }); - userIds.push(organizerThatShowedUp.id, organizerThatDidntShowUp.id); - - expect( - getLuckyUser({ - availableUsers: [organizerThatShowedUp, organizerThatDidntShowUp], - eventType: { - id: eventTypeId, - isRRWeightsEnabled: false, - }, - allRRHosts: [], - }) - ).resolves.toStrictEqual(organizerThatDidntShowUp); + expect(luckyUser.email).toBe(organizerThatDidntShowUp.email); }); it("When a attendee is a noShow for organizers booking, that organizer is competing with another host whose attendee showed up for the booking", async () => { - const organizerWhoseAttendeeShowedUp = await prisma.user.create({ - data: { - email: "test-user1@example.com", - bookings: { - create: [ - { - uid: "uuid-test-user1-booking1", - createdAt: new Date("2022-01-25T05:30:00.000Z"), - title: "Test User 1 Booking", - startTime: new Date(), - endTime: new Date(), - eventTypeId, - attendees: { - create: [ - { - name: "test-attendee", - email: "test-attendee@example.com", - timeZone: "Asia/Calcutta", - }, - ], + const organizerHostWhoseAttendeeShowedUp = await createHostWithBookings({ + user: { email: "test-user1@example.com" }, + bookings: [ + { + eventTypeId: commonEventTypeId, + createdAt: new Date("2022-01-25T05:30:00.000Z"), + title: "Test User 1 Booking", + attendees: { + create: [ + { + name: "test-attendee", + email: "test-attendee@example.com", + timeZone: "Asia/Calcutta", }, - }, - ], + ], + }, }, - }, + ], }); - const organizerWhoseAttendeeDidntShowUp = await prisma.user.create({ - data: { - email: "test-user2@example.com", - bookings: { - create: [ - { - uid: "uuid-test-user2-booking1", - title: "Test User 2 Booking", - createdAt: new Date("2022-01-25T06:30:00.000Z"), - startTime: new Date(), - endTime: new Date(), - attendees: { - create: [ - { - noShow: true, - name: "test-attendee", - email: "test-attendee@example.com", - timeZone: "Asia/Calcutta", - }, - ], + const organizerWhoseAttendeeShowedUp = organizerHostWhoseAttendeeShowedUp.user; + + const organizerHostWhoseAttendeeDidntShowUp = await createHostWithBookings({ + user: { email: "test-user2@example.com" }, + bookings: [ + { + eventTypeId: commonEventTypeId, + createdAt: new Date("2022-01-25T06:30:00.000Z"), + attendees: { + create: [ + { + noShow: true, + name: "test-attendee", + email: "test-attendee@example.com", + timeZone: "Asia/Calcutta", }, - eventTypeId, - }, - ], + ], + }, }, - }, + ], }); - userIds.push(organizerWhoseAttendeeShowedUp.id, organizerWhoseAttendeeDidntShowUp.id); + const organizerWhoseAttendeeDidntShowUp = organizerHostWhoseAttendeeDidntShowUp.user; expect( getLuckyUser({ availableUsers: [organizerWhoseAttendeeShowedUp, organizerWhoseAttendeeDidntShowUp], eventType: { - id: eventTypeId, + id: commonEventTypeId, isRRWeightsEnabled: false, + team: {}, }, allRRHosts: [], + routingFormResponse: null, }) ).resolves.toStrictEqual(organizerWhoseAttendeeDidntShowUp); }); it("When a organizer is attendee (event types with fixed hosts) and no show, that organizer is competing other hosts", async () => { - const organizerWhoseAttendeeShowedUp = await prisma.user.create({ - data: { - email: "test-user1@example.com", - bookings: { - create: [ - { - uid: "uuid-test-user1-booking1", - createdAt: new Date("2022-01-25T05:30:00.000Z"), - title: "Test User 1 Booking", - startTime: new Date(), - endTime: new Date(), - eventTypeId, - attendees: { - create: [ - { - name: "test-attendee", - email: "test-attendee@example.com", - timeZone: "Asia/Calcutta", - }, - ], + const organizerHostWhoseAttendeeShowedUp = await createHostWithBookings({ + user: { email: "test-user1@example.com" }, + bookings: [ + { + eventTypeId: commonEventTypeId, + createdAt: new Date("2022-01-25T05:30:00.000Z"), + title: "Test User 1 Booking", + attendees: { + create: [ + { + name: "test-attendee", + email: "test-attendee@example.com", + timeZone: "Asia/Calcutta", }, - }, - ], + ], + }, }, - }, + ], + }); + const organizerWhoseAttendeeShowedUp = organizerHostWhoseAttendeeShowedUp.user; + + const fixedHostOrganizerHostWhoseAttendeeDidNotShowUp = await createHostWithBookings({ + user: { email: "test-user2@example.com" }, + bookings: [ + { + eventTypeId: commonEventTypeId, + createdAt: new Date("2022-01-25T06:30:00.000Z"), + attendees: { + create: [ + { + name: "test-attendee", + email: "test-attendee@example.com", + timeZone: "Asia/Calcutta", + }, + ], + }, + }, + // User2 is fixed user, User 3 was selected as round robin host but did not show up so this booking should be counted for User 3 + { + uid: "uuid-test-user2-booking2", + title: `Test User 2 Booking 2`, + createdAt: new Date("2022-01-25T07:30:00.000Z"), + eventTypeId: commonEventTypeId, + attendees: { + create: [ + { + name: "test-attendee", + email: "test-user3@example.com", + timeZone: "Asia/Calcutta", + noShow: true, + }, + ], + }, + }, + ], + }); + const fixedHostOrganizerWhoseAttendeeDidNotShowUp = + fixedHostOrganizerHostWhoseAttendeeDidNotShowUp.user; + + const organizerHostWhoWasAttendeeAndDidntShowUp = await createHostWithBookings({ + user: { email: `test-user3@example.com` }, + bookings: [ + { + uid: "uuid-test-user3-booking1", + title: `Test User 3 Booking`, + createdAt: new Date("2022-01-25T04:30:00.000Z"), + attendees: { + create: [ + { + name: "test-attendee", + email: "test-attendee@example.com", + timeZone: "Asia/Calcutta", + }, + ], + }, + eventTypeId: commonEventTypeId, + }, + ], }); - const fixedHostOrganizerWhoseAttendeeDidNotShowUp = await prisma.user.create({ - data: { - email: "test-user2@example.com", - bookings: { - create: [ - { - uid: "uuid-test-user2-booking1", - title: `Test User 2 Booking`, - createdAt: new Date("2022-01-25T06:30:00.000Z"), - startTime: new Date(), - endTime: new Date(), - attendees: { - create: [ - { - name: "test-attendee", - email: "test-attendee@example.com", - timeZone: "Asia/Calcutta", - }, - ], - }, - eventTypeId, - }, - // User2 is fixed user, User 3 was selected as round robin host but did not show up so this booking should be counted for User 3 - { - uid: "uuid-test-user2-booking2", - title: `Test User 2 Booking 2`, - createdAt: new Date("2022-01-25T07:30:00.000Z"), - startTime: new Date(), - endTime: new Date(), - attendees: { - create: [ - { - name: "test-attendee", - email: "test-user3@example.com", - timeZone: "Asia/Calcutta", - noShow: true, - }, - ], - }, - eventTypeId, - }, - ], - }, - }, - }); - - const organizerWhoWasAttendeeAndDidntShowUp = await prisma.user.create({ - data: { - email: `test-user3@example.com`, - bookings: { - create: [ - { - uid: "uuid-test-user3-booking1", - title: `Test User 3 Booking`, - createdAt: new Date("2022-01-25T04:30:00.000Z"), - startTime: new Date(), - endTime: new Date(), - attendees: { - create: [ - { - name: "test-attendee", - email: "test-attendee@example.com", - timeZone: "Asia/Calcutta", - }, - ], - }, - eventTypeId, - }, - ], - }, - }, - }); - - userIds.push( - organizerWhoseAttendeeShowedUp.id, - fixedHostOrganizerWhoseAttendeeDidNotShowUp.id, - organizerWhoWasAttendeeAndDidntShowUp.id - ); + const organizerWhoWasAttendeeAndDidntShowUp = organizerHostWhoWasAttendeeAndDidntShowUp.user; expect( getLuckyUser({ @@ -298,83 +361,428 @@ describe("getLuckyUser Integration tests", () => { organizerWhoWasAttendeeAndDidntShowUp, ], eventType: { - id: eventTypeId, + id: commonEventTypeId, isRRWeightsEnabled: false, + team: {}, }, allRRHosts: [], + routingFormResponse: null, }) ).resolves.toStrictEqual(organizerWhoWasAttendeeAndDidntShowUp); }); it("should consider booking when noShowHost is null", async () => { - const user1 = await prisma.user.create({ - data: { - email: "test-user1@example.com", - bookings: { - create: [ - { - uid: "uuid-test-user1-booking1", - createdAt: new Date("2022-01-25T07:30:00.000Z"), - title: "Test user 1 Booking", - startTime: new Date(), - noShowHost: null, - endTime: new Date(), - eventTypeId, - attendees: { - create: [ - { - name: "test-attendee", - email: "test-attendee@example.com", - timeZone: "Asia/Calcutta", - }, - ], + const hostWithBookingThatHappenedLater = await createHostWithBookings({ + user: { email: "test-user1@example.com" }, + bookings: [ + { + uid: "uuid-test-user1-booking1", + createdAt: new Date("2022-01-25T07:30:00.000Z"), + title: "Test user 1 Booking", + noShowHost: null, + eventTypeId: commonEventTypeId, + attendees: { + create: [ + { + name: "test-attendee", + email: "test-attendee@example.com", + timeZone: "Asia/Calcutta", }, - }, - ], + ], + }, }, - }, + ], }); - const user2 = await prisma.user.create({ - data: { - email: "test-user2@example.com", - bookings: { - create: [ - { - uid: "uuid-test-user2-booking1", - title: "Test User 2 Booking", - createdAt: new Date("2022-01-25T06:30:00.000Z"), - noShowHost: null, - startTime: new Date(), - endTime: new Date(), - attendees: { - create: [ - { - name: "test-attendee", - email: "test-attendee@example.com", - timeZone: "Asia/Calcutta", - }, - ], + const userWithBookingThatHappenedLater = hostWithBookingThatHappenedLater.user; + + const hostWithBookingThatHappenedEarlier = await createHostWithBookings({ + user: { email: "test-user2@example.com" }, + bookings: [ + { + uid: "uuid-test-user2-booking1", + title: "Test User 2 Booking", + createdAt: new Date("2022-01-25T06:30:00.000Z"), + noShowHost: null, + eventTypeId: commonEventTypeId, + attendees: { + create: [ + { + name: "test-attendee", + email: "test-attendee@example.com", + timeZone: "Asia/Calcutta", }, - eventTypeId, - }, - ], + ], + }, }, - }, + ], }); - userIds.push(user1.id, user2.id); + const userWithBookingThatHappenedEarlier = hostWithBookingThatHappenedEarlier.user; expect( getLuckyUser({ - availableUsers: [user1, user2], + availableUsers: [userWithBookingThatHappenedLater, userWithBookingThatHappenedEarlier], eventType: { - id: eventTypeId, + id: commonEventTypeId, isRRWeightsEnabled: false, + team: {}, }, allRRHosts: [], + routingFormResponse: null, }) - ).resolves.toStrictEqual(user2); + ).resolves.toStrictEqual(userWithBookingThatHappenedEarlier); + }); + }); +}); + +describe("getOrderedListOfLuckyUsers Integration tests", () => { + beforeEach(() => { + vi.setSystemTime("2024-11-14T00:00:13Z"); + }); + + it("should sort as per availableUsers if no other criteria like weight/priority/calibration (TODO: make it independent of availableUsers order)", async () => { + const [host1, host2, host3] = await Promise.all([ + createHostWithBookings({ + user: { email: "test-user1@example.com" }, + bookings: [], + createdAt: new Date(), + }), + createHostWithBookings({ + user: { email: "test-user2@example.com" }, + bookings: [], + createdAt: new Date(), + }), + createHostWithBookings({ + user: { email: "test-user3@example.com" }, + bookings: [], + createdAt: new Date(), + }), + ]); + + const user1 = host1.user; + const user2 = host2.user; + const user3 = host3.user; + + const { users: luckyUsers } = await getOrderedListOfLuckyUsers({ + availableUsers: [user2, user1, user3], + eventType: { + id: commonEventTypeId, + isRRWeightsEnabled: false, + team: {}, + }, + allRRHosts: [], + routingFormResponse: null, + }); + + expectLuckyUsers(luckyUsers, [user2, user1, user3]); + + const { users: luckyUsers2 } = await getOrderedListOfLuckyUsers({ + availableUsers: [user3, user1, user2], + eventType: { + id: commonEventTypeId, + isRRWeightsEnabled: false, + team: {}, + }, + allRRHosts: [], + routingFormResponse: null, + }); + expectLuckyUsers(luckyUsers2, [user3, user1, user2]); + }); + + describe("should sort as per weights", () => { + const isRRWeightsEnabled = true; + it("even if there are no bookings", async () => { + const [host1WithWeight100, host2WithWeight200, host3WithWeight100] = await Promise.all([ + createHostWithBookings({ + user: { email: "test-user1@example.com" }, + bookings: [], + createdAt: new Date(), + weight: 100, + }), + createHostWithBookings({ + user: { email: "test-user2@example.com" }, + bookings: [], + createdAt: new Date(), + weight: 200, + }), + createHostWithBookings({ + user: { email: "test-user3@example.com" }, + bookings: [], + createdAt: new Date(), + weight: 100, + }), + ]); + + const user1WithWeight100 = host1WithWeight100.user; + const userWithHighestWeight = host2WithWeight200.user; + const user2WithWeight100 = host3WithWeight100.user; + + const allRRHosts = [host1WithWeight100, host2WithWeight200, host3WithWeight100]; + const { users: luckyUsers } = await getOrderedListOfLuckyUsers({ + availableUsers: [userWithHighestWeight, user1WithWeight100, user2WithWeight100], + eventType: { + id: commonEventTypeId, + isRRWeightsEnabled, + team: {}, + }, + allRRHosts, + routingFormResponse: null, + }); + + expectLuckyUsers(luckyUsers, [ + // It has the highest weight + userWithHighestWeight, + // It has the same weight as the next one but comes earlier in availableUsers array + user1WithWeight100, + // It is the last choice + user2WithWeight100, + ]); + + const { users: luckyUsers2 } = await getOrderedListOfLuckyUsers({ + availableUsers: [user2WithWeight100, userWithHighestWeight, user1WithWeight100], + eventType: { + id: commonEventTypeId, + isRRWeightsEnabled, + team: {}, + }, + allRRHosts, + routingFormResponse: null, + }); + expectLuckyUsers(luckyUsers2, [ + // It has the highest weight and zero bookings. + userWithHighestWeight, + // It has the same weight as the next one but comes earlier in availableUsers array + user2WithWeight100, + // It is the last choice + user1WithWeight100, + ]); + }); + + it("consider booking count for the current month", async () => { + const [ + hostWithOneBookingAndWeight200, + hostWithTwoBookingsAndWeight100, + hostWithThreeBookingsAndWeight100, + ] = await Promise.all([ + createHostWithBookings({ + user: { email: "test-user1@example.com" }, + bookings: [{ eventTypeId: commonEventTypeId, createdAt: new Date() }], + createdAt: new Date(), + weight: 200, + }), + createHostWithBookings({ + user: { email: "test-user2@example.com" }, + bookings: [ + { eventTypeId: commonEventTypeId, createdAt: new Date() }, + { eventTypeId: commonEventTypeId, createdAt: new Date() }, + ], + createdAt: new Date(), + weight: 100, + }), + createHostWithBookings({ + user: { email: "test-user3@example.com" }, + bookings: [ + { eventTypeId: commonEventTypeId, createdAt: new Date() }, + { eventTypeId: commonEventTypeId, createdAt: new Date() }, + { eventTypeId: commonEventTypeId, createdAt: new Date() }, + ], + createdAt: new Date(), + weight: 100, + }), + ]); + + const userWithOneBookingAndWeight200 = hostWithOneBookingAndWeight200.user; + const userWithTwoBookingsAndWeight100 = hostWithTwoBookingsAndWeight100.user; + const userWithThreeBookingsAndWeight100 = hostWithThreeBookingsAndWeight100.user; + + const availableUsers = [ + userWithThreeBookingsAndWeight100, + userWithTwoBookingsAndWeight100, + userWithOneBookingAndWeight200, + ]; + + const getLuckUserParams = { + availableUsers, + eventType: { + id: commonEventTypeId, + isRRWeightsEnabled, + }, + allRRHosts: [ + hostWithOneBookingAndWeight200, + hostWithTwoBookingsAndWeight100, + hostWithThreeBookingsAndWeight100, + ], + }; + + const { users: luckyUsers, perUserData } = await getOrderedListOfLuckyUsers({ + ...getLuckUserParams, + availableUsers: [getLuckUserParams.availableUsers[0], ...getLuckUserParams.availableUsers.slice(1)], + }); + + expectLuckyUsers(luckyUsers, [ + // User with 1 booking is chosen first because it has higher weight and lesser bookings + userWithOneBookingAndWeight200, + // User with 2 bookings is chosen next because it has lesser bookings + userWithTwoBookingsAndWeight100, + // User with 3 bookings is chosen last because it has the most bookings + userWithThreeBookingsAndWeight100, + ]); + + if (!perUserData?.bookingShortfalls) { + throw new Error("bookingShortfalls is not defined"); + } + expect(perUserData.bookingShortfalls[userWithThreeBookingsAndWeight100.id]).toBe(-1.5); + expect(perUserData.bookingShortfalls[userWithTwoBookingsAndWeight100.id]).toBe(-0.5); + expect(perUserData.bookingShortfalls[userWithOneBookingAndWeight200.id]).toBe(2); + }); + + it("not considering bookings that were created in previous months", async () => { + const [ + hostWithOneBookingInPreviousMonthAndWeight200, + hostWithTwoBookingsInPreviousMonthAndWeight100, + hostWithThreeBookingsInPreviousMonthAndWeight100, + ] = await Promise.all([ + createHostWithBookings({ + user: { email: "test-user1@example.com" }, + bookings: [{ eventTypeId: commonEventTypeId, createdAt: new Date("2024-10-01T00:00:00.000Z") }], + weight: 200, + createdAt: new Date(), + }), + createHostWithBookings({ + user: { email: "test-user2@example.com" }, + bookings: [ + { eventTypeId: commonEventTypeId, createdAt: new Date("2024-10-01T00:00:00.000Z") }, + { eventTypeId: commonEventTypeId, createdAt: new Date("2024-10-01T00:00:00.000Z") }, + ], + weight: 100, + createdAt: new Date(), + }), + createHostWithBookings({ + user: { email: "test-user3@example.com" }, + bookings: [ + { eventTypeId: commonEventTypeId, createdAt: new Date("2024-10-01T00:00:00.000Z") }, + { eventTypeId: commonEventTypeId, createdAt: new Date("2024-10-01T00:00:00.000Z") }, + { eventTypeId: commonEventTypeId, createdAt: new Date("2024-10-01T00:00:00.000Z") }, + ], + weight: 100, + createdAt: new Date(), + }), + ]); + + const userWithOneBookingInPreviousMonthAndWeight200 = + hostWithOneBookingInPreviousMonthAndWeight200.user; + const userWithTwoBookingsInPreviousMonthAndWeight100 = + hostWithTwoBookingsInPreviousMonthAndWeight100.user; + const userWithThreeBookingsInPreviousMonthAndWeight100 = + hostWithThreeBookingsInPreviousMonthAndWeight100.user; + + const availableUsers = [ + userWithThreeBookingsInPreviousMonthAndWeight100, + userWithTwoBookingsInPreviousMonthAndWeight100, + userWithOneBookingInPreviousMonthAndWeight200, + ]; + + const getLuckUserParams = { + availableUsers, + eventType: { + id: commonEventTypeId, + isRRWeightsEnabled, + }, + allRRHosts: [ + hostWithOneBookingInPreviousMonthAndWeight200, + hostWithTwoBookingsInPreviousMonthAndWeight100, + hostWithThreeBookingsInPreviousMonthAndWeight100, + ], + }; + + const { users: luckyUsers, perUserData } = await getOrderedListOfLuckyUsers({ + ...getLuckUserParams, + availableUsers: [getLuckUserParams.availableUsers[0], ...getLuckUserParams.availableUsers.slice(1)], + }); + + expectLuckyUsers(luckyUsers, [ + // User with 1 booking is chosen first because it has higher weight and lesser bookings + userWithOneBookingInPreviousMonthAndWeight200, + // User with 3 bookings is chosen next because it comes earlier in availableUsers array + userWithThreeBookingsInPreviousMonthAndWeight100, + // User with 2 bookings is chosen last because it comes later in availableUsers array + userWithTwoBookingsInPreviousMonthAndWeight100, + ]); + + if (!perUserData?.bookingShortfalls) { + throw new Error("bookingShortfalls is not defined"); + } + + // Because no one has any bookings in the current month, the booking shortfall should be 0 for all users + expect(perUserData.bookingShortfalls[userWithThreeBookingsInPreviousMonthAndWeight100.id]).toBe(0); + expect(perUserData.bookingShortfalls[userWithTwoBookingsInPreviousMonthAndWeight100.id]).toBe(0); + expect(perUserData.bookingShortfalls[userWithOneBookingInPreviousMonthAndWeight200.id]).toBe(0); + }); + + describe("should sort as per host creation data calibration", () => { + it("not considering bookings that were created in previous months", async () => { + const today = new Date(); + const tenthOfTheMonth = new Date(today.getFullYear(), today.getMonth(), 10); + const secondsInDay = 24 * 60 * 60 * 1000; + const ninthOfTheMonth = new Date(tenthOfTheMonth.getTime() - secondsInDay); + const eighthOfTheMonth = new Date(tenthOfTheMonth.getTime() - 2 * secondsInDay); + const [host1, host2, host3] = await Promise.all([ + createHostWithBookings({ + user: { email: "test-user1@example.com" }, + bookings: [ + { eventTypeId: commonEventTypeId, createdAt: new Date(tenthOfTheMonth.getTime() + 1000) }, + ], + weight: 200, + createdAt: tenthOfTheMonth, + }), + createHostWithBookings({ + user: { email: "test-user2@example.com" }, + bookings: [ + { eventTypeId: commonEventTypeId, createdAt: new Date(ninthOfTheMonth.getTime() + 1000) }, + { eventTypeId: commonEventTypeId, createdAt: new Date(ninthOfTheMonth.getTime() + 2000) }, + ], + weight: 100, + createdAt: ninthOfTheMonth, + }), + createHostWithBookings({ + user: { email: "test-user3@example.com" }, + bookings: [ + { eventTypeId: commonEventTypeId, createdAt: new Date(eighthOfTheMonth.getTime() + 1000) }, + { eventTypeId: commonEventTypeId, createdAt: new Date(eighthOfTheMonth.getTime() + 2000) }, + { eventTypeId: commonEventTypeId, createdAt: new Date(eighthOfTheMonth.getTime() + 3000) }, + ], + weight: 100, + createdAt: eighthOfTheMonth, + }), + ]); + + const availableUsers = [host3.user, host2.user, host1.user]; + + const getLuckUserParams = { + availableUsers, + eventType: { + id: commonEventTypeId, + isRRWeightsEnabled, + }, + allRRHosts: [host1, host2, host3], + }; + + const { users: luckyUsers, perUserData } = await getOrderedListOfLuckyUsers({ + ...getLuckUserParams, + availableUsers: [getLuckUserParams.availableUsers[0], ...getLuckUserParams.availableUsers.slice(1)], + }); + + if (!perUserData?.bookingShortfalls || !perUserData?.calibrations) { + throw new Error("bookingShortfalls or calibrations is not defined"); + } + + expect(perUserData.calibrations[host1.user.id]).toBe(2.5); + expect(perUserData.calibrations[host2.user.id]).toBe(3); + expect(perUserData.calibrations[host3.user.id]).toBe(0); + + expectLuckyUsers(luckyUsers, [host1.user, host3.user, host2.user]); + }); }); }); }); diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index 20ba1c1677..f949e30f8d 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -3,12 +3,15 @@ import type { Prisma, User } from "@prisma/client"; import { getFieldResponse } from "@calcom/app-store/routing-forms/trpc/utils"; import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types/types"; import { zodRoutes, children1Schema } from "@calcom/app-store/routing-forms/zod"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { BookingRepository } from "@calcom/lib/server/repository/booking"; import prisma from "@calcom/prisma"; import type { Booking } from "@calcom/prisma/client"; import type { AttributeType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; +const log = logger.getSubLogger({ prefix: ["getLuckyUser"] }); async function getAttributesQueryValue() { const { getAttributesQueryValue } = (await import("@calcom/app-store/routing-forms/lib/raqbUtils")) .acrossQueryValueCompatiblity; @@ -20,6 +23,14 @@ type PartialBooking = Pick & }; type PartialUser = Pick; +type RoutingFormResponse = { + response: Prisma.JsonValue; + chosenRouteId: string | null; + form: { + fields: Prisma.JsonValue; + routes: Prisma.JsonValue; + }; +}; type AttributeWithWeights = { name: string; @@ -56,58 +67,22 @@ interface GetLuckyUserParams { createdAt: Date; weight?: number | null; }[]; - routingFormResponse?: RoutingFormResponse | null; + routingFormResponse: RoutingFormResponse | null; } // === dayjs.utc().startOf("month").toDate(); const startOfMonth = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1)); + // TS helper function. const isNonEmptyArray = (arr: T[]): arr is [T, ...T[]] => arr.length > 0; -async function leastRecentlyBookedUser({ +function leastRecentlyBookedUser({ availableUsers, - eventType, bookingsOfAvailableUsers, -}: GetLuckyUserParams & { bookingsOfAvailableUsers: PartialBooking[] }) { - // First we get all organizers (fixed host/single round robin user) - const organizersWithLastCreated = await prisma.user.findMany({ - where: { - id: { - in: availableUsers.map((user) => user.id), - }, - }, - select: { - id: true, - bookings: { - select: { - createdAt: true, - }, - where: { - eventTypeId: eventType.id, - status: BookingStatus.ACCEPTED, - attendees: { - some: { - noShow: false, - }, - }, - // not:true won't match null, thus we need to do an OR with null case separately(for bookings that might have null value for `noShowHost` as earlier it didn't have default false) - // https://github.com/calcom/cal.com/pull/15323#discussion_r1687728207 - OR: [ - { - noShowHost: false, - }, - { - noShowHost: null, - }, - ], - }, - orderBy: { - createdAt: "desc", - }, - take: 1, - }, - }, - }); - + organizersWithLastCreated, +}: GetLuckyUserParams & { + bookingsOfAvailableUsers: PartialBooking[]; + organizersWithLastCreated: { id: number; bookings: { createdAt: Date }[] }[]; +}) { const organizerIdAndAtCreatedPair = organizersWithLastCreated.reduce( (keyValuePair: { [userId: number]: Date }, user) => { keyValuePair[user.id] = user.bookings[0]?.createdAt || new Date(0); @@ -135,6 +110,15 @@ async function leastRecentlyBookedUser({ ...attendeeUserIdAndAtCreatedPair, }; + log.info( + "userIdAndAtCreatedPair", + safeStringify({ + organizerIdAndAtCreatedPair, + attendeeUserIdAndAtCreatedPair, + userIdAndAtCreatedPair, + }) + ); + if (!userIdAndAtCreatedPair) { throw new Error("Unable to find users by availableUser ids."); // should never happen. } @@ -143,45 +127,28 @@ async function leastRecentlyBookedUser({ if (userIdAndAtCreatedPair[a.id] > userIdAndAtCreatedPair[b.id]) return 1; else if (userIdAndAtCreatedPair[a.id] < userIdAndAtCreatedPair[b.id]) return -1; // if two (or more) dates are identical, we randomize the order - else return Math.random() > 0.5 ? 1 : -1; + else return 0; })[0]; return leastRecentlyBookedUser; } -async function getHostsWithCalibration( - eventTypeId: number, - hosts: { userId: number; email: string; createdAt: Date }[], - virtualQueuesData?: VirtualQueuesDataType -) { - const [newHostsArray, existingBookings] = await Promise.all([ - prisma.host.findMany({ - where: { - userId: { - in: hosts.map((host) => host.userId), - }, - eventTypeId, - isFixed: false, - createdAt: { - gte: startOfMonth, - }, - }, - }), - BookingRepository.getAllBookingsForRoundRobin({ - eventTypeId, - users: hosts.map((host) => ({ - id: host.userId, - email: host.email, - })), - startDate: startOfMonth, - endDate: new Date(), - virtualQueuesData, - }), - ]); +function getHostsWithCalibration({ + hosts, + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, +}: { + hosts: { userId: number; email: string; createdAt: Date }[]; + allRRHostsBookingsOfThisMonth: PartialBooking[]; + allRRHostsCreatedThisMonth: { userId: number; createdAt: Date }[]; +}) { + const existingBookings = allRRHostsBookingsOfThisMonth; + // Return early if there are no new hosts or no existing bookings - if (newHostsArray.length === 0 || existingBookings.length === 0) { + if (allRRHostsCreatedThisMonth.length === 0 || existingBookings.length === 0) { return hosts.map((host) => ({ ...host, calibration: 0 })); } + // Helper function to calculate calibration for a new host function calculateCalibration(newHost: { userId: number; createdAt: Date }) { const existingBookingsBeforeAdded = existingBookings.filter( @@ -190,13 +157,25 @@ async function getHostsWithCalibration( const hostsAddedBefore = hosts.filter( (host) => host.userId !== newHost.userId && host.createdAt < newHost.createdAt ); - return existingBookingsBeforeAdded.length && hostsAddedBefore.length - ? existingBookingsBeforeAdded.length / hostsAddedBefore.length - : 0; + + const calibration = + existingBookingsBeforeAdded.length && hostsAddedBefore.length + ? existingBookingsBeforeAdded.length / hostsAddedBefore.length + : 0; + log.debug( + "calculateCalibration", + safeStringify({ + newHost, + existingBookingsBeforeAdded: existingBookingsBeforeAdded.length, + hostsAddedBefore: hostsAddedBefore.length, + calibration, + }) + ); + return calibration; } // Calculate calibration for each new host and store in a Map const newHostsWithCalibration = new Map( - newHostsArray.map((newHost) => [ + allRRHostsCreatedThisMonth.map((newHost) => [ newHost.userId, { ...newHost, calibration: calculateCalibration(newHost) }, ]) @@ -220,68 +199,40 @@ function getUsersWithHighestPriority user.id), + }) + ); return usersWithHighestPriority; } -async function filterUsersBasedOnWeights< +function filterUsersBasedOnWeights< T extends PartialUser & { weight?: number | null; } >({ availableUsers, - bookingsOfAvailableUsers, + currentMonthBookingsOfAvailableUsers, + bookingsOfNotAvailableUsersOfThisMonth, allRRHosts, - eventType, - virtualQueuesData, + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, attributeWeights, -}: GetLuckyUserParams & { - bookingsOfAvailableUsers: PartialBooking[]; - virtualQueuesData?: VirtualQueuesDataType; - attributeWeights?: { - userId: number; - weight: number; - }[]; -}): Promise<[T, ...T[]]> { +}: GetLuckyUserParams & FetchedData) { //get all bookings of all other RR hosts that are not available - const availableUserIds = new Set(availableUsers.map((user) => user.id)); - const notAvailableHosts = allRRHosts.reduce( - ( - acc: { - id: number; - email: string; - }[], - host - ) => { - if (!availableUserIds.has(host.user.id)) { - acc.push({ - id: host.user.id, - email: host.user.email, - }); - } - return acc; - }, - [] - ); + const allBookings = currentMonthBookingsOfAvailableUsers.concat(bookingsOfNotAvailableUsersOfThisMonth); - //only get bookings where response matches the virtual queue - const bookingsOfNotAvailableUsers = await BookingRepository.getAllBookingsForRoundRobin({ - eventTypeId: eventType.id, - users: notAvailableHosts, - startDate: startOfMonth, - endDate: new Date(), - virtualQueuesData, - }); - - const allBookings = bookingsOfAvailableUsers.concat(bookingsOfNotAvailableUsers); - - const allHostsWithCalibration = await getHostsWithCalibration( - eventType.id, - allRRHosts.map((host) => { + const allHostsWithCalibration = getHostsWithCalibration({ + hosts: allRRHosts.map((host) => { return { email: host.user.email, userId: host.user.id, createdAt: host.createdAt }; }), - virtualQueuesData - ); + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, + }); // Calculate the total calibration and weight of all round-robin hosts let totalWeight: number; @@ -306,14 +257,11 @@ async function filterUsersBasedOnWeights< // Calculate booking shortfall for each available user const usersWithBookingShortfalls = availableUsers.map((user) => { let userWeight = user.weight ?? 100; - if (attributeWeights) { userWeight = attributeWeights.find((userWeight) => userWeight.userId === user.id)?.weight ?? 100; } - const targetPercentage = userWeight / totalWeight; - - const userBookings = bookingsOfAvailableUsers.filter( + const userBookings = currentMonthBookingsOfAvailableUsers.filter( (booking) => booking.userId === user.id || booking.attendees.some((attendee) => attendee.email === user.email) ); @@ -325,7 +273,11 @@ async function filterUsersBasedOnWeights< return { ...user, + calibration: userCalibration, + weight: userWeight, + targetNumberOfBookings, bookingShortfall, + numBookings: userBookings.length, }; }); @@ -339,15 +291,65 @@ async function filterUsersBasedOnWeights< const maxWeight = Math.max(...usersWithMaxShortfall.map((user) => user.weight ?? 100)); const userIdsWithMaxShortfallAndWeight = new Set( - usersWithMaxShortfall.filter((user) => user.weight === maxWeight).map((user) => user.id) + usersWithMaxShortfall + .filter((user) => { + const weight = user.weight ?? 100; + return weight === maxWeight; + }) + .map((user) => user.id) ); + const remainingUsersAfterWeightFilter = availableUsers.filter((user) => userIdsWithMaxShortfallAndWeight.has(user.id) ); + + log.debug( + "filterUsersBasedOnWeights", + safeStringify({ + userIdsWithMaxShortfallAndWeight: userIdsWithMaxShortfallAndWeight, + usersWithMaxShortfall: usersWithMaxShortfall.map((user) => user.email), + usersWithBookingShortfalls: usersWithBookingShortfalls.map((user) => ({ + calibration: user.calibration, + bookingShortfall: user.bookingShortfall, + email: user.email, + targetNumberOfBookings: user.targetNumberOfBookings, + weight: user.weight, + numBookings: user.numBookings, + })), + remainingUsersAfterWeightFilter: remainingUsersAfterWeightFilter.map((user) => user.email), + }) + ); + if (!isNonEmptyArray(remainingUsersAfterWeightFilter)) { throw new Error("Internal Error: Weight filter should never return length=0."); } - return remainingUsersAfterWeightFilter; + return { + remainingUsersAfterWeightFilter, + usersAndTheirBookingShortfalls: usersWithBookingShortfalls.map((user) => ({ + id: user.id, + calibration: user.calibration, + bookingShortfall: user.bookingShortfall, + weight: user.weight, + })), + }; +} + +async function getCurrentMonthsBookings({ + eventTypeId, + users, + virtualQueuesData, +}: { + eventTypeId: number; + users: { id: number; email: string }[]; + virtualQueuesData: VirtualQueuesDataType | null; +}) { + return await BookingRepository.getAllBookingsForRoundRobin({ + eventTypeId: eventTypeId, + users, + startDate: startOfMonth, + endDate: new Date(), + virtualQueuesData, + }); } export async function getLuckyUser< @@ -355,16 +357,356 @@ export async function getLuckyUser< priority?: number | null; weight?: number | null; } ->({ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams) { - const { attributeWeights, virtualQueuesData } = await prepareQueuesAndAttributesData(getLuckyUserParams); - return _getLuckyUser( - { +>(getLuckyUserParams: GetLuckyUserParams) { + const { + currentMonthBookingsOfAvailableUsers, + bookingsOfNotAvailableUsersOfThisMonth, + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, + organizersWithLastCreated, + attributeWeights, + virtualQueuesData, + } = await fetchAllDataNeededForCalculations(getLuckyUserParams); + + const { luckyUser } = getLuckyUser_requiresDataToBePreFetched({ + ...getLuckyUserParams, + currentMonthBookingsOfAvailableUsers, + bookingsOfNotAvailableUsersOfThisMonth, + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, + organizersWithLastCreated, + attributeWeights, + virtualQueuesData, + }); + + return luckyUser; +} + +type FetchedData = { + bookingsOfNotAvailableUsersOfThisMonth: PartialBooking[]; + currentMonthBookingsOfAvailableUsers: PartialBooking[]; + allRRHostsBookingsOfThisMonth: PartialBooking[]; + allRRHostsCreatedThisMonth: { userId: number; createdAt: Date }[]; + organizersWithLastCreated: { id: number; bookings: { createdAt: Date }[] }[]; + attributeWeights?: + | { + userId: number; + weight: number; + }[] + | null; + virtualQueuesData?: VirtualQueuesDataType | null; +}; + +export function getLuckyUser_requiresDataToBePreFetched< + T extends PartialUser & { + priority?: number | null; + weight?: number | null; + } +>({ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams & FetchedData) { + const { + eventType, + currentMonthBookingsOfAvailableUsers, + bookingsOfNotAvailableUsersOfThisMonth, + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, + organizersWithLastCreated, + } = getLuckyUserParams; + + // there is only one user + if (availableUsers.length === 1) { + return { luckyUser: availableUsers[0], usersAndTheirBookingShortfalls: [] }; + } + + let usersAndTheirBookingShortfalls: { + id: number; + bookingShortfall: number; + calibration: number; + weight: number; + }[] = []; + if (eventType.isRRWeightsEnabled) { + const { + remainingUsersAfterWeightFilter, + usersAndTheirBookingShortfalls: _usersAndTheirBookingShortfalls, + } = filterUsersBasedOnWeights({ ...getLuckyUserParams, availableUsers, - }, - attributeWeights, - virtualQueuesData + currentMonthBookingsOfAvailableUsers, + bookingsOfNotAvailableUsersOfThisMonth, + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, + }); + availableUsers = remainingUsersAfterWeightFilter; + usersAndTheirBookingShortfalls = _usersAndTheirBookingShortfalls; + } + + const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers }); + // No need to round-robin through the only user, return early also. + if (highestPriorityUsers.length === 1) { + return { + luckyUser: highestPriorityUsers[0], + usersAndTheirBookingShortfalls, + }; + } + // TS is happy. + return { + luckyUser: leastRecentlyBookedUser({ + ...getLuckyUserParams, + availableUsers: highestPriorityUsers, + bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers, + organizersWithLastCreated, + }), + usersAndTheirBookingShortfalls, + }; +} + +async function fetchAllDataNeededForCalculations< + T extends PartialUser & { + priority?: number | null; + weight?: number | null; + } +>(getLuckyUserParams: GetLuckyUserParams) { + const startTime = performance.now(); + + const { availableUsers, allRRHosts, eventType } = getLuckyUserParams; + const notAvailableHosts = (function getNotAvailableHosts() { + const availableUserIds = new Set(availableUsers.map((user) => user.id)); + return allRRHosts.reduce( + ( + acc: { + id: number; + email: string; + }[], + host + ) => { + if (!availableUserIds.has(host.user.id)) { + acc.push({ + id: host.user.id, + email: host.user.email, + }); + } + return acc; + }, + [] + ); + })(); + + const { attributeWeights, virtualQueuesData } = await prepareQueuesAndAttributesData(getLuckyUserParams); + + const [ + currentMonthBookingsOfAvailableUsers, + bookingsOfNotAvailableUsersOfThisMonth, + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, + organizersWithLastCreated, + ] = await Promise.all([ + getCurrentMonthsBookings({ + eventTypeId: eventType.id, + users: availableUsers.map((user) => { + return { id: user.id, email: user.email }; + }), + virtualQueuesData: virtualQueuesData ?? null, + }), + + getCurrentMonthsBookings({ + eventTypeId: eventType.id, + users: notAvailableHosts, + virtualQueuesData: virtualQueuesData ?? null, + }), + + getCurrentMonthsBookings({ + eventTypeId: eventType.id, + users: allRRHosts.map((host) => { + return { id: host.user.id, email: host.user.email }; + }), + virtualQueuesData: virtualQueuesData ?? null, + }), + + prisma.host.findMany({ + where: { + userId: { + in: allRRHosts.map((host) => host.user.id), + }, + eventTypeId: eventType.id, + isFixed: false, + createdAt: { + gte: startOfMonth, + }, + }, + }), + + prisma.user.findMany({ + where: { + id: { + in: availableUsers.map((user) => user.id), + }, + }, + select: { + id: true, + bookings: { + select: { + createdAt: true, + }, + where: { + eventTypeId: eventType.id, + status: BookingStatus.ACCEPTED, + attendees: { + some: { + noShow: false, + }, + }, + // not:true won't match null, thus we need to do an OR with null case separately(for bookings that might have null value for `noShowHost` as earlier it didn't have default false) + // https://github.com/calcom/cal.com/pull/15323#discussion_r1687728207 + OR: [ + { + noShowHost: false, + }, + { + noShowHost: null, + }, + ], + }, + orderBy: { + createdAt: "desc", + }, + take: 1, + }, + }, + }), + ]); + + const endTime = performance.now(); + log.info(`fetchAllDataNeededForCalculations took ${endTime - startTime}ms`); + + log.debug( + "fetchAllDataNeededForCalculations", + safeStringify({ + currentMonthBookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers.length, + bookingsOfNotAvailableUsersOfThisMonth: bookingsOfNotAvailableUsersOfThisMonth.length, + allRRHostsBookingsOfThisMonth: allRRHostsBookingsOfThisMonth.length, + allRRHostsCreatedThisMonth: allRRHostsCreatedThisMonth.length, + virtualQueuesData, + attributeWeights, + }) ); + + return { + currentMonthBookingsOfAvailableUsers, + bookingsOfNotAvailableUsersOfThisMonth, + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, + organizersWithLastCreated, + attributeWeights, + virtualQueuesData, + }; +} + +type AvailableUserBase = PartialUser & { + priority: number | null; + weight: number | null; +}; + +export async function getOrderedListOfLuckyUsers( + getLuckyUserParams: GetLuckyUserParams +) { + const { availableUsers, eventType } = getLuckyUserParams; + + const { + currentMonthBookingsOfAvailableUsers, + bookingsOfNotAvailableUsersOfThisMonth, + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, + organizersWithLastCreated, + attributeWeights, + virtualQueuesData, + } = await fetchAllDataNeededForCalculations(getLuckyUserParams); + + log.info( + "getOrderedListOfLuckyUsers", + safeStringify({ + availableUsers: availableUsers.map((user) => { + return { id: user.id, email: user.email, priority: user.priority, weight: user.weight }; + }), + currentMonthBookingsOfAvailableUsers, + bookingsOfNotAvailableUsersOfThisMonth, + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, + organizersWithLastCreated, + }) + ); + + let remainingAvailableUsers = [...availableUsers]; + let currentMonthBookingsOfRemainingAvailableUsers = [...currentMonthBookingsOfAvailableUsers]; + const orderedUsersSet = new Set(); + const perUserBookingsCount: Record = {}; + + const startTime = performance.now(); + let usersAndTheirBookingShortfalls: { + id: number; + bookingShortfall: number; + calibration: number; + weight: number; + }[] = []; + // Keep getting lucky users until none remain + while (remainingAvailableUsers.length > 0) { + const { luckyUser, usersAndTheirBookingShortfalls: _usersAndTheirBookingShortfalls } = + getLuckyUser_requiresDataToBePreFetched({ + ...getLuckyUserParams, + eventType, + availableUsers: remainingAvailableUsers as [AvailableUser, ...AvailableUser[]], + currentMonthBookingsOfAvailableUsers: currentMonthBookingsOfRemainingAvailableUsers, + bookingsOfNotAvailableUsersOfThisMonth, + allRRHostsBookingsOfThisMonth, + allRRHostsCreatedThisMonth, + organizersWithLastCreated, + attributeWeights, + virtualQueuesData, + }); + + if (!usersAndTheirBookingShortfalls.length) { + usersAndTheirBookingShortfalls = _usersAndTheirBookingShortfalls; + } + + if (orderedUsersSet.has(luckyUser)) { + // It is helpful in breaking the loop as same user is returned again and again. + // Also, it tells a bug in the code. + throw new Error( + `Error building ordered list of lucky users. The lucky user ${luckyUser.email} is already in the set.` + ); + } + + orderedUsersSet.add(luckyUser); + perUserBookingsCount[luckyUser.id] = currentMonthBookingsOfAvailableUsers.filter( + (booking) => booking.userId === luckyUser.id + ).length; + remainingAvailableUsers = remainingAvailableUsers.filter((user) => user.id !== luckyUser.id); + currentMonthBookingsOfRemainingAvailableUsers = currentMonthBookingsOfRemainingAvailableUsers.filter( + (booking) => remainingAvailableUsers.map((user) => user.id).includes(booking.userId ?? 0) + ); + } + + const endTime = performance.now(); + log.info(`getOrderedListOfLuckyUsers took ${endTime - startTime}ms`); + + const bookingShortfalls: Record = {}; + const calibrations: Record = {}; + const weights: Record = {}; + + usersAndTheirBookingShortfalls.forEach((user) => { + bookingShortfalls[user.id] = parseFloat(user.bookingShortfall.toFixed(2)); + calibrations[user.id] = parseFloat(user.calibration.toFixed(2)); + weights[user.id] = user.weight; + }); + + return { + users: Array.from(orderedUsersSet), + isUsingAttributeWeights: !!attributeWeights && !!virtualQueuesData, + perUserData: { + bookingsCount: perUserBookingsCount, + bookingShortfalls: eventType.isRRWeightsEnabled ? bookingShortfalls : null, + calibrations: eventType.isRRWeightsEnabled ? calibrations : null, + weights: eventType.isRRWeightsEnabled ? weights : null, + }, + }; } export async function prepareQueuesAndAttributesData({ @@ -374,11 +716,12 @@ export async function prepareQueuesAndAttributesData({ }: Omit, "availableUsers">) { let attributeWeights; let virtualQueuesData; - - if (routingFormResponse && eventType.team?.parentId) { + const organizationId = eventType.team?.parentId; + log.debug("prepareQueuesAndAttributesData", safeStringify({ routingFormResponse, organizationId })); + if (routingFormResponse && organizationId) { const attributeWithEnabledWeights = await prisma.attribute.findFirst({ where: { - teamId: eventType.team?.parentId, + teamId: organizationId, isWeightsEnabled: true, }, select: { @@ -414,7 +757,7 @@ export async function prepareQueuesAndAttributesData({ attributeWithEnabledWeights ); - console.log(`attributeWithEnabledWeights ${JSON.stringify(attributeWithEnabledWeights)}`); + log.debug(`attributeWithEnabledWeights ${safeStringify(attributeWithEnabledWeights)}`); if (queueAndAtributeWeightData?.averageWeightsHosts && queueAndAtributeWeightData?.virtualQueuesData) { attributeWeights = queueAndAtributeWeightData?.averageWeightsHosts; @@ -528,10 +871,9 @@ function getAverageAttributeWeights< ); allRRHosts.forEach((rrHost) => { - const weight = - attributeOptionWithUsers?.assignedUsers.find( - (assignedUser) => rrHost.user.id === assignedUser.member.userId - )?.weight ?? 100; + const weight = attributeOptionWithUsers?.assignedUsers.find( + (assignedUser) => rrHost.user.id === assignedUser.member.userId + )?.weight; if (weight) { if (allRRHostsWeights.has(rrHost.user.id)) { @@ -554,7 +896,10 @@ function getAverageAttributeWeights< }); } }); - + log.debug( + "getAverageAttributeWeights", + safeStringify({ allRRHosts, attributesQueryValueChild, attributeWithWeights, averageWeightsHosts }) + ); return averageWeightsHosts; } @@ -604,61 +949,3 @@ function getAttributesForVirtualQueues( }); return selectionOptions; } - -type RoutingFormResponse = { - response: Prisma.JsonValue; - chosenRouteId: string | null; - form: { - fields: Prisma.JsonValue; - routes: Prisma.JsonValue; - }; -}; - -async function _getLuckyUser< - T extends PartialUser & { - priority?: number | null; - weight?: number | null; - } ->( - { availableUsers, ...getLuckyUserParams }: GetLuckyUserParams, - attributeWeights?: { - userId: number; - weight: number; - }[], - virtualQueuesData?: VirtualQueuesDataType -) { - //maybe pass response directly not id - const { eventType } = getLuckyUserParams; - - // there is only one user - if (availableUsers.length === 1) { - return availableUsers[0]; - } - const currentMonthBookingsOfAvailableUsers = await BookingRepository.getAllBookingsForRoundRobin({ - eventTypeId: eventType.id, - users: availableUsers.map((user) => { - return { id: user.id, email: user.email }; - }), - startDate: startOfMonth, - endDate: new Date(), - virtualQueuesData, - }); - if (eventType.isRRWeightsEnabled) { - availableUsers = await filterUsersBasedOnWeights({ - ...getLuckyUserParams, - availableUsers, - bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers, - virtualQueuesData, - attributeWeights, - }); - } - const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers }); - // No need to round-robin through the only user, return early also. - if (highestPriorityUsers.length === 1) return highestPriorityUsers[0]; - // TS is happy. - return leastRecentlyBookedUser({ - ...getLuckyUserParams, - availableUsers: highestPriorityUsers, - bookingsOfAvailableUsers: currentMonthBookingsOfAvailableUsers, - }); -} diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 08d638d22f..f345d59d72 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -34,13 +34,13 @@ const buildWhereClauseForActiveBookings = ({ startDate?: Date; endDate?: Date; users: { id: number; email: string }[]; - virtualQueuesData?: { + virtualQueuesData: { chosenRouteId: string; fieldOptionData: { fieldId: string; selectedOptionIds: string | number | string[]; }; - }; + } | null; }): Prisma.BookingWhereInput => ({ OR: [ { @@ -155,6 +155,7 @@ export class BookingRepository { users, eventTypeId, startDate, + virtualQueuesData: null, }), _count: { _all: true, @@ -173,13 +174,13 @@ export class BookingRepository { eventTypeId: number; startDate?: Date; endDate?: Date; - virtualQueuesData?: { + virtualQueuesData: { chosenRouteId: string; fieldOptionData: { fieldId: string; selectedOptionIds: string | number | string[]; }; - }; + } | null; }) { const allBookings = await prisma.booking.findMany({ where: buildWhereClauseForActiveBookings({ @@ -226,6 +227,7 @@ export class BookingRepository { } }); } + console.log(`queueBookings ${JSON.stringify(queueBookings.map((booking) => booking.id))}`); return queueBookings; } diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index e2992a806d..6e124206df 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -648,6 +648,43 @@ export class EventTypeRepository { }); } + static async findByIdMinimal({ id }: { id: number }) { + return await prisma.eventType.findUnique({ + where: { + id, + }, + }); + } + + static async findByIdIncludeHostsAndTeam({ id }: { id: number }) { + return await prisma.eventType.findUnique({ + where: { + id, + }, + include: { + hosts: { + select: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + weight: true, + priority: true, + createdAt: true, + }, + }, + team: { + select: { + parentId: true, + }, + }, + }, + }); + } + static async findAllByTeamIdIncludeManagedEventTypes({ teamId }: { teamId?: number }) { return await prisma.eventType.findMany({ where: { diff --git a/packages/trpc/react/shared.ts b/packages/trpc/react/shared.ts index a7c1bf6dcf..7577b47e1e 100644 --- a/packages/trpc/react/shared.ts +++ b/packages/trpc/react/shared.ts @@ -30,4 +30,5 @@ export const ENDPOINTS = [ "googleWorkspace", "oAuth", "attributes", + "routingForms", ] as const; diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index 22e4cc5e1c..fb9ea91662 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -23,6 +23,7 @@ import { highPerfRouter } from "./highPerf/_router"; import { oAuthRouter } from "./oAuth/_router"; import { viewerOrganizationsRouter } from "./organizations/_router"; import { paymentsRouter } from "./payments/_router"; +import { routingFormsRouter } from "./routing-forms/_router"; import { slotsRouter } from "./slots/_router"; import { ssoRouter } from "./sso/_router"; import { viewerTeamsRouter } from "./teams/_router"; @@ -63,5 +64,6 @@ export const viewerRouter = mergeRouters( admin: adminRouter, attributes: attributesRouter, highPerf: highPerfRouter, + routingForms: routingFormsRouter, }) ); diff --git a/packages/trpc/server/routers/viewer/routing-forms/_router.ts b/packages/trpc/server/routers/viewer/routing-forms/_router.ts new file mode 100644 index 0000000000..41f2f47093 --- /dev/null +++ b/packages/trpc/server/routers/viewer/routing-forms/_router.ts @@ -0,0 +1,19 @@ +import authedProcedure from "../../../procedures/authedProcedure"; +import { router, importHandler } from "../../../trpc"; +import { ZFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema"; + +const NAMESPACE = "routingForms"; + +const namespaced = (s: string) => `${NAMESPACE}.${s}`; + +export const routingFormsRouter = router({ + findTeamMembersMatchingAttributeLogic: authedProcedure + .input(ZFindTeamMembersMatchingAttributeLogicInputSchema) + .mutation(async ({ ctx, input }) => { + const handler = await importHandler( + namespaced("findTeamMembersMatchingAttributeLogic"), + () => import("./findTeamMembersMatchingAttributeLogic.handler") + ); + return handler({ ctx, input }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.handler.ts b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.handler.ts new file mode 100644 index 0000000000..f89e9026ab --- /dev/null +++ b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.handler.ts @@ -0,0 +1,324 @@ +/** + * This route is used only by "Test Preview" button + * Live mode uses findTeamMembersMatchingAttributeLogicOfRoute fn directly + */ +import type { App_RoutingForms_Form } from "@prisma/client"; +import type { ServerResponse } from "http"; +import type { NextApiResponse } from "next"; + +import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData"; +import { getUrlSearchParamsToForwardForTestPreview } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward"; +import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils"; +import { fromEntriesWithDuplicateKeys } from "@calcom/lib/fromEntriesWithDuplicateKeys"; +import { getOrderedListOfLuckyUsers } from "@calcom/lib/server/getLuckyUser"; +import { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; +import { UserRepository } from "@calcom/lib/server/repository/user"; +import type { PrismaClient } from "@calcom/prisma"; +import { getAbsoluteEventTypeRedirectUrl } from "@calcom/routing-forms/getEventTypeRedirectUrl"; +import { findTeamMembersMatchingAttributeLogicOfRoute } from "@calcom/routing-forms/lib/findTeamMembersMatchingAttributeLogicOfRoute"; +import { getSerializableForm } from "@calcom/routing-forms/lib/getSerializableForm"; +import isRouter from "@calcom/routing-forms/lib/isRouter"; +import { RouteActionType } from "@calcom/routing-forms/zod"; +import { TRPCError } from "@calcom/trpc/server"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema"; + +interface FindTeamMembersMatchingAttributeLogicHandlerOptions { + ctx: { + prisma: PrismaClient; + user: NonNullable; + res: ServerResponse | NextApiResponse | undefined; + }; + input: TFindTeamMembersMatchingAttributeLogicInputSchema; +} + +async function getEnrichedSerializableForm< + TForm extends App_RoutingForms_Form & { + user: { + id: number; + username: string | null; + movedToProfileId: number | null; + }; + team: { + parent: { + slug: string | null; + } | null; + metadata: unknown; + } | null; + } +>(form: TForm) { + const formWithUserInfoProfile = { + ...form, + user: await UserRepository.enrichUserWithItsProfile({ user: form.user }), + }; + + const serializableForm = await getSerializableForm({ + form: enrichFormWithMigrationData(formWithUserInfoProfile), + }); + + return serializableForm; +} + +export const findTeamMembersMatchingAttributeLogicHandler = async ({ + ctx, + input, +}: FindTeamMembersMatchingAttributeLogicHandlerOptions) => { + const { prisma, user } = ctx; + const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import( + "@calcom/web/lib/getTeamMemberEmailFromCrm" + ); + + const { formId, response, route, isPreview, _enablePerf, _concurrency } = input; + + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: formId, + ...entityPrismaWhereClause({ userId: user.id }), + }, + include: { + team: { + select: { + parentId: true, + parent: { + select: { + slug: true, + }, + }, + metadata: true, + }, + }, + user: { + select: { + id: true, + username: true, + movedToProfileId: true, + }, + }, + }, + }); + + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Form not found", + }); + } + + if (!form.teamId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This form is not associated with a team", + }); + } + + const beforeEnrichedForm = performance.now(); + const serializableForm = await getEnrichedSerializableForm(form); + const afterEnrichedForm = performance.now(); + const timeTakenToEnrichForm = afterEnrichedForm - beforeEnrichedForm; + + if (!serializableForm.fields) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Form fields not found", + }); + } + + if (!route) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Route not found", + }); + } + + if (isRouter(route)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This route is a global router which is not supported", + }); + } + + if (route.action.type !== RouteActionType.EventTypeRedirectUrl) { + return { + troubleshooter: null, + result: null, + contactOwnerEmail: null, + checkedFallback: false, + mainWarnings: [], + fallbackWarnings: [], + eventTypeRedirectUrl: null, + isUsingAttributeWeights: false, + }; + } + + const eventTypeId = route.action.eventTypeId; + // e.g. /team/abc/team-event-type + const eventTypeRedirectPath = route.action.value; + + if (!eventTypeId) { + // If it ever happens, should automatically be fixed by saving the form again from route-builder. + // Legacy route actions do not have eventTypeId. + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The route action is missing eventTypeId.", + }); + } + + const eventType = await EventTypeRepository.findByIdIncludeHostsAndTeam({ id: eventTypeId }); + + if (!eventType) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Event type not found", + }); + } + + const { + teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult, + timeTaken: teamMembersMatchingAttributeLogicTimeTaken, + troubleshooter, + checkedFallback, + mainAttributeLogicBuildingWarnings: mainWarnings, + fallbackAttributeLogicBuildingWarnings: fallbackWarnings, + } = await findTeamMembersMatchingAttributeLogicOfRoute( + { + response, + route, + form: serializableForm, + teamId: form.teamId, + isPreview: !!isPreview, + }, + { + enablePerf: _enablePerf, + // Reuse same flag for enabling troubleshooter. We would normall use them together + enableTroubleshooter: _enablePerf, + concurrency: _concurrency, + } + ); + + const urlSearchParamsToForward = getUrlSearchParamsToForwardForTestPreview({ + formResponse: response, + fields: serializableForm.fields, + attributeRoutingConfig: route.attributeRoutingConfig ?? null, + teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult + ? matchingTeamMembersWithResult.map((member) => member.userId) + : [], + }); + + const eventTypeRedirectUrl = getAbsoluteEventTypeRedirectUrl({ + eventTypeRedirectUrl: eventTypeRedirectPath, + form: serializableForm, + allURLSearchParams: urlSearchParamsToForward, + }); + + const timeBeforeCrm = performance.now(); + const contactOwnerEmail = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: fromEntriesWithDuplicateKeys(urlSearchParamsToForward.entries()), + eventTypeId: eventType.id, + eventData: eventType, + chosenRoute: route, + }); + const timeAfterCrm = performance.now(); + + const timeTaken: Record = { + ...teamMembersMatchingAttributeLogicTimeTaken, + crm: timeAfterCrm - timeBeforeCrm, + enrichForm: timeTakenToEnrichForm, + }; + + if (!matchingTeamMembersWithResult) { + return { + contactOwnerEmail, + troubleshooter, + checkedFallback, + mainWarnings, + fallbackWarnings, + eventTypeRedirectUrl, + isUsingAttributeWeights: false, + result: null, + }; + } + + const matchingTeamMembersIds = matchingTeamMembersWithResult.map((member) => member.userId); + const matchingTeamMembers = await UserRepository.findByIds({ ids: matchingTeamMembersIds }); + const matchingHosts = eventType.hosts.filter((host) => matchingTeamMembersIds.includes(host.user.id)); + + if (matchingTeamMembers.length !== matchingHosts.length) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Looks like not all matching team members are assigned to the event", + }); + } + + const timeBeforeGetOrderedLuckyUsers = performance.now(); + const { + users: orderedLuckyUsers, + perUserData, + isUsingAttributeWeights, + } = matchingTeamMembers.length + ? await getOrderedListOfLuckyUsers({ + // Assuming all are available + availableUsers: [ + { + ...matchingHosts[0].user, + weight: matchingHosts[0].weight, + priority: matchingHosts[0].priority, + }, + ...matchingHosts.slice(1).map((host) => ({ + ...host.user, + weight: host.weight, + priority: host.priority, + })), + ], + eventType, + allRRHosts: matchingHosts, + routingFormResponse: { + response, + form, + chosenRouteId: route.id, + }, + }) + : { users: [], perUserData: null, isUsingAttributeWeights: false }; + const timeAfterGetOrderedLuckyUsers = performance.now(); + timeTaken.getOrderedLuckyUsers = timeAfterGetOrderedLuckyUsers - timeBeforeGetOrderedLuckyUsers; + + console.log("_enablePerf, _concurrency", _enablePerf, _concurrency); + if (_enablePerf) { + const serverTimingHeader = getServerTimingHeader(timeTaken); + ctx.res?.setHeader("Server-Timing", serverTimingHeader); + console.log("Server-Timing", serverTimingHeader); + } + + return { + troubleshooter, + contactOwnerEmail, + checkedFallback, + mainWarnings, + fallbackWarnings, + result: { + users: orderedLuckyUsers.map((user) => ({ + id: user.id, + name: user.name, + email: user.email, + })), + perUserData, + }, + isUsingAttributeWeights, + eventTypeRedirectUrl, + }; +}; + +function getServerTimingHeader(timeTaken: Record) { + const headerParts = Object.entries(timeTaken) + .map(([key, value]) => { + if (value !== null && value !== undefined) { + return `${key};dur=${value}`; + } + return null; + }) + .filter(Boolean); + + return headerParts.join(", "); +} + +export default findTeamMembersMatchingAttributeLogicHandler; diff --git a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.schema.ts similarity index 66% rename from packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts rename to packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.schema.ts index 6c4f16d21b..2df3cca2ec 100644 --- a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts +++ b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.schema.ts @@ -1,10 +1,15 @@ import { z } from "zod"; -import { zodNonRouterRoute } from "../zod"; +import { zodNonRouterRoute } from "@calcom/routing-forms/zod"; export const ZFindTeamMembersMatchingAttributeLogicInputSchema = z.object({ formId: z.string(), - response: z.record(z.string(), z.any()), + response: z.record( + z.object({ + label: z.string(), + value: z.union([z.string(), z.number(), z.array(z.string())]), + }) + ), route: zodNonRouterRoute, isPreview: z.boolean().optional(), _enablePerf: z.boolean().optional(), diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index d4bb54d72b..44187e09ff 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -478,10 +478,10 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise