diff --git a/PR_TODO.md b/PR_TODO.md new file mode 100644 index 00000000000000..3e0c31c3fa3735 --- /dev/null +++ b/PR_TODO.md @@ -0,0 +1,7 @@ +- [ ] findFirst -> findUnique for Form +- [ ] Abstract out response handling fn and reuse in /router and /response +- [ ] Pass on orgId directly to findTeamMembersMatchingAttributeLogic +- [ ] Slowest query seems to be allAssignedOptionsForTheOrg + - Don't query attributes in it. We query it already in another fn + - Changed the querying approach completely doing some calculations ourselves +- [ ] Measure _getMembershipsWithOrgAndTeam perf with 6000 membership entries for orgId and \ No newline at end of file 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..6e391a1d862f10 --- /dev/null +++ b/packages/app-store/routing-forms/lib/handleResponse.ts @@ -0,0 +1,198 @@ +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 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", + }); + } + + console.log("before findTeamMembersMatchingAttributeLogic"); + const teamMembersMatchingAttributeLogicWithResult = form.teamId + ? await findTeamMembersMatchingAttributeLogic( + { + dynamicFieldValueOperands: { + response, + fields: form.fields || [], + }, + attributesQueryValue: chosenRoute.attributesQueryValue ?? null, + fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue, + teamId: form.teamId, + orgId: form.team?.parentId ?? null, + }, + { + 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 55cdb44d52f8fe..f6160ac80b644a 100644 --- a/packages/app-store/routing-forms/pages/router/getServerSideProps.ts +++ b/packages/app-store/routing-forms/pages/router/getServerSideProps.ts @@ -10,6 +10,7 @@ import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData"; import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "../../getEventTypeRedirectUrl"; import getFieldIdentifier from "../../lib/getFieldIdentifier"; import { getSerializableForm } from "../../lib/getSerializableForm"; +import { handleResponse } from "../../lib/handleResponse"; import { findMatchingRoute } from "../../lib/processRoute"; import { substituteVariables } from "../../lib/substituteVariables"; import { getFieldResponseForJsonLogic } from "../../lib/transformResponse"; @@ -66,7 +67,7 @@ export const getServerSideProps = async function getServerSideProps( const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data; const { currentOrgDomain } = orgDomainConfig(context.req); - let timeTaken: Record = {}; + let timeTaken: Record = {}; const routingFormFindStart = performance.now(); const form = await prisma.app_RoutingForms_Form.findUnique({ @@ -78,6 +79,7 @@ export const getServerSideProps = async function getServerSideProps( select: { id: true, username: true, + email: true, movedToProfileId: true, metadata: true, organization: { @@ -148,18 +150,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, diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts index 507c4c63cc844f..9e4fc14160b5b8 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,182 +13,33 @@ 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, - }, - }, - }, - }); - 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, - }, - }, + const { formId, response, formFillerId, chosenRouteId = null } = input; + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: formId, + }, + include: { + team: true, + 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; - let timeTaken: Record = {}; - if (chosenRoute) { - if (isRouter(chosenRoute)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Chosen route is a router", - }); - } - - console.log("before findTeamMembersMatchingAttributeLogic"); - const teamMembersMatchingAttributeLogicWithResult = form.teamId - ? await findTeamMembersMatchingAttributeLogic( - { - dynamicFieldValueOperands: { - response, - fields: serializableForm.fields || [], - }, - attributesQueryValue: chosenRoute.attributesQueryValue ?? null, - fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue, - teamId: form.teamId, - orgId: form.team.parentId - }, - { - 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: { - 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, - timeTaken, - }; - } 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/raqb/findTeamMembersMatchingAttributeLogic.ts b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts index 3ce854144150b8..0f6e9ffc0c44b1 100644 --- a/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts +++ b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts @@ -4,11 +4,11 @@ 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 { +import { getAllData, getAttributesAssignmentData } from "@calcom/lib/service/attribute/server/getAttributes"; +import type { + Attribute, getTeamMembersWithAttributeOptionValuePerAttribute, - getAttributesForTeam, } from "@calcom/lib/service/attribute/server/getAttributes"; -import type { Attribute } from "@calcom/lib/service/attribute/server/getAttributes"; import { RaqbLogicResult } from "./evaluateRaqbLogic"; import jsonLogic from "./jsonLogic"; @@ -28,7 +28,7 @@ type TeamMemberWithAttributeOptionValuePerAttribute = Awaited< type RunAttributeLogicData = { attributesQueryValue: AttributesQueryValue | null; attributesData: { - attributesForTeam: Attribute[]; + attributesForTheOrg: Attribute[]; teamMembersWithAttributeOptionValuePerAttribute: TeamMemberWithAttributeOptionValuePerAttribute[]; }; dynamicFieldValueOperands?: dynamicFieldValueOperands; @@ -183,13 +183,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 +209,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib const [attributesQueryBuilderConfig, ttgetAttributesQueryBuilderConfigHavingListofLabels] = pf(() => getAttributesQueryBuilderConfigHavingListofLabels({ dynamicFieldValueOperands, - attributes: attributesForTeam, + attributes: attributesForTheOrg, }) ); @@ -272,7 +272,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib attributesQueryValue, attributesQueryBuilderConfig, logic, - attributesForTeam, + attributesForTheOrg, }, }), }; @@ -307,18 +307,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 +322,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 +340,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 +368,7 @@ export async function findTeamMembersMatchingAttributeLogic( const runAttributeLogicData: Omit = { // Change it as per the main/fallback query attributesData: { - attributesForTeam, + attributesForTheOrg, teamMembersWithAttributeOptionValuePerAttribute, }, dynamicFieldValueOperands, @@ -452,7 +456,7 @@ export async function findTeamMembersMatchingAttributeLogic( type: TroubleshooterCase.MATCH_RESULTS_READY, data: { ...troubleshooter.data, - attributesForTeam, + attributesForTheOrg, }, }) : null), diff --git a/packages/lib/service/attribute/server/getAttributes.perf-test.ts b/packages/lib/service/attribute/server/getAttributes.perf-test.ts new file mode 100644 index 00000000000000..1f9d98d3495bd6 --- /dev/null +++ b/packages/lib/service/attribute/server/getAttributes.perf-test.ts @@ -0,0 +1,8 @@ +// import { getAssignedAttributeOptions } from "./getAttributes"; +// import { getAttributesForTeam, getTeamMembersWithAttributeOptionValuePerAttribute } from "./getAttributes"; +import { getAllData } from "./getAttributes"; + +// getAssignedAttributeOptions({ teamId: 17, orgId: 3 }); +getAllData({ teamId: 17, orgId: 3 }); +// Lowest time goes to 271ms +// Before the min time was 442ms diff --git a/packages/lib/service/attribute/server/getAttributes.ts b/packages/lib/service/attribute/server/getAttributes.ts index 8826b2096ff5cc..c735959e35b4e1 100644 --- a/packages/lib/service/attribute/server/getAttributes.ts +++ b/packages/lib/service/attribute/server/getAttributes.ts @@ -5,7 +5,6 @@ import prisma from "@calcom/prisma"; import type { AttributeType } from "@calcom/prisma/enums"; import type { AttributeId } from "../types"; -import { findAllAttributesWithTheirOptions } from "./utils"; type UserId = number; @@ -37,121 +36,66 @@ export type AttributeOptionValueWithType = { }; /** - * Note: assignedAttributeOptions[x].attributeOption isn't unique. It is returned multiple times depending on how many users it is assigned to + * Returns in ~50ms compared to ~100ms for _getAttributeOptionsWithAttributes */ -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, - }, - }, - }, - }, - }, - }, - }, +const queryAttributesWithOptions = async ({ orgId }: { orgId: number }) => { + const start = performance.now(); + // 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, }, - }; - - 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, + options: true, }, }); - return assignedAttributeOptions; -} + const end = performance.now(); + console.log(`queryAttributesWithOptions took ${end - start}ms`); + return result; +}; -export async function getAttributesForTeam({ teamId }: { teamId: number }) { - const attributes = await getAttributesAssignedToMembersOfTeam({ teamId }); - return attributes satisfies Attribute[]; +async function queryAttributeToUserForMembershipIds({ teamMembershipIds }: { teamMembershipIds?: number[] }) { + const start = performance.now(); + const attributesAssignedToTeamMembers = await prisma.attributeToUser.findMany({ + where: { + member: { + // Consider only members of the team + id: { + in: teamMembershipIds, + }, + }, + }, + }); + const end = performance.now(); + console.log(`_getAllAssignedOptionsForTheOrgAndAttributes took ${end - start}ms`); + return attributesAssignedToTeamMembers; } -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 }), - ]); - +function _prepareAssignmentData( + assignedAttributeOptions: { + userId: number; + attributeOption: { + id: string; + value: string; + slug: string; + contains: string[]; + isGroup: boolean; + }; + attribute: { + id: string; + name: string; + type: AttributeType; + }; + }[] +) { const teamMembersThatHaveOptionAssigned = assignedAttributeOptions.reduce((acc, attributeToUser) => { - const { userId } = attributeToUser.member; - const { attribute, ...attributeOption } = attributeToUser.attributeOption; + const userId = attributeToUser.userId; + const attributeOption = attributeToUser.attributeOption; + const attribute = attributeToUser.attribute; if (!acc[userId]) { acc[userId] = { userId, attributes: {} }; @@ -228,6 +172,216 @@ 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; +} + +// 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 +async function _getMembershipsWithOrgAndTeam({ orgId, teamId }: { orgId: number; teamId: number }) { + console.log("_getMembershipsWithOrgAndTeam", orgId, "and teamId", teamId); + const memberships = await prisma.membership.findMany({ + where: { + teamId: { + in: [orgId, teamId], + }, + }, + }); + console.log("Total memberships", memberships.length); + const teamMemberships = memberships.filter((membership) => membership.teamId === teamId); + const orgMemberships = memberships.filter((membership) => membership.teamId === orgId); + return { + teamMemberships, + orgMemberships, + }; +} + +async function _getOrgMembershipToUserId({ orgId, teamId }: { orgId: number; teamId: number }) { + const { orgMemberships, teamMemberships } = await _getMembershipsWithOrgAndTeam({ orgId, teamId }); + type MembershipId = number; + type UserId = number; + + const orgMembershipToUserId = new Map(); + + // For all team members, it builds orgMembershipToUserId map + 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; + } + orgMembershipToUserId.set(orgMembership.id, orgMembership.userId); + }); + + return orgMembershipToUserId; +} + +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 totalStart = performance.now(); + const [orgMembershipToUserId, attributesOfTheOrg] = await Promise.all([ + _getOrgMembershipToUserId({ orgId, teamId }), + queryAttributesWithOptions({ orgId }), + ]); + + // Get all the attributes assigned to the members of the team + const attributesToUsersForTeam = await queryAttributeToUserForMembershipIds({ + teamMembershipIds: Array.from(orgMembershipToUserId.keys()), + }); + const totalEnd = performance.now(); + console.log(`getAllData total took ${totalEnd - totalStart}ms`); + + return { + attributesOfTheOrg, + attributesToUsersForTeam, + orgMembershipToUserId, + }; +} + +export async function getAttributesAssignmentData({ orgId, teamId }: { orgId: number; teamId: number }) { + const { attributesOfTheOrg, attributesToUsersForTeam, orgMembershipToUserId } = await getAllData({ + orgId, + teamId, + }); + + const assignmentsForTheTeam = attributesToUsersForTeam.map((attributeToUser) => { + const userId = orgMembershipToUserId.get(attributeToUser.memberId); + if (!userId) { + throw new Error(`User id not found for membership id ${attributeToUser.memberId}`); + } + 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(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..008af72c2a6210 100644 --- a/packages/lib/service/attribute/server/utils.ts +++ b/packages/lib/service/attribute/server/utils.ts @@ -1,70 +1,81 @@ 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 + * Returns all the options for all the attributes for an organization */ -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, - }, - }); -}; +// const findAllAttributeOptions = async ({ orgId }: { orgId: number }) => { +// const start = performance.now(); +// const result = await prisma.attributeOption.findMany({ +// where: { +// attribute: { +// teamId: orgId, +// }, +// }, +// select: { +// id: true, +// value: true, +// slug: true, +// contains: true, +// isGroup: true, +// attribute: true, +// }, +// }); +// const end = performance.now(); +// console.log(`findAllAttributeOptions took ${end - start}ms`); +// return result; +// }; /** * 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 findAllAttributesWithTheirOptions = async ({ orgId }: { orgId: number }) => { +// const allOptionsOfAllAttributes = await findAllAttributeOptions({ orgId }); +// const attributeOptionsMap = new Map< +// AttributeId, +// { +// id: string; +// value: string; +// slug: string; +// contains: string[]; +// isGroup: boolean; +// }[] +// >(); + +// type AttributeOptionId = string; + +// const attributeOptionToAttributeMap = new Map< +// AttributeOptionId, +// { +// name: string; +// id: string; +// createdAt: Date; +// updatedAt: Date; +// slug: string; +// teamId: number; +// type: AttributeType; +// enabled: boolean; +// usersCanEditRelation: boolean; +// isWeightsEnabled: boolean; +// isLocked: boolean; +// } +// >(); +// allOptionsOfAllAttributes.forEach((_attributeOption) => { +// const { attribute, ...attributeOption } = _attributeOption; +// const existingOptionsArray = attributeOptionsMap.get(attribute.id); +// attributeOptionToAttributeMap.set(attributeOption.id, attribute); +// if (!existingOptionsArray) { +// attributeOptionsMap.set(attribute.id, [attributeOption]); +// } else { +// // We already have the options for this attribute +// existingOptionsArray.push(attributeOption); +// } +// }); +// return { attributeOptionsMap, attributeOptionToAttributeMap }; +// }; export const getWhereClauseForAttributeOptionsManagedByCalcom = () => { // Neither created nor updated by DSync diff --git a/packages/prisma/index.ts b/packages/prisma/index.ts index c94df7b0620a6f..50f18ab649b097 100644 --- a/packages/prisma/index.ts +++ b/packages/prisma/index.ts @@ -16,7 +16,7 @@ const globalForPrisma = global as unknown as { prismaWithClientExtensions: PrismaClientWithExtensions; }; -const loggerLevel = parseInt(process.env.NEXT_PUBLIC_LOGGER_LEVEL ?? "", 10); +const loggerLevel = 2; if (!isNaN(loggerLevel)) { switch (loggerLevel) { 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..e465bcb5de201c 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,6 +1638,8 @@ model Attribute { options AttributeOption[] isWeightsEnabled Boolean @default(false) isLocked Boolean @default(false) + + @@index([teamId]) } model AttributeToUser { 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..8ff0d74e62bf57 100644 --- a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts +++ b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts @@ -189,6 +189,7 @@ export const findTeamMembersMatchingAttributeLogicOfRouteHandler = async ({ attributesQueryValue: route.attributesQueryValue ?? null, fallbackAttributesQueryValue: route.fallbackAttributesQueryValue ?? null, teamId: form.teamId, + orgId: form.team.parentId!, isPreview: !!isPreview, }, {