fix: Preview queue position and contact owner in preview (#17552)

* wip

* Add preview mode in router

* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

* WIP

* remove comment

* update migrations

* get attributes weights and virtual queue data

* use attribute weights and use bookings of virtual queue only

* clean up migrations

* Add shortfall column and add tests

* code clean up from feedback

* wrapper function for getLuckyUser

* code clean up

* fetch routingFormResponse in handleNewBooking

* fix type errors

* fix type errors in tests

* fix getAttributesQueryValue import for tests

* fix totalWeight

* add test for attributes weights and virtual queues

* clean up code

* add test for prepareQueuesAndAttributesData

* remove console.log

* use lazy import

* Add more tests and more columns to matching members queue

* fix issue from merge

* always send usersAndTheirBookingShortfalls

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
authored and GitHub committed 2024-11-15 20:39:46 +00:00
1 parent 9273226fb4
commit 3eaccb8738
28 files changed
+2109 -871

No files matched your search

+2 -2
View File
@@ -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);
},
});
@@ -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
+246
View File
@@ -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<LocalRoute, "attributeRoutingConfig">;
}
) {
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<string | 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;
}
/**
* 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,
});
}
@@ -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<string | null> {
// 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;
}
@@ -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);
@@ -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",