perf: /router endpoint perf improvement (#18366)

This commit is contained in:
Hariom Balhara authored and GitHub committed 2024-12-27 08:56:52 +00:00
1 parent b48d0e1f48
commit b5bec79354
32 files changed
+785 -527

No files matched your search

-4
View File
@@ -292,10 +292,6 @@ const nextConfig = {
source: "/forms/:formQuery*",
destination: "/apps/routing-forms/routing-link/:formQuery*",
},
{
source: "/router/:path*",
destination: "/apps/routing-forms/router/:path*",
},
{
source: "/success/:path*",
has: [
+6
View File
@@ -0,0 +1,6 @@
import { getServerSideProps as _getServerSideProps } from "../../server/lib/router/getServerSideProps";
export { default } from "./index";
// We are not using withEmbedSsr here because _getServerSideProps already has the logic to append /embed to the redirect URL and it ends up as /embed/embed
// TODO: We should use withEmbedSsr here and remove the logic from _getServerSideProps
export const getServerSideProps = _getServerSideProps;
@@ -4,7 +4,7 @@ import Head from "next/head";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { getServerSideProps } from "./getServerSideProps";
import { getServerSideProps } from "../../server/lib/router/getServerSideProps";
export default function Router({ form, message }: inferSSRProps<typeof getServerSideProps>) {
return (
@@ -1,48 +1,42 @@
import type { GetServerSidePropsContext } from "next";
import { stringify } from "querystring";
import z from "zod";
import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData";
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier";
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
import { getServerTimingHeader } from "@calcom/app-store/routing-forms/lib/getServerTimingHeader";
import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse";
import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute";
import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables";
import { getFieldResponseForJsonLogic } from "@calcom/app-store/routing-forms/lib/transformResponse";
import { isAuthorizedToViewTheForm } from "@calcom/app-store/routing-forms/pages/routing-link/getServerSideProps";
import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import logger from "@calcom/lib/logger";
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
import { TRPCError } from "@calcom/trpc/server";
import type { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppGetServerSideProps";
import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData";
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "../../getEventTypeRedirectUrl";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { findMatchingRoute } from "../../lib/processRoute";
import { substituteVariables } from "../../lib/substituteVariables";
import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
import type { FormResponse } from "../../types/types";
import { isAuthorizedToViewTheForm } from "../routing-link/getServerSideProps";
import { getUrlSearchParamsToForward } from "../routing-link/getUrlSearchParamsToForward";
const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] });
const querySchema = z
.object({
form: z.string(),
slug: z.string(),
pages: z.array(z.string()),
})
.catchall(z.string().or(z.array(z.string())));
function getNamedParams(params: { appPages: string[] } | undefined) {
const [embed] = params?.appPages || [];
return {
// There might not be item at index 0, so explicit assertion is needed
embed: embed as typeof embed | undefined,
};
function hasEmbedPath(pathWithQuery: string) {
const onlyPath = pathWithQuery.split("?")[0];
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
}
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma
) {
export const getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
const queryParsed = querySchema.safeParse(context.query);
const { embed } = getNamedParams(context.params);
const isEmbed = hasEmbedPath(context.req.url || "");
const pageProps = {
isEmbed: !!embed,
isEmbed,
};
if (!queryParsed.success) {
@@ -51,42 +45,16 @@ export const getServerSideProps = async function getServerSideProps(
notFound: true,
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data;
// Known params reserved by Cal.com are form, embed, layout. We should exclude all of them.
const { form: formId, ...fieldsResponses } = queryParsed.data;
const { currentOrgDomain } = orgDomainConfig(context.req);
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
},
include: {
user: {
select: {
id: true,
username: true,
movedToProfileId: true,
metadata: true,
organization: {
select: {
slug: true,
},
},
},
},
team: {
select: {
parentId: true,
parent: {
select: {
slug: true,
},
},
slug: true,
metadata: true,
},
},
},
});
let timeTaken: Record<string, number | null> = {};
const formQueryStart = performance.now();
const form = await RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
timeTaken.formQuery = performance.now() - formQueryStart;
if (!form) {
return {
@@ -95,20 +63,24 @@ export const getServerSideProps = async function getServerSideProps(
}
const { UserRepository } = await import("@calcom/lib/server/repository/user");
const profileEnrichmentStart = performance.now();
const formWithUserProfile = {
...form,
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
};
timeTaken.profileEnrichment = performance.now() - profileEnrichmentStart;
if (!(await isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain }))) {
if (!isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain })) {
return {
notFound: true,
};
}
const getSerializableFormStart = performance.now();
const serializableForm = await getSerializableForm({
form: enrichFormWithMigrationData(formWithUserProfile),
});
timeTaken.getSerializableForm = performance.now() - getSerializableFormStart;
const response: FormResponse = {};
if (!serializableForm.fields) {
@@ -131,18 +103,13 @@ export const getServerSideProps = async function getServerSideProps(
const decidedAction = matchingRoute.action;
const { createContext } = await import("@calcom/trpc/server/createContext");
const ctx = await createContext(context);
const { default: trpcRouter } = await import("@calcom/app-store/routing-forms/trpc/_router");
const caller = trpcRouter.createCaller(ctx);
const { v4: uuidv4 } = await import("uuid");
let teamMembersMatchingAttributeLogic = null;
let formResponseId = null;
let attributeRoutingConfig = null;
try {
const result = await caller.public.response({
formId: form.id,
const result = await handleResponse({
form: serializableForm,
formFillerId: uuidv4(),
response: response,
chosenRouteId: matchingRoute.id,
@@ -150,6 +117,10 @@ export const getServerSideProps = async function getServerSideProps(
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
formResponseId = result.formResponse.id;
attributeRoutingConfig = result.attributeRoutingConfig;
timeTaken = {
...timeTaken,
...result.timeTaken,
};
} catch (e) {
if (e instanceof TRPCError) {
return {
@@ -162,6 +133,9 @@ export const getServerSideProps = async function getServerSideProps(
}
}
// TODO: To be done using sentry tracing
console.log("Server-Timing", getServerTimingHeader(timeTaken));
//TODO: Maybe take action after successful mutation
if (decidedAction.type === "customPageMessage") {
return {
+3
View File
@@ -23,6 +23,9 @@
},
"pages/api/trpc/appRoutingForms/[trpc].ts": {
"memory": 2048
},
"pages/router/embed.tsx": {
"memory": 2048
}
}
}
@@ -0,0 +1,12 @@
export 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(", ");
}
@@ -5,58 +5,55 @@ import { emailSchema } from "@calcom/lib/emailSchema";
import logger from "@calcom/lib/logger";
import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { PrismaClient } from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@calcom/trpc/server";
import type { ZResponseInputSchema } from "@calcom/trpc/server/routers/viewer/routing-forms/response.schema";
import { getSerializableForm } from "../lib/getSerializableForm";
import isRouter from "../lib/isRouter";
import type { FormResponse } from "../types/types";
import type { TResponseInputSchema } from "./response.schema";
import { onFormSubmission } from "./utils";
import { onFormSubmission } from "../trpc/utils";
import type { FormResponse, SerializableForm } from "../types/types";
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/response.handler"] });
type Form = SerializableForm<
App_RoutingForms_Form & {
user: {
id: number;
email: string;
};
team: {
parentId: number | null;
} | null;
}
>;
interface ResponseHandlerOptions {
ctx: {
prisma: PrismaClient;
};
input: TResponseInputSchema;
}
export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => {
const { prisma } = ctx;
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/handleResponse"] });
export const handleResponse = async ({
response,
form,
// Unused but probably should be used
// formFillerId,
chosenRouteId,
}: {
response: z.infer<typeof ZResponseInputSchema>["response"];
form: Form;
formFillerId: string;
chosenRouteId: string | null;
}) => {
try {
const { response, formId, chosenRouteId } = input;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
});
}
const serializableForm = await getSerializableForm({ form });
if (!serializableForm.fields) {
if (!form.fields) {
// There is no point in submitting a form that doesn't have fields defined
throw new TRPCError({
code: "BAD_REQUEST",
});
}
const formTeamId = form.teamId;
const formOrgId = form.team?.parentId ?? null;
const serializableFormWithFields = {
...serializableForm,
fields: serializableForm.fields,
...form,
fields: form.fields,
};
const missingFields = serializableFormWithFields.fields
@@ -120,7 +117,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId);
let teamMemberIdsMatchingAttributeLogic: number[] | null = null;
let timeTaken: Record<string, number | null> = {};
if (chosenRoute) {
if (isRouter(chosenRoute)) {
throw new TRPCError({
@@ -128,17 +125,25 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
message: "Chosen route is a router",
});
}
const teamMembersMatchingAttributeLogicWithResult = form.teamId
? await findTeamMembersMatchingAttributeLogic({
dynamicFieldValueOperands: {
response,
fields: serializableForm.fields || [],
},
attributesQueryValue: chosenRoute.attributesQueryValue ?? null,
fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue,
teamId: form.teamId,
})
: null;
const teamMembersMatchingAttributeLogicWithResult =
formTeamId && formOrgId
? await findTeamMembersMatchingAttributeLogic(
{
dynamicFieldValueOperands: {
response,
fields: form.fields || [],
},
attributesQueryValue: chosenRoute.attributesQueryValue ?? null,
fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue,
teamId: formTeamId,
orgId: formOrgId,
},
{
enablePerf: true,
}
)
: null;
moduleLogger.debug(
"teamMembersMatchingAttributeLogic",
@@ -151,71 +156,17 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
(member) => member.userId
)
: null;
timeTaken = teamMembersMatchingAttributeLogicWithResult?.timeTaken ?? {};
} else {
// It currently happens for a Router route. Such a route id isn't present in the form.routes
}
// 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,
// TODO: Why do we not save formFillerId available in the input?
// formFillerId,
formId: form.id,
response: response,
chosenRouteId,
},
@@ -237,6 +188,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
? chosenRoute.attributeRoutingConfig
: null
: null,
timeTaken,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
@@ -249,5 +201,3 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
throw e;
}
};
export default responseHandler;
@@ -9,8 +9,6 @@ import { getServerSideProps as getServerSidePropsForms } from "./forms/getServer
import * as LayoutHandler from "./layout-handler/[...appPages]";
import * as Reporting from "./reporting/[...appPages]";
import * as RouteBuilder from "./route-builder/[...appPages]";
import * as Router from "./router/[...appPages]";
import { getServerSideProps as getServerSidePropsRouter } from "./router/getServerSideProps";
import * as RoutingLink from "./routing-link/[...appPages]";
import { getServerSideProps as getServerSidePropsRoutingLink } from "./routing-link/getServerSideProps";
@@ -19,7 +17,6 @@ const routingConfig = {
"route-builder": RouteBuilder,
forms: forms,
"routing-link": RoutingLink,
router: Router,
reporting: Reporting,
layoutHandler: LayoutHandler,
};
@@ -29,7 +26,6 @@ export const serverSidePropsConfig: Record<string, AppGetServerSideProps> = {
"form-edit": getServerSidePropsSingleForm,
"route-builder": getServerSidePropsSingleForm,
"routing-link": getServerSidePropsRoutingLink,
router: getServerSidePropsRouter,
reporting: getServerSidePropsSingleForm,
};
@@ -92,7 +92,7 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
sdkActionManager?.fire("__routeChanged", {});
}, [customPageMessage]);
const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({
const responseMutation = trpc.viewer.routingForms.public.response.useMutation({
onSuccess: async (data) => {
const { teamMembersMatchingAttributeLogic, formResponse, attributeRoutingConfig } = data;
const chosenRouteWithFormResponse = chosenRouteWithFormResponseRef.current;
@@ -6,7 +6,7 @@ import type { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppG
import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData";
import { getSerializableForm } from "../../lib/getSerializableForm";
export async function isAuthorizedToViewTheForm({
export function isAuthorizedToViewTheForm({
user,
currentOrgDomain,
}: {
@@ -108,7 +108,7 @@ export const getServerSideProps = async function getServerSideProps(
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
};
if (!(await isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain }))) {
if (!isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain })) {
return {
notFound: true,
};
@@ -1,7 +1,6 @@
import { z } from "zod";
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { ZDeleteFormInputSchema } from "./deleteForm.schema";
@@ -10,7 +9,6 @@ import { ZFormQueryInputSchema } from "./formQuery.schema";
import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
import { forms } from "./procedures/forms";
import { ZReportInputSchema } from "./report.schema";
import { ZResponseInputSchema } from "./response.schema";
// eslint-disable-next-line @typescript-eslint/ban-types
const UNSTABLE_HANDLER_CACHE: Record<string, Function> = {};
@@ -53,12 +51,6 @@ export const ZFormByResponseIdInputSchema = z.object({
export type TFormQueryInputSchema = z.infer<typeof ZFormQueryInputSchema>;
const appRoutingForms = router({
public: router({
response: publicProcedure.input(ZResponseInputSchema).mutation(async ({ ctx, input }) => {
const handler = await getHandler("response", () => import("./response.handler"));
return handler({ ctx, input });
}),
}),
forms,
formQuery: authedProcedure.input(ZFormQueryInputSchema).query(async ({ ctx, input }) => {
const handler = await getHandler("formQuery", () => import("./formQuery.handler"));
+3 -2
View File
@@ -70,13 +70,14 @@ async function findMatchingTeamMembersIdsForEventRRSegment(eventType: EventType)
return null;
}
if (!eventType.team) {
if (!eventType.team || !eventType.team.parentId) {
return null;
}
const { teamMembersMatchingAttributeLogic } = await findTeamMembersMatchingAttributeLogic({
attributesQueryValue: eventType.rrSegmentQueryValue ?? null,
teamId: eventType.team.id,
orgId: eventType.team.parentId,
});
if (!teamMembersMatchingAttributeLogic) {
return teamMembersMatchingAttributeLogic;
@@ -102,7 +103,7 @@ type EventType = {
assignAllTeamMembers: boolean;
assignRRMembersUsingSegment: boolean;
rrSegmentQueryValue: AttributesQueryValue | null | undefined;
team: { id: number } | null;
team: { id: number; parentId: number | null } | null;
};
export function getNormalizedHosts<User extends BaseUser, Host extends BaseHost<User>>({
@@ -18,41 +18,63 @@ vi.mock("../../components/react-awesome-query-builder/widgets", () => ({
default: {},
}));
vi.mock("@calcom/ui", () => ({}));
const orgId = 1001;
function mockAttributesScenario({
attributes,
teamMembersWithAttributeOptionValuePerAttribute,
}: {
attributes: Awaited<ReturnType<typeof getAttributesModule.getAttributesForTeam>>;
attributes: {
id: string;
name: string;
type: AttributeType;
slug: string;
options: {
id: string;
value: string;
slug: string;
}[];
}[];
teamMembersWithAttributeOptionValuePerAttribute: {
userId: number;
attributes: Record<string, string | string[]>;
}[];
}) {
vi.mocked(getAttributesModule.getAttributesForTeam).mockResolvedValue(attributes);
vi.mocked(getAttributesModule.getTeamMembersWithAttributeOptionValuePerAttribute).mockResolvedValue(
teamMembersWithAttributeOptionValuePerAttribute.map((member) => {
return {
...member,
attributes: Object.fromEntries(
Object.entries(member.attributes).map(([attributeId, value]) => {
return [
attributeId,
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
{
attributeOption:
value instanceof Array
? value.map((value) => ({ value, isGroup: false, contains: [] }))
: { value, isGroup: false, contains: [] },
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
type: attributes.find((attribute) => attribute.id === attributeId)!.type,
},
];
})
),
};
})
);
const commonOptionsProps = {
isGroup: false,
contains: [],
};
vi.mocked(getAttributesModule.getAttributesAssignmentData).mockResolvedValue({
attributesOfTheOrg: attributes.map((attribute) => ({
...attribute,
options: attribute.options.map((option) => ({
...option,
...commonOptionsProps,
})),
})),
attributesAssignedToTeamMembersWithOptions: teamMembersWithAttributeOptionValuePerAttribute.map(
(member) => {
return {
...member,
attributes: Object.fromEntries(
Object.entries(member.attributes).map(([attributeId, value]) => {
return [
attributeId,
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
{
attributeOption:
value instanceof Array
? value.map((value) => ({ value, isGroup: false, contains: [] }))
: { value, isGroup: false, contains: [] },
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
type: attributes.find((attribute) => attribute.id === attributeId)!.type,
},
];
})
),
};
}
),
});
}
function mockHugeAttributesOfTypeSingleSelect({
@@ -253,6 +275,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
},
attributesQueryValue: { type: "group" } as unknown as AttributesQueryValue,
teamId: 1,
orgId,
},
{
enableTroubleshooter: true,
@@ -302,6 +325,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
},
attributesQueryValue,
teamId: 1,
orgId,
});
expect(result).toEqual([
@@ -374,6 +398,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
},
attributesQueryValue: attributesQueryValue,
teamId: 1,
orgId,
});
expect(result).toEqual([
@@ -438,6 +463,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
},
attributesQueryValue,
teamId: 1,
orgId,
});
expect(result).toEqual([
@@ -503,6 +529,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
},
attributesQueryValue,
teamId: 1,
orgId,
});
expect(result).toEqual([
@@ -571,6 +598,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
},
attributesQueryValue,
teamId: 1,
orgId,
});
expect(result).toEqual([
@@ -596,6 +624,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
attributesQueryValue: failingAttributesQueryValue,
fallbackAttributesQueryValue: null,
teamId: 1,
orgId,
});
expect(result).toEqual(null);
@@ -619,6 +648,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
attributesQueryValue: failingAttributesQueryValue,
fallbackAttributesQueryValue: matchingAttributesQueryValue,
teamId: 1,
orgId,
});
expect(checkedFallback).toEqual(true);
@@ -647,6 +677,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
attributesQueryValue: failingAttributesQueryValue,
fallbackAttributesQueryValue: failingAttributesQueryValue,
teamId: 1,
orgId,
});
expect(checkedFallback).toEqual(true);
@@ -691,6 +722,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
],
}),
teamId: 1,
orgId,
})
).rejects.toThrow("Unsupported attribute type");
});
@@ -750,6 +782,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
},
attributesQueryValue,
teamId: 1,
orgId,
isPreview: mode === "preview" ? true : false,
fallbackAttributesQueryValue: null,
});
@@ -825,6 +858,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
},
attributesQueryValue,
teamId: 1,
orgId,
isPreview: mode === "preview" ? true : false,
});
return result;
@@ -871,6 +905,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
},
attributesQueryValue,
teamId: 1,
orgId,
},
{
concurrency: 1,
@@ -975,6 +1010,7 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
},
attributesQueryValue,
teamId: 1,
orgId,
},
{
enableTroubleshooter: true,
@@ -4,10 +4,7 @@ import type { Config } from "react-awesome-query-builder/lib";
import { Utils as QbUtils } from "react-awesome-query-builder/lib";
import type { dynamicFieldValueOperands } from "@calcom/lib/raqb/types";
import {
getTeamMembersWithAttributeOptionValuePerAttribute,
getAttributesForTeam,
} from "@calcom/lib/service/attribute/server/getAttributes";
import { getAttributesAssignmentData } from "@calcom/lib/service/attribute/server/getAttributes";
import type { Attribute } from "@calcom/lib/service/attribute/server/getAttributes";
import { RaqbLogicResult } from "./evaluateRaqbLogic";
@@ -22,13 +19,13 @@ const {
} = acrossQueryValueCompatiblity;
type TeamMemberWithAttributeOptionValuePerAttribute = Awaited<
ReturnType<typeof getTeamMembersWithAttributeOptionValuePerAttribute>
>[number];
ReturnType<typeof getAttributesAssignmentData>
>["attributesAssignedToTeamMembersWithOptions"][number];
type RunAttributeLogicData = {
attributesQueryValue: AttributesQueryValue | null;
attributesData: {
attributesForTeam: Attribute[];
attributesOfTheOrg: Attribute[];
teamMembersWithAttributeOptionValuePerAttribute: TeamMemberWithAttributeOptionValuePerAttribute[];
};
dynamicFieldValueOperands?: dynamicFieldValueOperands;
@@ -183,13 +180,13 @@ async function getLogicResultForAllMembers(
async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttributeLogicOptions) {
const {
attributesQueryValue: _attributesQueryValue,
attributesData: { attributesForTeam, teamMembersWithAttributeOptionValuePerAttribute },
attributesData: { attributesOfTheOrg, teamMembersWithAttributeOptionValuePerAttribute },
dynamicFieldValueOperands,
} = data;
const { concurrency, enablePerf, enableTroubleshooter } = options;
const attributesQueryValue = getAttributesQueryValue({
attributesQueryValue: _attributesQueryValue ?? null,
attributes: attributesForTeam,
attributes: attributesOfTheOrg,
dynamicFieldValueOperands,
});
@@ -209,7 +206,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib
const [attributesQueryBuilderConfig, ttgetAttributesQueryBuilderConfigHavingListofLabels] = pf(() =>
getAttributesQueryBuilderConfigHavingListofLabels({
dynamicFieldValueOperands,
attributes: attributesForTeam,
attributes: attributesOfTheOrg,
})
);
@@ -272,7 +269,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib
attributesQueryValue,
attributesQueryBuilderConfig,
logic,
attributesForTeam,
attributesOfTheOrg,
},
}),
};
@@ -307,18 +304,14 @@ async function runFallbackAttributeLogic(data: RunAttributeLogicData, options: R
};
}
export async function getAttributesForLogic({ teamId }: { teamId: number }) {
const [[attributesForTeam, teamMembersWithAttributeOptionValuePerAttribute], ttAttributes] =
await asyncPerf(async () => {
return [
await getAttributesForTeam({ teamId: teamId }),
await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: teamId }),
];
});
export async function getAttributesForLogic({ teamId, orgId }: { teamId: number; orgId: number }) {
const [result, ttAttributes] = await asyncPerf(async () => {
return getAttributesAssignmentData({ teamId, orgId });
});
return {
attributesForTeam,
teamMembersWithAttributeOptionValuePerAttribute,
attributesOfTheOrg: result.attributesOfTheOrg,
teamMembersWithAttributeOptionValuePerAttribute: result.attributesAssignedToTeamMembersWithOptions,
timeTaken: ttAttributes,
};
}
@@ -326,6 +319,7 @@ export async function getAttributesForLogic({ teamId }: { teamId: number }) {
export async function findTeamMembersMatchingAttributeLogic(
data: {
teamId: number;
orgId: number;
attributesQueryValue: AttributesQueryValue | null;
fallbackAttributesQueryValue?: AttributesQueryValue | null;
dynamicFieldValueOperands?: dynamicFieldValueOperands;
@@ -343,15 +337,22 @@ export async function findTeamMembersMatchingAttributeLogic(
// Any explicit value being passed should cause fallback to be considered. Even undefined
const considerFallback = "fallbackAttributesQueryValue" in data;
const { teamId, attributesQueryValue, fallbackAttributesQueryValue, dynamicFieldValueOperands, isPreview } =
data;
const {
teamId,
orgId,
attributesQueryValue,
fallbackAttributesQueryValue,
dynamicFieldValueOperands,
isPreview,
} = data;
const {
attributesForTeam,
attributesOfTheOrg,
teamMembersWithAttributeOptionValuePerAttribute,
timeTaken: ttGetAttributesForLogic,
} = await getAttributesForLogic({
teamId,
orgId,
});
const runAttributeLogicOptions = {
@@ -364,7 +365,7 @@ export async function findTeamMembersMatchingAttributeLogic(
const runAttributeLogicData: Omit<RunAttributeLogicData, "attributesQueryValue"> = {
// Change it as per the main/fallback query
attributesData: {
attributesForTeam,
attributesOfTheOrg,
teamMembersWithAttributeOptionValuePerAttribute,
},
dynamicFieldValueOperands,
@@ -452,7 +453,7 @@ export async function findTeamMembersMatchingAttributeLogic(
type: TroubleshooterCase.MATCH_RESULTS_READY,
data: {
...troubleshooter.data,
attributesForTeam,
attributesOfTheOrg,
},
})
: null),
@@ -24,4 +24,22 @@ export class AttributeRepository {
},
});
}
static async findManyByOrgId({ orgId }: { orgId: number }) {
// It should be a faster query because of lesser number of attributes record and index on teamId
const result = await prisma.attribute.findMany({
where: {
teamId: orgId,
},
select: {
id: true,
name: true,
type: true,
slug: true,
options: true,
},
});
return result;
}
}
@@ -28,4 +28,20 @@ export class AttributeToUserRepository {
},
});
}
static async findManyByOrgMembershipIds({ orgMembershipIds }: { orgMembershipIds: number[] }) {
if (!orgMembershipIds.length) {
return [];
}
const attributesAssignedToTeamMembers = await prisma.attributeToUser.findMany({
where: {
memberId: {
in: orgMembershipIds,
},
},
});
return attributesAssignedToTeamMembers;
}
}
@@ -797,6 +797,7 @@ export class EventTypeRepository {
id: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
parentId: true,
},
},
parent: {
@@ -258,4 +258,33 @@ export class MembershipRepository {
return membershipsWithSelectedCalendars;
}
static async findMembershipsForBothOrgAndTeam({ orgId, teamId }: { orgId: number; teamId: number }) {
const memberships = await prisma.membership.findMany({
where: {
teamId: {
in: [orgId, teamId],
},
},
});
type Membership = (typeof memberships)[number];
const { teamMemberships, orgMemberships } = memberships.reduce(
(acc, membership) => {
if (membership.teamId === teamId) {
acc.teamMemberships.push(membership);
} else if (membership.teamId === orgId) {
acc.orgMemberships.push(membership);
}
return acc;
},
{ teamMemberships: [] as Membership[], orgMemberships: [] as Membership[] }
);
return {
teamMemberships,
orgMemberships,
};
}
}
@@ -0,0 +1,39 @@
import prisma from "@calcom/prisma";
export class RoutingFormRepository {
static async findFormByIdIncludeUserTeamAndOrg(formId: string) {
return await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
include: {
user: {
select: {
id: true,
username: true,
email: true,
movedToProfileId: true,
metadata: true,
organization: {
select: {
slug: true,
},
},
},
},
team: {
select: {
parentId: true,
parent: {
select: {
slug: true,
},
},
slug: true,
metadata: true,
},
},
},
});
}
}
@@ -5,11 +5,8 @@ import { describe, expect, it, beforeEach } from "vitest";
import type { AttributeOption } from "@calcom/prisma/client";
import { AttributeType, MembershipRole } from "@calcom/prisma/enums";
import {
getAttributesForTeam,
getTeamMembersWithAttributeOptionValuePerAttribute,
getUsersAttributes,
} from "./getAttributes";
import type { Attribute } from "./getAttributes";
import { getAttributesForTeam, getAttributesAssignmentData, getUsersAttributes } from "./getAttributes";
// Helper functions to create test data
async function createMockAttribute({
@@ -43,7 +40,15 @@ async function createMockAttribute({
})),
});
return attribute;
const result = await prismock.attribute.findUnique({
where: { id: attribute.id },
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
include: { options: true },
});
if (!result) {
throw new Error("Some error in creating mock attribute");
}
return result;
}
async function createMockUserHavingMembershipWithBothTeamAndOrg({
@@ -96,20 +101,36 @@ async function createMockTeam({ orgId }: { orgId: number | null }) {
}
async function createMockAttributeAssignment({
membershipId,
orgMembershipId,
attributeOptionId,
}: {
membershipId: number;
orgMembershipId: number;
attributeOptionId: string;
}) {
return await prismock.attributeToUser.create({
data: {
memberId: membershipId,
memberId: orgMembershipId,
attributeOptionId,
},
});
}
async function expectAttributeToMatch(attribute: Attribute, expected: Attribute) {
expect(attribute).toMatchObject({
id: expected.id,
name: expected.name,
slug: expected.slug,
type: expected.type,
});
}
async function expectAttributesToMatch(attributes: Attribute[], expected: Attribute[]) {
expect(attributes).toHaveLength(expected.length);
for (let i = 0; i < attributes.length; i++) {
expectAttributeToMatch(attributes[i], expected[i]);
}
}
describe("getAttributes", () => {
const orgId = 1001;
@@ -157,7 +178,7 @@ describe("getAttributes", () => {
});
await createMockAttributeAssignment({
membershipId: teamMembership.id,
orgMembershipId: teamMembership.id,
attributeOptionId: "opt1",
});
@@ -172,15 +193,15 @@ describe("getAttributes", () => {
});
});
describe("getTeamMembersWithAttributeOptionValuePerAttribute", () => {
describe("getAttributesAssignmentData", () => {
it("should return team members with their assigned attributes", async () => {
const team = await createMockTeam({ orgId });
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
const { user, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
orgId,
teamId: team.id,
});
await createMockAttribute({
const attr1 = await createMockAttribute({
orgId,
id: "attr1",
name: "Department",
@@ -199,7 +220,7 @@ describe("getAttributes", () => {
],
});
await createMockAttribute({
const attr2 = await createMockAttribute({
orgId,
id: "attr2",
name: "Territory",
@@ -212,7 +233,7 @@ describe("getAttributes", () => {
});
// Unassigned attribute
await createMockAttribute({
const attr3 = await createMockAttribute({
orgId,
id: "attr3",
name: "Attr3",
@@ -225,25 +246,26 @@ describe("getAttributes", () => {
});
await createMockAttributeAssignment({
membershipId: teamMembership.id,
orgMembershipId: orgMembership.id,
// Assigning a group option that has its sub-options not assigned directly to the user
attributeOptionId: "engineering-and-sales-id",
});
await createMockAttributeAssignment({
membershipId: teamMembership.id,
orgMembershipId: orgMembership.id,
attributeOptionId: "india-id",
});
await createMockAttributeAssignment({
membershipId: teamMembership.id,
orgMembershipId: orgMembership.id,
attributeOptionId: "engineering-id",
});
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id });
const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } =
await getAttributesAssignmentData({ teamId: team.id, orgId });
expect(teamMembers).toHaveLength(1);
expect(teamMembers[0]).toEqual({
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1);
expect(attributesAssignedToTeamMembersWithOptions[0]).toEqual({
userId: user.id,
attributes: {
attr1: {
@@ -275,16 +297,18 @@ describe("getAttributes", () => {
// attr3 - unassigned isn't here
},
});
expectAttributesToMatch(attributesOfTheOrg, [attr1, attr2, attr3]);
});
it("should return contains correctly for group options that have sub-options not assigned directly to the user", async () => {
const team = await createMockTeam({ orgId });
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
const { user, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
orgId,
teamId: team.id,
});
await createMockAttribute({
const attr1 = await createMockAttribute({
orgId,
id: "attr1",
name: "Department",
@@ -304,15 +328,16 @@ describe("getAttributes", () => {
});
await createMockAttributeAssignment({
membershipId: teamMembership.id,
orgMembershipId: orgMembership.id,
// Assigning a group option that has its sub-options not assigned directly to the user
attributeOptionId: "engineering-and-sales-id",
});
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id });
const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } =
await getAttributesAssignmentData({ teamId: team.id, orgId });
expect(teamMembers).toHaveLength(1);
expect(teamMembers[0]).toEqual({
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1);
expect(attributesAssignedToTeamMembersWithOptions[0]).toEqual({
userId: user.id,
attributes: {
attr1: {
@@ -332,12 +357,15 @@ describe("getAttributes", () => {
},
},
});
expectAttributesToMatch(attributesOfTheOrg, [attr1]);
});
it("should return no attributes for a different org", async () => {
const team = await createMockTeam({ orgId });
const otherTeam = await createMockTeam({ orgId: 1002 });
const { teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
const otherOrgId = 1002;
const otherOrgsTeam = await createMockTeam({ orgId: otherOrgId });
const { orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
orgId,
teamId: team.id,
});
@@ -362,24 +390,27 @@ describe("getAttributes", () => {
});
await createMockAttributeAssignment({
membershipId: teamMembership.id,
orgMembershipId: orgMembership.id,
// Assigning a group option that has its sub-options not assigned directly to the user
attributeOptionId: "engineering-and-sales-id",
});
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: otherTeam.id });
const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } =
await getAttributesAssignmentData({ teamId: otherOrgsTeam.id, orgId: otherOrgId });
expect(teamMembers).toHaveLength(0);
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(0);
// Because created attribute is of other org, it should not be returned
expect(attributesOfTheOrg).toHaveLength(0);
});
it("should handle multi-select attributes correctly", async () => {
const team = await createMockTeam({ orgId });
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
const { orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
orgId,
teamId: team.id,
});
await createMockAttribute({
const attr1 = await createMockAttribute({
orgId,
id: "attr1",
name: "Skills",
@@ -393,28 +424,31 @@ describe("getAttributes", () => {
// Assign multiple skills
await createMockAttributeAssignment({
membershipId: teamMembership.id,
orgMembershipId: orgMembership.id,
attributeOptionId: "opt1",
});
await createMockAttributeAssignment({
membershipId: teamMembership.id,
orgMembershipId: orgMembership.id,
attributeOptionId: "opt2",
});
const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id });
const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } =
await getAttributesAssignmentData({ teamId: team.id, orgId });
expect(teamMembers).toHaveLength(1);
expect(teamMembers[0].attributes.attr1.attributeOption).toEqual([
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1);
expect(attributesAssignedToTeamMembersWithOptions[0].attributes.attr1.attributeOption).toEqual([
{ value: "JavaScript", isGroup: false, contains: [] },
{ value: "Python", isGroup: false, contains: [] },
]);
expectAttributesToMatch(attributesOfTheOrg, [attr1]);
});
});
describe("getUsersAttributes", () => {
it("should return attributes assigned to specific user in team", async () => {
const team = await createMockTeam({ orgId });
const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
const { user, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({
orgId,
teamId: team.id,
});
@@ -429,7 +463,7 @@ describe("getAttributes", () => {
});
await createMockAttributeAssignment({
membershipId: teamMembership.id,
orgMembershipId: orgMembership.id,
attributeOptionId: "opt1",
});
@@ -2,12 +2,32 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { AttributeToUser } from "@calcom/prisma/client";
import type { AttributeType } from "@calcom/prisma/enums";
import { AttributeRepository } from "../../../server/repository/attribute";
import { AttributeToUserRepository } from "../../../server/repository/attributeToUser";
import { MembershipRepository } from "../../../server/repository/membership";
import type { AttributeId } from "../types";
import { findAllAttributesWithTheirOptions } from "./utils";
type UserId = number;
type OrgMembershipId = number;
type AttributeOptionId = string;
type AssignmentForTheTeam = {
userId: number;
attributeOption: {
id: string;
value: string;
slug: string;
contains: string[];
isGroup: boolean;
};
attribute: {
id: string;
name: string;
type: AttributeType;
};
};
export type Attribute = {
name: string;
@@ -21,6 +41,20 @@ export type Attribute = {
}[];
};
type FullAttribute = {
name: string;
slug: string;
type: AttributeType;
id: string;
options: {
id: string;
value: string;
slug: string;
isGroup: boolean;
contains: string[];
}[];
};
export type AttributeOptionValue = {
value: string;
isGroup: boolean;
@@ -36,122 +70,17 @@ export type AttributeOptionValueWithType = {
attributeOption: AttributeOptionValue | AttributeOptionValue[];
};
/**
* Note: assignedAttributeOptions[x].attributeOption isn't unique. It is returned multiple times depending on how many users it is assigned to
*/
async function getAssignedAttributeOptions({ teamId }: { teamId: number }) {
const log = logger.getSubLogger({ prefix: ["getAssignedAttributeOptions"] });
const whereClauseForAssignment = {
member: {
user: {
teams: {
some: {
teamId,
},
},
},
},
};
log.debug(
safeStringify({
teamId,
whereClauseForAssignment,
})
);
const assignedAttributeOptions = await prisma.attributeToUser.findMany({
where: whereClauseForAssignment,
select: {
member: {
select: {
userId: true,
},
},
attributeOption: {
select: {
id: true,
value: true,
slug: true,
contains: true,
isGroup: true,
attribute: {
select: { id: true, name: true, type: true, slug: true },
},
},
},
},
});
log.debug("Returned assignedAttributeOptions", safeStringify({ assignedAttributeOptions }));
return assignedAttributeOptions;
}
async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) {
const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] });
const whereClauseForAttributesAssignedToMembersOfTeam = {
options: {
some: {
assignedUsers: {
some: {
member: {
userId,
user: {
teams: {
some: {
teamId,
},
},
},
},
},
},
},
},
};
log.debug(
safeStringify({
teamId,
whereClauseForAttributesAssignedToMembersOfTeam,
})
);
const assignedAttributeOptions = await prisma.attribute.findMany({
where: whereClauseForAttributesAssignedToMembersOfTeam,
select: {
id: true,
name: true,
type: true,
options: {
select: {
id: true,
value: true,
slug: true,
},
},
slug: true,
},
});
return assignedAttributeOptions;
}
export async function getAttributesForTeam({ teamId }: { teamId: number }) {
const attributes = await getAttributesAssignedToMembersOfTeam({ teamId });
return attributes satisfies Attribute[];
}
export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamId }: { teamId: number }) {
const [assignedAttributeOptions, allAttributesWithTheirOptions] = await Promise.all([
getAssignedAttributeOptions({ teamId }),
// We need to fetch even the unassigned options, to know what all sub-options are there in an option group. Because it is possible that sub-options aren't assigned directly to the user
findAllAttributesWithTheirOptions({ teamId }),
]);
const teamMembersThatHaveOptionAssigned = assignedAttributeOptions.reduce((acc, attributeToUser) => {
const { userId } = attributeToUser.member;
const { attribute, ...attributeOption } = attributeToUser.attributeOption;
function _prepareAssignmentData({
assignmentsForTheTeam,
attributesOfTheOrg,
}: {
assignmentsForTheTeam: AssignmentForTheTeam[];
attributesOfTheOrg: Attribute[];
}) {
const teamMembersThatHaveOptionAssigned = assignmentsForTheTeam.reduce((acc, attributeToUser) => {
const userId = attributeToUser.userId;
const attributeOption = attributeToUser.attributeOption;
const attribute = attributeToUser.attribute;
if (!acc[userId]) {
acc[userId] = { userId, attributes: {} };
@@ -208,7 +137,7 @@ export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamI
}) {
return contains
.map((optionId) => {
const allOptions = allAttributesWithTheirOptions.get(attribute.id);
const allOptions = attributesOfTheOrg.find((_attribute) => _attribute.id === attribute.id)?.options;
const option = allOptions?.find((option) => option.id === optionId);
if (!option) {
console.error(
@@ -228,6 +157,210 @@ export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamI
}
}
function _getAttributeFromAttributeOption({
allAttributesOfTheOrg,
attributeOptionId,
}: {
allAttributesOfTheOrg: Attribute[];
attributeOptionId: AttributeOptionId;
}) {
return allAttributesOfTheOrg.find((attribute) =>
attribute.options.some((option) => option.id === attributeOptionId)
);
}
function _getAttributeOptionFromAttributeOption({
allAttributesOfTheOrg,
attributeOptionId,
}: {
allAttributesOfTheOrg: FullAttribute[];
attributeOptionId: AttributeOptionId;
}) {
const matchingOption = allAttributesOfTheOrg.reduce((found, attribute) => {
if (found) return found;
return attribute.options.find((option) => option.id === attributeOptionId) || null;
}, null as null | (typeof allAttributesOfTheOrg)[number]["options"][number]);
return matchingOption;
}
async function _getOrgMembershipToUserIdForTeam({ orgId, teamId }: { orgId: number; teamId: number }) {
const { orgMemberships, teamMemberships } = await MembershipRepository.findMembershipsForBothOrgAndTeam({
orgId,
teamId,
});
// Using map for performance lookup as it matters in the below loop working with 1000s of records
const orgMembershipsByUserId = new Map(orgMemberships.map((m) => [m.userId, m]));
/**
* Holds the records of orgMembershipId to userId for the sub-team's members only.
*/
const orgMembershipToUserIdForTeamMembers = new Map<OrgMembershipId, UserId>();
/**
* For an organization with 3000 users and 10 teams, with every team having around 300 members, the total memberships we get for a team are 3000+300 = 3300
* So, these are not a lot of records and we could afford to do in memory computations on them.
*/
teamMemberships.forEach((teamMembership) => {
const orgMembership = orgMembershipsByUserId.get(teamMembership.userId);
if (!orgMembership) {
console.error(
`Org membership not found for userId ${teamMembership.userId} in the organization's memberships`
);
return;
}
orgMembershipToUserIdForTeamMembers.set(orgMembership.id, orgMembership.userId);
});
return orgMembershipToUserIdForTeamMembers;
}
async function _queryAllData({ orgId, teamId }: { orgId: number; teamId: number }) {
const [orgMembershipToUserIdForTeamMembers, attributesOfTheOrg] = await Promise.all([
_getOrgMembershipToUserIdForTeam({ orgId, teamId }),
AttributeRepository.findManyByOrgId({ orgId }),
]);
const orgMembershipIds = Array.from(orgMembershipToUserIdForTeamMembers.keys());
// Get all the attributes assigned to the members of the team
const attributesToUsersForTeam = await AttributeToUserRepository.findManyByOrgMembershipIds({
orgMembershipIds,
});
return {
attributesOfTheOrg,
attributesToUsersForTeam,
orgMembershipToUserIdForTeamMembers,
};
}
async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) {
const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] });
const whereClauseForAttributesAssignedToMembersOfTeam = {
options: {
some: {
assignedUsers: {
some: {
member: {
userId,
user: {
teams: {
some: {
teamId,
},
},
},
},
},
},
},
},
};
log.debug(
safeStringify({
teamId,
whereClauseForAttributesAssignedToMembersOfTeam,
})
);
const assignedAttributeOptions = await prisma.attribute.findMany({
where: whereClauseForAttributesAssignedToMembersOfTeam,
select: {
id: true,
name: true,
type: true,
options: {
select: {
id: true,
value: true,
slug: true,
contains: true,
isGroup: true,
},
},
slug: true,
},
});
return assignedAttributeOptions;
}
function _buildAssignmentsForTeam({
attributesToUsersForTeam,
orgMembershipToUserIdForTeamMembers,
attributesOfTheOrg,
}: {
attributesToUsersForTeam: AttributeToUser[];
orgMembershipToUserIdForTeamMembers: Map<OrgMembershipId, UserId>;
attributesOfTheOrg: FullAttribute[];
}) {
return attributesToUsersForTeam
.map((attributeToUser) => {
const orgMembershipId = attributeToUser.memberId;
const userId = orgMembershipToUserIdForTeamMembers.get(orgMembershipId);
if (!userId) {
console.error(`No org membership found for membership id ${orgMembershipId}`);
return null;
}
const attribute = _getAttributeFromAttributeOption({
allAttributesOfTheOrg: attributesOfTheOrg,
attributeOptionId: attributeToUser.attributeOptionId,
});
const attributeOption = _getAttributeOptionFromAttributeOption({
allAttributesOfTheOrg: attributesOfTheOrg,
attributeOptionId: attributeToUser.attributeOptionId,
});
if (!attributeOption || !attribute) {
console.error(
`Attribute option with id ${attributeToUser.attributeOptionId} not found in the organization's attributes`
);
return null;
}
return {
...attributeToUser,
userId,
attribute,
attributeOption,
};
})
.filter((assignment): assignment is NonNullable<typeof assignment> => assignment !== null);
}
export async function getAttributesAssignmentData({ orgId, teamId }: { orgId: number; teamId: number }) {
const { attributesOfTheOrg, attributesToUsersForTeam, orgMembershipToUserIdForTeamMembers } =
await _queryAllData({
orgId,
teamId,
});
const assignmentsForTheTeam = _buildAssignmentsForTeam({
attributesToUsersForTeam,
orgMembershipToUserIdForTeamMembers,
attributesOfTheOrg,
});
const attributesAssignedToTeamMembersWithOptions = _prepareAssignmentData({
attributesOfTheOrg,
assignmentsForTheTeam,
});
return {
attributesOfTheOrg,
attributesAssignedToTeamMembersWithOptions,
};
}
export async function getAttributesForTeam({ teamId }: { teamId: number }) {
const attributes = await getAttributesAssignedToMembersOfTeam({ teamId });
return attributes satisfies Attribute[];
}
export async function getUsersAttributes({ userId, teamId }: { userId: number; teamId: number }) {
return await getAttributesAssignedToMembersOfTeam({ teamId, userId });
}
@@ -1,70 +1,4 @@
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { AttributeToUserRepository } from "../../../server/repository/attributeToUser";
import type { AttributeId } from "../types";
const log = logger.getSubLogger({ prefix: ["entity/attribute"] });
async function findTeamById({ teamId }: { teamId: number }) {
return prisma.team.findUnique({ where: { id: teamId } });
}
/**
* Returns all the options for all the attributes for a team
*/
const findAllAttributeOptions = async ({ teamId }: { teamId: number }) => {
const team = await findTeamById({ teamId });
// A non-org team doesn't have attributes
if (!team || !team.parentId) {
return [];
}
return await prisma.attributeOption.findMany({
where: {
attribute: {
teamId: team.parentId,
},
},
select: {
id: true,
value: true,
slug: true,
contains: true,
isGroup: true,
attribute: true,
},
});
};
/**
* Ensures all attributes are their with all their options(only assigned options) mapped to them
*/
export const findAllAttributesWithTheirOptions = async ({ teamId }: { teamId: number }) => {
const allOptionsOfAllAttributes = await findAllAttributeOptions({ teamId });
const attributeOptionsMap = new Map<
AttributeId,
{
id: string;
value: string;
slug: string;
contains: string[];
isGroup: boolean;
}[]
>();
allOptionsOfAllAttributes.forEach((_attributeOption) => {
const { attribute, ...attributeOption } = _attributeOption;
const existingOptionsArray = attributeOptionsMap.get(attribute.id);
if (!existingOptionsArray) {
attributeOptionsMap.set(attribute.id, [attributeOption]);
} else {
// We already have the options for this attribute
existingOptionsArray.push(attributeOption);
}
});
return attributeOptionsMap;
};
export const getWhereClauseForAttributeOptionsManagedByCalcom = () => {
// Neither created nor updated by DSync
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Attribute_teamId_idx" ON "Attribute"("teamId");
+8
View File
@@ -1618,7 +1618,10 @@ model AttributeOption {
model Attribute {
id String @id @default(uuid())
// This is organization
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
// This is organizationId
teamId Int
type AttributeType
@@ -1635,12 +1638,17 @@ model Attribute {
options AttributeOption[]
isWeightsEnabled Boolean @default(false)
isLocked Boolean @default(false)
@@index([teamId])
}
model AttributeToUser {
id String @id @default(uuid())
// This is the membership of the organization
member Membership @relation(fields: [memberId], references: [id], onDelete: Cascade)
// This is the membership id of the organization
memberId Int
attributeOption AttributeOption @relation(fields: [attributeOptionId], references: [id], onDelete: Cascade)
+2
View File
@@ -227,6 +227,8 @@ async function main() {
});
}
const javascriptEventId = teamEvents.find((event) => event.slug === "team-javascript")?.id;
const salesEventId = teamEvents.find((event) => event.slug === "team-sales")?.id;
const insightsMembers = await prisma.membership.findMany({
where: {
teamId: insightsTeam.id,
+7 -3
View File
@@ -426,7 +426,9 @@ export async function seedAttributes(teamId: number) {
export async function seedRoutingForms(
teamId: number,
userId: number,
attributeRaw: { id: string; options: { id: string; value: string }[] }[]
attributeRaw: { id: string; options: { id: string; value: string }[] }[],
javascriptEventId: number,
salesEventId: number
) {
const seededForm = {
id: "948ae412-d995-4865-885a-48302588de03",
@@ -435,10 +437,12 @@ export async function seedRoutingForms(
{
id: "8a898988-89ab-4cde-b012-31823f708642",
value: "team/insights-team/team-javascript",
eventTypeId: javascriptEventId,
},
{
id: "8b2224b2-89ab-4cde-b012-31823f708642",
value: "team/insights-team/team-sales",
eventTypeId: salesEventId,
},
],
formFieldLocation: {
@@ -481,7 +485,7 @@ export async function seedRoutingForms(
action: {
type: "eventTypeRedirectUrl",
value: seededForm.routes[0].value,
eventTypeId: 1133,
eventTypeId: seededForm.routes[0].eventTypeId,
},
queryValue: {
id: "aaba9988-cdef-4012-b456-719300f53ef8",
@@ -512,7 +516,7 @@ export async function seedRoutingForms(
action: {
type: "eventTypeRedirectUrl",
value: seededForm.routes[1].value,
eventTypeId: 1133,
eventTypeId: seededForm.routes[1].eventTypeId,
},
queryValue: {
id: "aaba9948-cdef-4012-b456-719300f53ef8",
+43 -33
View File
@@ -298,46 +298,56 @@ async function createOrganizationAndAddMembersAndTeams({
})[] = [];
try {
for (const member of orgMembers) {
const newUser = await createUserAndEventType({
user: {
...member.memberData,
password: member.memberData.password.create?.hash,
},
eventTypes: [
{
title: "30min",
slug: "30min",
length: 30,
_bookings: [
const batchSize = 50;
// Process members in batches of in parallel
for (let i = 0; i < orgMembers.length; i += batchSize) {
const batch = orgMembers.slice(i, i + batchSize);
const batchResults = await Promise.all(
batch.map(async (member) => {
const newUser = await createUserAndEventType({
user: {
...member.memberData,
password: member.memberData.password.create?.hash,
},
eventTypes: [
{
uid: uuid(),
title: "30min",
startTime: dayjs().add(1, "day").toDate(),
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
slug: "30min",
length: 30,
_bookings: [
{
uid: uuid(),
title: "30min",
startTime: dayjs().add(1, "day").toDate(),
endTime: dayjs().add(1, "day").add(30, "minutes").toDate(),
},
],
},
],
},
],
});
});
const orgMemberInDb = {
...newUser,
inTeams: member.inTeams,
orgMembership: member.orgMembership,
orgProfile: member.orgProfile,
};
const orgMemberInDb = {
...newUser,
inTeams: member.inTeams,
orgMembership: member.orgMembership,
orgProfile: member.orgProfile,
};
await prisma.tempOrgRedirect.create({
data: {
fromOrgId: 0,
type: RedirectType.User,
from: member.memberData.username,
toUrl: `${getOrgFullOrigin(orgData.slug)}/${member.orgProfile.username}`,
},
});
await prisma.tempOrgRedirect.create({
data: {
fromOrgId: 0,
type: RedirectType.User,
from: member.memberData.username,
toUrl: `${getOrgFullOrigin(orgData.slug)}/${member.orgProfile.username}`,
},
});
orgMembersInDb.push(orgMemberInDb);
return orgMemberInDb;
})
);
orgMembersInDb.push(...batchResults);
}
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
@@ -18,9 +18,14 @@ interface FindTeamMembersMatchingAttributeLogicHandlerOptions {
}
export const findTeamMembersMatchingAttributeLogicHandler = async ({
ctx,
input,
}: FindTeamMembersMatchingAttributeLogicHandlerOptions) => {
const { teamId, attributesQueryValue, _enablePerf, _concurrency } = input;
const orgId = ctx.user.organizationId;
if (!orgId) {
throw new Error("You must be in an organization to use this feature");
}
const {
teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult,
mainAttributeLogicBuildingWarnings: mainWarnings,
@@ -30,6 +35,7 @@ export const findTeamMembersMatchingAttributeLogicHandler = async ({
{
teamId,
attributesQueryValue,
orgId,
},
{
enablePerf: _enablePerf,
@@ -1,6 +1,8 @@
import authedProcedure from "../../../procedures/authedProcedure";
import publicProcedure from "../../../procedures/publicProcedure";
import { router, importHandler } from "../../../trpc";
import { ZFindTeamMembersMatchingAttributeLogicOfRouteInputSchema } from "./findTeamMembersMatchingAttributeLogicOfRoute.schema";
import { ZResponseInputSchema } from "./response.schema";
const NAMESPACE = "routingForms";
@@ -16,4 +18,11 @@ export const routingFormsRouter = router({
);
return handler({ ctx, input });
}),
public: router({
response: publicProcedure.input(ZResponseInputSchema).mutation(async ({ ctx, input }) => {
const handler = await importHandler(namespaced("response"), () => import("./response.handler"));
return handler({ ctx, input });
}),
}),
});
@@ -17,6 +17,7 @@ import { UserRepository } from "@calcom/lib/server/repository/user";
import type { PrismaClient } from "@calcom/prisma";
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 { TRPCError } from "@calcom/trpc/server";
@@ -112,6 +113,14 @@ export const findTeamMembersMatchingAttributeLogicOfRouteHandler = async ({
});
}
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);
const afterEnrichedForm = performance.now();
@@ -189,6 +198,7 @@ export const findTeamMembersMatchingAttributeLogicOfRouteHandler = async ({
attributesQueryValue: route.attributesQueryValue ?? null,
fallbackAttributesQueryValue: route.fallbackAttributesQueryValue ?? null,
teamId: form.teamId,
orgId: formOrgId,
isPreview: !!isPreview,
},
{
@@ -310,17 +320,4 @@ export const findTeamMembersMatchingAttributeLogicOfRouteHandler = async ({
};
};
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 findTeamMembersMatchingAttributeLogicOfRouteHandler;
@@ -0,0 +1,49 @@
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TResponseInputSchema } from "./response.schema";
interface ResponseHandlerOptions {
ctx: {
prisma: PrismaClient;
};
input: TResponseInputSchema;
}
export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => {
const { prisma } = ctx;
const { formId, response, formFillerId, chosenRouteId = null } = input;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
},
include: {
team: {
select: {
parentId: true,
},
},
user: {
select: {
id: true,
email: true,
},
},
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
});
}
const serializableForm = await getSerializableForm({
form,
});
return handleResponse({ response, form: serializableForm, formFillerId, chosenRouteId });
};
export default responseHandler;