Files
calendar/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.handler.ts
T
3eaccb8738 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>
2024-11-15 20:39:46 +00:00

325 lines
9.7 KiB
TypeScript

/**
* 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;