Files
calendar/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts
T
Benny JooGitHubbenny@cal.com <sldisek783@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
00e5770273 refactor: Move attribute and routing-forms code from app-store/lib to features (#26025)
* mv

* wip

* wip

* wip

* wip

* plural

* fix: resolve TypeScript type errors for attribute refactoring

- Add TransformedAttributeOption type to handle transformed contains field
- Update imports to use routing-forms Attribute type where needed
- Fix getValueOfAttributeOption to use TransformedAttributeOption type
- Update AttributeOptionValueWithType to use TransformedAttributeOption
- Fix pre-existing lint warnings (any -> unknown, proper type casting)

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix

* cleanup

* cleanup

* fix

* fix

* fix

* fix

* fix

* fix: add explicit type annotation to reduce callback in getAttributes.ts

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: use RouterOutputs from @calcom/trpc/react for consistent type inference

- Update AppPage.tsx to use RouterOutputs from @calcom/trpc/react instead of inferRouterOutputs<AppRouter>
- Update MultiDisconnectIntegration.tsx to use the same RouterOutputs type source
- Add eslint-disable comments for pre-existing warnings (useEffect deps, img elements)

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: explicitly type attribute property in AttributeOptionAssignment

The attribute property from Pick<AttributeOption, 'attribute'> was typed as
'unknown' because Prisma relations don't have explicit types when picked.
This explicitly defines the attribute shape with id, type, and isLocked
properties that are used in assignValueToUser.ts.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: remove unused assignedUsers from AttributeOptionAssignment Pick

The assignedUsers property was not being used in the code but was required
by the Pick type, causing a type mismatch when the actual data from the
database query didn't include it.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: update import path for AttributeOptionAssignment and BulkAttributeAssigner types

The types.d.ts file was moved from packages/lib/service/attribute/ to
packages/app-store/routing-forms/types/. Updated the import path in utils.ts
to point to the new location.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-29 21:51:06 -03:00

333 lines
10 KiB
TypeScript

/**
* This route is used only by "Test Preview" button and Virtual Queues
* Also, it is applicable only for sub-teams. Regular teams and user Routing Forms don't hit this endpoint.
* Live mode uses findTeamMembersMatchingAttributeLogicOfRoute fn directly
*/
import type { ServerResponse } from "http";
import type { NextApiResponse } from "next";
import { enrichHostsWithDelegationCredentials } from "@calcom/app-store/delegationCredential";
import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData";
import { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { entityPrismaWhereClause } from "@calcom/features/pbac/lib/entityPermissionUtils.server";
import { findTeamMembersMatchingAttributeLogic } from "@calcom/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic";
import { getUrlSearchParamsToForwardForTestPreview } from "@calcom/features/routing-forms/lib/getUrlSearchParamsToForward";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { fromEntriesWithDuplicateKeys } from "@calcom/lib/fromEntriesWithDuplicateKeys";
import type { PrismaClient } from "@calcom/prisma";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
import { getAbsoluteEventTypeRedirectUrl } from "@calcom/routing-forms/getEventTypeRedirectUrl";
import { getSerializableForm } from "@calcom/routing-forms/lib/getSerializableForm";
import { getServerTimingHeader } from "@calcom/routing-forms/lib/getServerTimingHeader";
import isRouter from "@calcom/routing-forms/lib/isRouter";
import { RouteActionType } from "@calcom/routing-forms/zod";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TFindTeamMembersMatchingAttributeLogicOfRouteInputSchema } from "./findTeamMembersMatchingAttributeLogicOfRoute.schema";
interface FindTeamMembersMatchingAttributeLogicOfRouteHandlerOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
res: ServerResponse | NextApiResponse | undefined;
};
input: TFindTeamMembersMatchingAttributeLogicOfRouteInputSchema;
}
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, prisma }: { prisma: PrismaClient; form: TForm }) {
const formWithUserInfoProfile = {
...form,
user: await new UserRepository(prisma).enrichUserWithItsProfile({ user: form.user }),
};
const serializableForm = await getSerializableForm({
form: enrichFormWithMigrationData(formWithUserInfoProfile),
});
return serializableForm;
}
export const findTeamMembersMatchingAttributeLogicOfRouteHandler = async ({
ctx,
input,
}: FindTeamMembersMatchingAttributeLogicOfRouteHandlerOptions) => {
const { prisma, user } = ctx;
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
"@calcom/features/ee/teams/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 formOrgId = form.team?.parentId;
if (!formOrgId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "This form is not associated with an organization",
});
}
const beforeEnrichedForm = performance.now();
const serializableForm = await getEnrichedSerializableForm({ form, prisma });
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 eventTypeRepo = new EventTypeRepository(prisma);
const eventType = await eventTypeRepo.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 findTeamMembersMatchingAttributeLogic(
{
dynamicFieldValueOperands: {
response,
fields: serializableForm.fields || [],
},
attributesQueryValue: route.attributesQueryValue ?? null,
fallbackAttributesQueryValue: route.fallbackAttributesQueryValue ?? null,
teamId: form.teamId,
orgId: formOrgId,
isPreview: !!isPreview,
},
{
enablePerf: _enablePerf,
// Reuse same flag for enabling troubleshooter. We would normally 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 { email: contactOwnerEmail } = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: fromEntriesWithDuplicateKeys(urlSearchParamsToForward.entries()),
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 matchingHosts = await enrichHostsWithDelegationCredentials({
orgId: formOrgId,
hosts: eventType.hosts.filter((host) => matchingTeamMembersIds.includes(host.user.id)),
});
if (!matchingHosts.length) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "No matching team members found",
});
}
const timeBeforeGetOrderedLuckyUsers = performance.now();
const luckyUserService = getLuckyUserService();
const {
users: orderedLuckyUsers,
perUserData,
isUsingAttributeWeights,
} = matchingHosts.length
? await luckyUserService.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,
},
// During Preview testing we could consider the current time itself as the meeting start time
meetingStartTime: new Date(),
})
: { 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,
};
};
export default findTeamMembersMatchingAttributeLogicOfRouteHandler;