* 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>
333 lines
10 KiB
TypeScript
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;
|