From 3dff0ca8ee455f8edaec7b83034cc100f9a1b039 Mon Sep 17 00:00:00 2001 From: Hariom Date: Tue, 24 Dec 2024 15:16:28 +0530 Subject: [PATCH] Improve /router performance --- .../lib/getServerTimingHeader.ts | 12 + .../routing-forms/lib/handleResponse.ts | 200 ++++++++++ .../pages/router/getServerSideProps.ts | 75 ++-- .../pages/routing-link/getServerSideProps.ts | 4 +- .../routing-forms/trpc/response.handler.ts | 252 ++----------- packages/lib/bookings/getRoutedUsers.ts | 5 +- ...dTeamMembersMatchingAttributeLogic.test.ts | 88 +++-- .../findTeamMembersMatchingAttributeLogic.ts | 53 +-- packages/lib/server/repository/attribute.ts | 18 + .../lib/server/repository/attributeToUser.ts | 16 + packages/lib/server/repository/eventType.ts | 1 + packages/lib/server/repository/membership.ts | 18 + .../attribute/server/getAttributes.test.ts | 71 ++-- .../service/attribute/server/getAttributes.ts | 353 ++++++++++++------ .../lib/service/attribute/server/utils.ts | 66 ---- .../migration.sql | 2 + packages/prisma/schema.prisma | 8 + packages/prisma/seed-insights.ts | 2 + packages/prisma/seed-utils.ts | 10 +- packages/prisma/seed.ts | 76 ++-- ...amMembersMatchingAttributeLogic.handler.ts | 6 + ...rsMatchingAttributeLogicOfRoute.handler.ts | 23 +- 22 files changed, 779 insertions(+), 580 deletions(-) create mode 100644 packages/app-store/routing-forms/lib/getServerTimingHeader.ts create mode 100644 packages/app-store/routing-forms/lib/handleResponse.ts create mode 100644 packages/prisma/migrations/20241224023424_add_index_team_id_on_attribute/migration.sql diff --git a/packages/app-store/routing-forms/lib/getServerTimingHeader.ts b/packages/app-store/routing-forms/lib/getServerTimingHeader.ts new file mode 100644 index 00000000000000..3e03178951ba04 --- /dev/null +++ b/packages/app-store/routing-forms/lib/getServerTimingHeader.ts @@ -0,0 +1,12 @@ +export function getServerTimingHeader(timeTaken: Record) { + const headerParts = Object.entries(timeTaken) + .map(([key, value]) => { + if (value !== null && value !== undefined) { + return `${key};dur=${value}`; + } + return null; + }) + .filter(Boolean); + + return headerParts.join(", "); +} diff --git a/packages/app-store/routing-forms/lib/handleResponse.ts b/packages/app-store/routing-forms/lib/handleResponse.ts new file mode 100644 index 00000000000000..5b24d22e75f986 --- /dev/null +++ b/packages/app-store/routing-forms/lib/handleResponse.ts @@ -0,0 +1,200 @@ +import { Prisma } from "@prisma/client"; +import { z } from "zod"; + +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 { 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 isRouter from "../lib/isRouter"; +import type { ZResponseInputSchema } from "../trpc/response.schema"; +import { onFormSubmission } from "../trpc/utils"; +import type { FormResponse, SerializableForm } from "../types/types"; + +const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/handleResponse"] }); + +export const handleResponse = async ({ + response, + form, + // formFillerId, + chosenRouteId, +}: { + response: z.infer["response"]; + form: SerializableForm< + App_RoutingForms_Form & { + user: { + id: number; + email: string; + }; + team: { + parentId: number | null; + } | null; + } + >; + formFillerId: string; + chosenRouteId: string | null; +}) => { + try { + 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 = { + ...form, + fields: form.fields, + }; + + const missingFields = serializableFormWithFields.fields + .filter((field) => !(field.required ? response[field.id]?.value : true)) + .map((f) => f.label); + + if (missingFields.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Missing required fields ${missingFields.join(", ")}`, + }); + } + const invalidFields = serializableFormWithFields.fields + .filter((field) => { + const fieldValue = response[field.id]?.value; + // The field isn't required at this point. Validate only if it's set + if (!fieldValue) { + return false; + } + let schema; + if (field.type === "email") { + schema = emailSchema; + } else if (field.type === "phone") { + schema = z.any(); + } else { + schema = z.any(); + } + return !schema.safeParse(fieldValue).success; + }) + .map((f) => ({ label: f.label, type: f.type, value: response[f.id]?.value })); + + if (invalidFields.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid value for fields ${invalidFields + .map((f) => `'${f.label}' with value '${f.value}' should be valid ${f.type}`) + .join(", ")}`, + }); + } + + const settings = RoutingFormSettings.parse(form.settings); + let userWithEmails: string[] = []; + if (form.teamId && settings?.sendUpdatesTo?.length) { + const userEmails = await prisma.membership.findMany({ + where: { + teamId: form.teamId, + userId: { + in: settings.sendUpdatesTo, + }, + }, + select: { + user: { + select: { + email: true, + }, + }, + }, + }); + userWithEmails = userEmails.map((userEmail) => userEmail.user.email); + } + + const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId); + let teamMemberIdsMatchingAttributeLogic: number[] | null = null; + let timeTaken: Record = {}; + if (chosenRoute) { + if (isRouter(chosenRoute)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Chosen route is a router", + }); + } + + 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", + safeStringify({ teamMembersMatchingAttributeLogicWithResult }) + ); + + teamMemberIdsMatchingAttributeLogic = + teamMembersMatchingAttributeLogicWithResult?.teamMembersMatchingAttributeLogic + ? teamMembersMatchingAttributeLogicWithResult.teamMembersMatchingAttributeLogic.map( + (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 dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({ + data: { + // TODO: Why do we not save formFillerId available in the input? + // formFillerId, + formId: form.id, + response: response, + chosenRouteId, + }, + }); + + await onFormSubmission( + { ...serializableFormWithFields, userWithEmails }, + dbFormResponse.response as FormResponse, + dbFormResponse.id, + chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : undefined) : undefined + ); + + return { + isPreview: false, + formResponse: dbFormResponse, + teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic, + attributeRoutingConfig: chosenRoute + ? "attributeRoutingConfig" in chosenRoute + ? chosenRoute.attributeRoutingConfig + : null + : null, + timeTaken, + }; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === "P2002") { + throw new TRPCError({ + code: "CONFLICT", + }); + } + } + throw e; + } +}; diff --git a/packages/app-store/routing-forms/pages/router/getServerSideProps.ts b/packages/app-store/routing-forms/pages/router/getServerSideProps.ts index 4a088e3d467a7d..f0508899548d90 100644 --- a/packages/app-store/routing-forms/pages/router/getServerSideProps.ts +++ b/packages/app-store/routing-forms/pages/router/getServerSideProps.ts @@ -10,6 +10,8 @@ import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData"; import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "../../getEventTypeRedirectUrl"; import getFieldIdentifier from "../../lib/getFieldIdentifier"; import { getSerializableForm } from "../../lib/getSerializableForm"; +import { getServerTimingHeader } from "../../lib/getServerTimingHeader"; +import { handleResponse } from "../../lib/handleResponse"; import { findMatchingRoute } from "../../lib/processRoute"; import { substituteVariables } from "../../lib/substituteVariables"; import { getFieldResponseForJsonLogic } from "../../lib/transformResponse"; @@ -35,27 +37,8 @@ function getNamedParams(params: { appPages: string[] } | undefined) { }; } -export const getServerSideProps = async function getServerSideProps( - context: AppGetServerSidePropsContext, - prisma: AppPrisma -) { - const queryParsed = querySchema.safeParse(context.query); - const { embed } = getNamedParams(context.params); - const pageProps = { - isEmbed: !!embed, - }; - - if (!queryParsed.success) { - log.warn("Error parsing query", queryParsed.error); - return { - notFound: true, - }; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data; - const { currentOrgDomain } = orgDomainConfig(context.req); - - const form = await prisma.app_RoutingForms_Form.findFirst({ +async function findFormById(formId: string, prisma: AppPrisma) { + return await prisma.app_RoutingForms_Form.findUnique({ where: { id: formId, }, @@ -64,6 +47,7 @@ export const getServerSideProps = async function getServerSideProps( select: { id: true, username: true, + email: true, movedToProfileId: true, metadata: true, organization: { @@ -87,6 +71,33 @@ export const getServerSideProps = async function getServerSideProps( }, }, }); +} + +export const getServerSideProps = async function getServerSideProps( + context: AppGetServerSidePropsContext, + prisma: AppPrisma +) { + const queryParsed = querySchema.safeParse(context.query); + const { embed } = getNamedParams(context.params); + const pageProps = { + isEmbed: !!embed, + }; + + if (!queryParsed.success) { + log.warn("Error parsing query", queryParsed.error); + return { + notFound: true, + }; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data; + const { currentOrgDomain } = orgDomainConfig(context.req); + + let timeTaken: Record = {}; + + const formQueryStart = performance.now(); + const form = await findFormById(formId, prisma); + timeTaken.formQuery = performance.now() - formQueryStart; if (!form) { return { @@ -95,20 +106,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 +146,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 +160,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 +176,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 { diff --git a/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts b/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts index ae4fc6c5533395..56fb0247468a5e 100644 --- a/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts +++ b/packages/app-store/routing-forms/pages/routing-link/getServerSideProps.ts @@ -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, }; diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts index 4a87665d769a14..d9aa30f9bf7325 100644 --- a/packages/app-store/routing-forms/trpc/response.handler.ts +++ b/packages/app-store/routing-forms/trpc/response.handler.ts @@ -1,21 +1,9 @@ -import { Prisma } from "@prisma/client"; -import { z } from "zod"; - -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 { RoutingFormSettings } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@calcom/trpc/server"; import { getSerializableForm } from "../lib/getSerializableForm"; -import isRouter from "../lib/isRouter"; -import type { FormResponse } from "../types/types"; +import { handleResponse } from "../lib/handleResponse"; import type { TResponseInputSchema } from "./response.schema"; -import { onFormSubmission } from "./utils"; - -const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/response.handler"] }); interface ResponseHandlerOptions { ctx: { @@ -25,229 +13,37 @@ interface ResponseHandlerOptions { } export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => { const { prisma } = ctx; - try { - const { response, formId, chosenRouteId } = input; - const form = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: formId, - }, - include: { - user: { - select: { - id: true, - email: true, - }, + const { formId, response, formFillerId, chosenRouteId = null } = input; + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: formId, + }, + include: { + team: { + select: { + parentId: true, }, }, - }); - if (!form) { - throw new TRPCError({ - code: "NOT_FOUND", - }); - } - - const serializableForm = await getSerializableForm({ form }); - if (!serializableForm.fields) { - // There is no point in submitting a form that doesn't have fields defined - throw new TRPCError({ - code: "BAD_REQUEST", - }); - } - - const serializableFormWithFields = { - ...serializableForm, - fields: serializableForm.fields, - }; - - const missingFields = serializableFormWithFields.fields - .filter((field) => !(field.required ? response[field.id]?.value : true)) - .map((f) => f.label); - - if (missingFields.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Missing required fields ${missingFields.join(", ")}`, - }); - } - const invalidFields = serializableFormWithFields.fields - .filter((field) => { - const fieldValue = response[field.id]?.value; - // The field isn't required at this point. Validate only if it's set - if (!fieldValue) { - return false; - } - let schema; - if (field.type === "email") { - schema = emailSchema; - } else if (field.type === "phone") { - schema = z.any(); - } else { - schema = z.any(); - } - return !schema.safeParse(fieldValue).success; - }) - .map((f) => ({ label: f.label, type: f.type, value: response[f.id]?.value })); - - if (invalidFields.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid value for fields ${invalidFields - .map((f) => `'${f.label}' with value '${f.value}' should be valid ${f.type}`) - .join(", ")}`, - }); - } - - const settings = RoutingFormSettings.parse(form.settings); - let userWithEmails: string[] = []; - if (form.teamId && settings?.sendUpdatesTo?.length) { - const userEmails = await prisma.membership.findMany({ - where: { - teamId: form.teamId, - userId: { - in: settings.sendUpdatesTo, - }, - }, + user: { select: { - user: { - select: { - email: true, - }, - }, + id: true, + email: true, }, - }); - userWithEmails = userEmails.map((userEmail) => userEmail.user.email); - } - - const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId); - let teamMemberIdsMatchingAttributeLogic: number[] | null = null; - - if (chosenRoute) { - if (isRouter(chosenRoute)) { - throw new TRPCError({ - code: "BAD_REQUEST", - 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; - - moduleLogger.debug( - "teamMembersMatchingAttributeLogic", - safeStringify({ teamMembersMatchingAttributeLogicWithResult }) - ); - - teamMemberIdsMatchingAttributeLogic = - teamMembersMatchingAttributeLogicWithResult?.teamMembersMatchingAttributeLogic - ? teamMembersMatchingAttributeLogicWithResult.teamMembersMatchingAttributeLogic.map( - (member) => member.userId - ) - : null; - } 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, - response: response, - chosenRouteId, }, + }, + }); + + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", }); + } - await onFormSubmission( - { ...serializableFormWithFields, userWithEmails }, - dbFormResponse.response as FormResponse, - dbFormResponse.id, - chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : undefined) : undefined - ); + const serializableForm = await getSerializableForm({ + form, + }); - return { - isPreview: false, - formResponse: dbFormResponse, - teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic, - attributeRoutingConfig: chosenRoute - ? "attributeRoutingConfig" in chosenRoute - ? chosenRoute.attributeRoutingConfig - : null - : null, - }; - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === "P2002") { - throw new TRPCError({ - code: "CONFLICT", - }); - } - } - throw e; - } + return handleResponse({ response, form: serializableForm, formFillerId, chosenRouteId }); }; export default responseHandler; diff --git a/packages/lib/bookings/getRoutedUsers.ts b/packages/lib/bookings/getRoutedUsers.ts index 55ab5d666111f5..6f6e29e7afad9e 100644 --- a/packages/lib/bookings/getRoutedUsers.ts +++ b/packages/lib/bookings/getRoutedUsers.ts @@ -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>({ diff --git a/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts index 638f9ed798da3d..eee677d752c832 100644 --- a/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts +++ b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts @@ -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>; + attributes: { + id: string; + name: string; + type: AttributeType; + slug: string; + options: { + id: string; + value: string; + slug: string; + }[]; + }[]; teamMembersWithAttributeOptionValuePerAttribute: { userId: number; attributes: Record; }[]; }) { - 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, diff --git a/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts index 3ce854144150b8..dfb67458164fdb 100644 --- a/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts +++ b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts @@ -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 ->[number]; + ReturnType +>["attributesAssignedToTeamMembersWithOptions"][number]; type RunAttributeLogicData = { attributesQueryValue: AttributesQueryValue | null; attributesData: { - attributesForTeam: Attribute[]; + attributesForTheOrg: 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: { attributesForTheOrg, teamMembersWithAttributeOptionValuePerAttribute }, dynamicFieldValueOperands, } = data; const { concurrency, enablePerf, enableTroubleshooter } = options; const attributesQueryValue = getAttributesQueryValue({ attributesQueryValue: _attributesQueryValue ?? null, - attributes: attributesForTeam, + attributes: attributesForTheOrg, dynamicFieldValueOperands, }); @@ -209,7 +206,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib const [attributesQueryBuilderConfig, ttgetAttributesQueryBuilderConfigHavingListofLabels] = pf(() => getAttributesQueryBuilderConfigHavingListofLabels({ dynamicFieldValueOperands, - attributes: attributesForTeam, + attributes: attributesForTheOrg, }) ); @@ -272,7 +269,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib attributesQueryValue, attributesQueryBuilderConfig, logic, - attributesForTeam, + attributesForTheOrg, }, }), }; @@ -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, + attributesForTheOrg: 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, + attributesForTheOrg, teamMembersWithAttributeOptionValuePerAttribute, timeTaken: ttGetAttributesForLogic, } = await getAttributesForLogic({ teamId, + orgId, }); const runAttributeLogicOptions = { @@ -364,7 +365,7 @@ export async function findTeamMembersMatchingAttributeLogic( const runAttributeLogicData: Omit = { // Change it as per the main/fallback query attributesData: { - attributesForTeam, + attributesForTheOrg, teamMembersWithAttributeOptionValuePerAttribute, }, dynamicFieldValueOperands, @@ -452,7 +453,7 @@ export async function findTeamMembersMatchingAttributeLogic( type: TroubleshooterCase.MATCH_RESULTS_READY, data: { ...troubleshooter.data, - attributesForTeam, + attributesForTheOrg, }, }) : null), diff --git a/packages/lib/server/repository/attribute.ts b/packages/lib/server/repository/attribute.ts index 0497aeb0b8b087..ac3942b9f163b8 100644 --- a/packages/lib/server/repository/attribute.ts +++ b/packages/lib/server/repository/attribute.ts @@ -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; + } } diff --git a/packages/lib/server/repository/attributeToUser.ts b/packages/lib/server/repository/attributeToUser.ts index 12a4e272f29b10..93f4378d201b8f 100644 --- a/packages/lib/server/repository/attributeToUser.ts +++ b/packages/lib/server/repository/attributeToUser.ts @@ -28,4 +28,20 @@ export class AttributeToUserRepository { }, }); } + + static async findManyByTeamMembershipIds({ teamMembershipIds }: { teamMembershipIds: number[] }) { + if (!teamMembershipIds.length) { + return []; + } + + const attributesAssignedToTeamMembers = await prisma.attributeToUser.findMany({ + where: { + memberId: { + in: teamMembershipIds, + }, + }, + }); + + return attributesAssignedToTeamMembers; + } } diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index 39b24a971435e3..9a52734ed1f677 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -797,6 +797,7 @@ export class EventTypeRepository { id: true, bookingLimits: true, includeManagedEventsInLimits: true, + parentId: true, }, }, parent: { diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index 1073718933bc11..ac343163e99d0d 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -258,4 +258,22 @@ export class MembershipRepository { return membershipsWithSelectedCalendars; } + + static async findMembershipsForOrgAndTeam({ orgId, teamId }: { orgId: number; teamId: number }) { + const memberships = await prisma.membership.findMany({ + where: { + teamId: { + in: [orgId, teamId], + }, + }, + }); + + const teamMemberships = memberships.filter((membership) => membership.teamId === teamId); + const orgMemberships = memberships.filter((membership) => membership.teamId === orgId); + + return { + teamMemberships, + orgMemberships, + }; + } } diff --git a/packages/lib/service/attribute/server/getAttributes.test.ts b/packages/lib/service/attribute/server/getAttributes.test.ts index f4eb615e86a306..a35a53a9c5d230 100644 --- a/packages/lib/service/attribute/server/getAttributes.test.ts +++ b/packages/lib/service/attribute/server/getAttributes.test.ts @@ -5,11 +5,7 @@ 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 { getAttributesForTeam, getAttributesAssignmentData, getUsersAttributes } from "./getAttributes"; // Helper functions to create test data async function createMockAttribute({ @@ -96,15 +92,15 @@ 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, }, }); @@ -157,7 +153,7 @@ describe("getAttributes", () => { }); await createMockAttributeAssignment({ - membershipId: teamMembership.id, + orgMembershipId: teamMembership.id, attributeOptionId: "opt1", }); @@ -172,10 +168,10 @@ 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, teamMembership, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ orgId, teamId: team.id, }); @@ -225,25 +221,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: { @@ -279,7 +276,7 @@ describe("getAttributes", () => { 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, teamMembership, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ orgId, teamId: team.id, }); @@ -304,15 +301,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: { @@ -336,8 +334,9 @@ describe("getAttributes", () => { 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 { teamMembership, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ orgId, teamId: team.id, }); @@ -362,19 +361,20 @@ 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); }); it("should handle multi-select attributes correctly", async () => { const team = await createMockTeam({ orgId }); - const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + const { user, teamMembership, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ orgId, teamId: team.id, }); @@ -393,18 +393,19 @@ 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: [] }, ]); @@ -414,7 +415,7 @@ describe("getAttributes", () => { 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, teamMembership, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ orgId, teamId: team.id, }); @@ -429,7 +430,7 @@ describe("getAttributes", () => { }); await createMockAttributeAssignment({ - membershipId: teamMembership.id, + orgMembershipId: orgMembership.id, attributeOptionId: "opt1", }); diff --git a/packages/lib/service/attribute/server/getAttributes.ts b/packages/lib/service/attribute/server/getAttributes.ts index 8826b2096ff5cc..c2c171ce8b6563 100644 --- a/packages/lib/service/attribute/server/getAttributes.ts +++ b/packages/lib/service/attribute/server/getAttributes.ts @@ -4,8 +4,10 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; 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; @@ -36,122 +38,38 @@ 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 = { +function _prepareAssignmentData({ + assignmentsForTheTeam, + attributesOfTheOrg, +}: { + assignmentsForTheTeam: { + userId: number; + attributeOption: { + id: string; + value: string; + slug: string; + contains: string[]; + isGroup: boolean; + }; + attribute: { + id: string; + name: string; + type: AttributeType; + }; + }[]; + attributesOfTheOrg: { + id: string; 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; + id: string; + value: string; + slug: string; + }[]; + }[]; +}) { + 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 +126,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 +146,207 @@ export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamI } } +function _getAttributeFromAttributeOption({ + allAttributesOfTheOrg, + attributeOptionId, +}: { + allAttributesOfTheOrg: { + id: string; + name: string; + type: AttributeType; + options: { + id: string; + value: string; + slug: string; + }[]; + }[]; + attributeOptionId: string; +}) { + return allAttributesOfTheOrg.find((attribute) => + attribute.options.some((option) => option.id === attributeOptionId) + ); +} + +function _getAttributeOptionFromAttributeOption({ + allAttributesOfTheOrg, + attributeOptionId, +}: { + allAttributesOfTheOrg: { + id: string; + name: string; + type: AttributeType; + options: { + id: string; + value: string; + slug: string; + contains: string[]; + isGroup: boolean; + }[]; + }[]; + attributeOptionId: string; +}) { + 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.findMembershipsForOrgAndTeam({ + orgId, + teamId, + }); + + type MembershipId = number; + type UserId = number; + + const orgMembershipToUserIdForTeamMembers = new Map(); + + /** + * For an organization with 3000 users and 10 teams, with every team having around 300 members, the total memberships we query from DB 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 = orgMemberships.find( + (orgMembership) => orgMembership.userId === 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 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 getAllData({ orgId, teamId }: { orgId: number; teamId: number }) { + // Get all the attributes with their options first. + const [orgMembershipToUserIdForTeamMembers, attributesOfTheOrg] = await Promise.all([ + _getOrgMembershipToUserIdForTeam({ orgId, teamId }), + AttributeRepository.findManyByOrgId({ orgId }), + ]); + + // Get all the attributes assigned to the members of the team + const attributesToUsersForTeam = await AttributeToUserRepository.findManyByTeamMembershipIds({ + teamMembershipIds: Array.from(orgMembershipToUserIdForTeamMembers.keys()), + }); + + return { + attributesOfTheOrg, + attributesToUsersForTeam, + orgMembershipToUserIdForTeamMembers, + }; +} + +export async function getAttributesAssignmentData({ orgId, teamId }: { orgId: number; teamId: number }) { + const { attributesOfTheOrg, attributesToUsersForTeam, orgMembershipToUserIdForTeamMembers } = + await getAllData({ + orgId, + teamId, + }); + + const assignmentsForTheTeam = attributesToUsersForTeam.map((attributeToUser) => { + const orgMembershipId = attributeToUser.memberId; + const userId = orgMembershipToUserIdForTeamMembers.get(orgMembershipId); + if (!userId) { + throw new Error(`No org membership found for membership id ${orgMembershipId}`); + } + const attribute = _getAttributeFromAttributeOption({ + allAttributesOfTheOrg: attributesOfTheOrg, + attributeOptionId: attributeToUser.attributeOptionId, + }); + + const attributeOption = _getAttributeOptionFromAttributeOption({ + allAttributesOfTheOrg: attributesOfTheOrg, + attributeOptionId: attributeToUser.attributeOptionId, + }); + + if (!attributeOption || !attribute) { + throw new Error( + `Attribute option with id ${attributeToUser.attributeOptionId} not found in the organization's attributes` + ); + } + + return { + ...attributeToUser, + userId, + attribute, + attributeOption, + }; + }); + + 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 }); } diff --git a/packages/lib/service/attribute/server/utils.ts b/packages/lib/service/attribute/server/utils.ts index 5bcd7f6942e953..4b6b5fd890cb54 100644 --- a/packages/lib/service/attribute/server/utils.ts +++ b/packages/lib/service/attribute/server/utils.ts @@ -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 diff --git a/packages/prisma/migrations/20241224023424_add_index_team_id_on_attribute/migration.sql b/packages/prisma/migrations/20241224023424_add_index_team_id_on_attribute/migration.sql new file mode 100644 index 00000000000000..0b1cd89b9c0193 --- /dev/null +++ b/packages/prisma/migrations/20241224023424_add_index_team_id_on_attribute/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "Attribute_teamId_idx" ON "Attribute"("teamId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 183f84aea53bac..7d19baf7c9b755 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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) diff --git a/packages/prisma/seed-insights.ts b/packages/prisma/seed-insights.ts index cc68f0024f9c32..8b417a954a0b7c 100644 --- a/packages/prisma/seed-insights.ts +++ b/packages/prisma/seed-insights.ts @@ -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, diff --git a/packages/prisma/seed-utils.ts b/packages/prisma/seed-utils.ts index 0a591ac154d8c1..cfa9cba0bb32a1 100644 --- a/packages/prisma/seed-utils.ts +++ b/packages/prisma/seed-utils.ts @@ -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", diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index c97a39e11c276e..fb6a423a73a3f2 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -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}`, + }, + }); + + return orgMemberInDb; + }) + ); - orgMembersInDb.push(orgMemberInDb); + orgMembersInDb.push(...batchResults); } } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.handler.ts b/packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.handler.ts index 84ca226a4c59f4..dcdf04349e26d6 100644 --- a/packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.handler.ts +++ b/packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts index 407bbd88bf3839..218f86f3053329 100644 --- a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts +++ b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts @@ -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) { - 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;