* 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>
325 lines
9.7 KiB
TypeScript
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;
|