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:
Hariom Balhara
2024-11-15 20:39:46 +00:00
committed by GitHub
co-authored by CarinaWolli Alex van Andel
parent 9273226fb4
commit 3eaccb8738
28 changed files with 2109 additions and 871 deletions
+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",
@@ -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<string | undefined> {
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;
}
@@ -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(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
@@ -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,
@@ -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<number, number>;
bookingShortfalls: Record<number, number> | null;
calibrations: Record<number, number> | null;
weights: Record<number, number> | 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 <span className="font-semibold">{matchingMembers.join(", ")}</span>;
if (matchingMembers.length && membersMatchResult.perUserData) {
const perUserData = membersMatchResult.perUserData;
return (
<span className="font-semibold">
<div className="mt-2 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-4">#</th>
<th className="py-2 pr-4">Email</th>
<th className="py-2 pr-4">Bookings</th>
{membersMatchResult.perUserData.weights ? <th className="py-2">Weight</th> : null}
{membersMatchResult.perUserData.calibrations ? <th className="py-2">Calibration</th> : null}
{membersMatchResult.perUserData.bookingShortfalls ? (
<th className="border-l py-2 pl-2">Shortfall</th>
) : null}
</tr>
</thead>
<tbody>
{matchingMembers.map((member, index) => (
<tr key={member.id} className="border-b">
<td className="py-2 pr-4">{index + 1}</td>
<td className="py-2 pr-4">{member.email}</td>
<td className="py-2">{perUserData.bookingsCount[member.id] ?? 0}</td>
{perUserData.weights ? (
<td className="py-2">{perUserData.weights[member.id] ?? 0}</td>
) : null}
{perUserData.calibrations ? (
<td className="py-2">{perUserData.calibrations[member.id] ?? 0}</td>
) : null}
{perUserData.bookingShortfalls ? (
<td className="border-l py-2 pl-2">{perUserData.bookingShortfalls[member.id] ?? 0}</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
</span>
);
}
return (
@@ -328,8 +373,23 @@ const TeamMembersMatchResult = ({
/>
)}
</div>
<div data-testid="matching-members">
{t("matching_members")}: {renderMatchingMembers()}
<div className="mt-4">
{membersMatchResult.contactOwnerEmail ? (
<div data-testid="contact-owner-email">
{t("contact_owner")}:{" "}
<span className="font-semibold">{membersMatchResult.contactOwnerEmail}</span>
</div>
) : (
<div data-testid="contact-owner-email">
{t("contact_owner")}: <span className="font-semibold">Not found</span>
</div>
)}
<div className="mt-2" data-testid="matching-members">
{membersMatchResult.isUsingAttributeWeights
? t("matching_members_queue_using_attribute_weights")
: t("matching_members_queue_using_event_assignee_weights")}
{renderQueue()}
</div>
</div>
</div>
);
@@ -362,7 +422,7 @@ export const TestFormDialog = ({
const { t } = useLocale();
const [response, setResponse] = useState<FormResponse>({});
const [chosenRoute, setChosenRoute] = useState<NonRouterRoute | null>(null);
const [eventTypeUrl, setEventTypeUrl] = useState("");
const [eventTypeUrlWithoutParams, setEventTypeUrlWithoutParams] = useState("");
const searchParams = useCompatSearchParams();
const isTeamForm = !!form.teamId;
const [membersMatchResult, setMembersMatchResult] = useState<MembersMatchResultType | null>(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 = ({
) : (
<div className="flex flex-col space-y-2">
<span className="text-default underline">
<a target="_blank" href={eventTypeUrl} rel="noreferrer" data-testid="test-routing-result">
<a
target="_blank"
className={cn(
findTeamMembersMatchingAttributeLogicMutation.isPending && "pointer-events-none"
)}
href={membersMatchResult?.eventTypeRedirectUrl ?? eventTypeUrlWithoutParams}
rel="noreferrer"
data-testid="test-routing-result">
{chosenRoute.action.value}
</a>
</span>
@@ -486,7 +560,7 @@ export const TestFormDialog = ({
return (
<Dialog open={isTestPreviewOpen} onOpenChange={setIsTestPreviewOpen}>
<DialogContent enableOverflow>
<DialogContent size="md" enableOverflow>
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
<div>
<form
@@ -554,7 +628,6 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
const sendUpdatesTo = hookForm.watch("settings.sendUpdatesTo", []) as number[];
const sendToAll = hookForm.watch("settings.sendToAll", false) as boolean;
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onSuccess() {
showToast(t("form_updated_successfully"), "success");
@@ -20,7 +20,7 @@ type GetUrlSearchParamsToForwardOptions = {
"id" | "type" | "options" | "identifier" | "label"
>[];
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,
});
}
@@ -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;
@@ -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<TrpcSessionUser>;
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<string, number | null | undefined>) {
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;
@@ -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:
@@ -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<typeof ZResponseInputSchema>;
@@ -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);
+1
View File
@@ -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(),
@@ -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
@@ -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;
File diff suppressed because it is too large Load Diff
+493 -206
View File
@@ -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<Booking, "id" | "createdAt" | "userId" | "status"> &
};
type PartialUser = Pick<User, "id" | "email">;
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<T extends PartialUser> {
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 = <T>(arr: T[]): arr is [T, ...T[]] => arr.length > 0;
async function leastRecentlyBookedUser<T extends PartialUser>({
function leastRecentlyBookedUser<T extends PartialUser>({
availableUsers,
eventType,
bookingsOfAvailableUsers,
}: GetLuckyUserParams<T> & { 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<T> & {
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<T extends PartialUser>({
...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<T extends PartialUser>({
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<T extends PartialUser & { priority?: number
if (!isNonEmptyArray(usersWithHighestPriority)) {
throw new Error("Internal Error: Highest Priority filter should never return length=0.");
}
log.info(
"getUsersWithHighestPriority",
safeStringify({
highestPriorityUsers: usersWithHighestPriority.map((user) => 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<T> & {
bookingsOfAvailableUsers: PartialBooking[];
virtualQueuesData?: VirtualQueuesDataType;
attributeWeights?: {
userId: number;
weight: number;
}[];
}): Promise<[T, ...T[]]> {
}: GetLuckyUserParams<T> & 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<T>) {
const { attributeWeights, virtualQueuesData } = await prepareQueuesAndAttributesData(getLuckyUserParams);
return _getLuckyUser(
{
>(getLuckyUserParams: GetLuckyUserParams<T>) {
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<T> & 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<T>) {
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<AvailableUser extends AvailableUserBase>(
getLuckyUserParams: GetLuckyUserParams<AvailableUser>
) {
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<AvailableUser>();
const perUserBookingsCount: Record<number, number> = {};
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<number, number> = {};
const calibrations: Record<number, number> = {};
const weights: Record<number, number> = {};
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<T extends PartialUser>({
@@ -374,11 +716,12 @@ export async function prepareQueuesAndAttributesData<T extends PartialUser>({
}: Omit<GetLuckyUserParams<T>, "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<T extends PartialUser>({
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<T>,
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,
});
}
+6 -4
View File
@@ -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;
}
@@ -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: {
+1
View File
@@ -30,4 +30,5 @@ export const ENDPOINTS = [
"googleWorkspace",
"oAuth",
"attributes",
"routingForms",
] as const;
@@ -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,
})
);
@@ -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 });
}),
});
@@ -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<TrpcSessionUser>;
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<string, number | null | undefined> = {
...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<string, number | null | undefined>) {
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;
@@ -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(),
@@ -478,10 +478,10 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
const contactOwnerEmailFromInput = input.teamMemberEmail ?? null;
const skipContactOwner = input.skipContactOwner;
const contactOwnerEmail = skipContactOwner ? null : contactOwnerEmailFromInput;
const routedTeamMemberIds = input.routedTeamMemberIds ?? null;
const routedHostsWithContactOwnerAndFixedHosts = getRoutedHostsWithContactOwnerAndFixedHosts({
hosts: eventHosts,
routedTeamMemberIds: input.routedTeamMemberIds ?? null,
routedTeamMemberIds,
contactOwnerEmail,
});
@@ -756,6 +756,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
const troubleshooterData = enableTroubleshooter
? {
troubleshooter: {
routedTeamMemberIds: routedTeamMemberIds,
// One that Salesforce asked for
askedContactOwner: contactOwnerEmailFromInput,
// One that we used as per Routing skipContactOwner flag