From b5bec793546b4760fc51d46c6c7aa60d82df30c6 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Fri, 27 Dec 2024 14:26:52 +0530 Subject: [PATCH] perf: /router endpoint perf improvement (#18366) --- apps/web/next.config.js | 4 - apps/web/pages/router/embed.tsx | 6 + .../web/pages/router/index.tsx | 2 +- .../server/lib}/router/getServerSideProps.ts | 110 ++---- apps/web/vercel.json | 3 + .../lib/getServerTimingHeader.ts | 12 + .../handleResponse.ts} | 172 +++----- .../pages/app-routing.config.tsx | 4 - .../pages/routing-link/[...appPages].tsx | 2 +- .../pages/routing-link/getServerSideProps.ts | 4 +- .../app-store/routing-forms/trpc/_router.ts | 8 - 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 | 29 ++ packages/lib/server/repository/routingForm.ts | 39 ++ .../attribute/server/getAttributes.test.ts | 116 ++++-- .../service/attribute/server/getAttributes.ts | 369 ++++++++++++------ .../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 + .../routers/viewer/routing-forms/_router.ts | 9 + ...rsMatchingAttributeLogicOfRoute.handler.ts | 23 +- .../viewer/routing-forms/response.handler.ts | 49 +++ .../viewer/routing-forms}/response.schema.ts | 0 32 files changed, 785 insertions(+), 527 deletions(-) create mode 100644 apps/web/pages/router/embed.tsx rename packages/app-store/routing-forms/pages/router/[...appPages].tsx => apps/web/pages/router/index.tsx (89%) rename {packages/app-store/routing-forms/pages => apps/web/server/lib}/router/getServerSideProps.ts (62%) create mode 100644 packages/app-store/routing-forms/lib/getServerTimingHeader.ts rename packages/app-store/routing-forms/{trpc/response.handler.ts => lib/handleResponse.ts} (52%) create mode 100644 packages/lib/server/repository/routingForm.ts create mode 100644 packages/prisma/migrations/20241224023424_add_index_team_id_on_attribute/migration.sql create mode 100644 packages/trpc/server/routers/viewer/routing-forms/response.handler.ts rename packages/{app-store/routing-forms/trpc => trpc/server/routers/viewer/routing-forms}/response.schema.ts (100%) diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 0399dcfcb0..ef21974e92 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -292,10 +292,6 @@ const nextConfig = { source: "/forms/:formQuery*", destination: "/apps/routing-forms/routing-link/:formQuery*", }, - { - source: "/router/:path*", - destination: "/apps/routing-forms/router/:path*", - }, { source: "/success/:path*", has: [ diff --git a/apps/web/pages/router/embed.tsx b/apps/web/pages/router/embed.tsx new file mode 100644 index 0000000000..7343125aa0 --- /dev/null +++ b/apps/web/pages/router/embed.tsx @@ -0,0 +1,6 @@ +import { getServerSideProps as _getServerSideProps } from "../../server/lib/router/getServerSideProps"; + +export { default } from "./index"; +// We are not using withEmbedSsr here because _getServerSideProps already has the logic to append /embed to the redirect URL and it ends up as /embed/embed +// TODO: We should use withEmbedSsr here and remove the logic from _getServerSideProps +export const getServerSideProps = _getServerSideProps; diff --git a/packages/app-store/routing-forms/pages/router/[...appPages].tsx b/apps/web/pages/router/index.tsx similarity index 89% rename from packages/app-store/routing-forms/pages/router/[...appPages].tsx rename to apps/web/pages/router/index.tsx index 3f9c0b31bf..b9b9956292 100644 --- a/packages/app-store/routing-forms/pages/router/[...appPages].tsx +++ b/apps/web/pages/router/index.tsx @@ -4,7 +4,7 @@ import Head from "next/head"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; -import { getServerSideProps } from "./getServerSideProps"; +import { getServerSideProps } from "../../server/lib/router/getServerSideProps"; export default function Router({ form, message }: inferSSRProps) { return ( diff --git a/packages/app-store/routing-forms/pages/router/getServerSideProps.ts b/apps/web/server/lib/router/getServerSideProps.ts similarity index 62% rename from packages/app-store/routing-forms/pages/router/getServerSideProps.ts rename to apps/web/server/lib/router/getServerSideProps.ts index 4a088e3d46..a79c31e4f3 100644 --- a/packages/app-store/routing-forms/pages/router/getServerSideProps.ts +++ b/apps/web/server/lib/router/getServerSideProps.ts @@ -1,48 +1,42 @@ +import type { GetServerSidePropsContext } from "next"; import { stringify } from "querystring"; import z from "zod"; +import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData"; +import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl"; +import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier"; +import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm"; +import { getServerTimingHeader } from "@calcom/app-store/routing-forms/lib/getServerTimingHeader"; +import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse"; +import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute"; +import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables"; +import { getFieldResponseForJsonLogic } from "@calcom/app-store/routing-forms/lib/transformResponse"; +import { isAuthorizedToViewTheForm } from "@calcom/app-store/routing-forms/pages/routing-link/getServerSideProps"; +import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward"; +import type { FormResponse } from "@calcom/app-store/routing-forms/types/types"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import logger from "@calcom/lib/logger"; +import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm"; import { TRPCError } from "@calcom/trpc/server"; -import type { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppGetServerSideProps"; - -import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData"; -import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "../../getEventTypeRedirectUrl"; -import getFieldIdentifier from "../../lib/getFieldIdentifier"; -import { getSerializableForm } from "../../lib/getSerializableForm"; -import { findMatchingRoute } from "../../lib/processRoute"; -import { substituteVariables } from "../../lib/substituteVariables"; -import { getFieldResponseForJsonLogic } from "../../lib/transformResponse"; -import type { FormResponse } from "../../types/types"; -import { isAuthorizedToViewTheForm } from "../routing-link/getServerSideProps"; -import { getUrlSearchParamsToForward } from "../routing-link/getUrlSearchParamsToForward"; const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] }); const querySchema = z .object({ form: z.string(), - slug: z.string(), - pages: z.array(z.string()), }) .catchall(z.string().or(z.array(z.string()))); -function getNamedParams(params: { appPages: string[] } | undefined) { - const [embed] = params?.appPages || []; - return { - // There might not be item at index 0, so explicit assertion is needed - embed: embed as typeof embed | undefined, - }; +function hasEmbedPath(pathWithQuery: string) { + const onlyPath = pathWithQuery.split("?")[0]; + return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/"); } -export const getServerSideProps = async function getServerSideProps( - context: AppGetServerSidePropsContext, - prisma: AppPrisma -) { +export const getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) { const queryParsed = querySchema.safeParse(context.query); - const { embed } = getNamedParams(context.params); + const isEmbed = hasEmbedPath(context.req.url || ""); const pageProps = { - isEmbed: !!embed, + isEmbed, }; if (!queryParsed.success) { @@ -51,42 +45,16 @@ export const getServerSideProps = async function getServerSideProps( notFound: true, }; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data; + + // Known params reserved by Cal.com are form, embed, layout. We should exclude all of them. + const { form: formId, ...fieldsResponses } = queryParsed.data; const { currentOrgDomain } = orgDomainConfig(context.req); - const form = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: formId, - }, - include: { - user: { - select: { - id: true, - username: true, - movedToProfileId: true, - metadata: true, - organization: { - select: { - slug: true, - }, - }, - }, - }, - team: { - select: { - parentId: true, - parent: { - select: { - slug: true, - }, - }, - slug: true, - metadata: true, - }, - }, - }, - }); + let timeTaken: Record = {}; + + const formQueryStart = performance.now(); + const form = await RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId); + timeTaken.formQuery = performance.now() - formQueryStart; if (!form) { return { @@ -95,20 +63,24 @@ export const getServerSideProps = async function getServerSideProps( } const { UserRepository } = await import("@calcom/lib/server/repository/user"); + const profileEnrichmentStart = performance.now(); const formWithUserProfile = { ...form, user: await UserRepository.enrichUserWithItsProfile({ user: form.user }), }; + timeTaken.profileEnrichment = performance.now() - profileEnrichmentStart; - if (!(await isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain }))) { + if (!isAuthorizedToViewTheForm({ user: formWithUserProfile.user, currentOrgDomain })) { return { notFound: true, }; } + const getSerializableFormStart = performance.now(); const serializableForm = await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserProfile), }); + timeTaken.getSerializableForm = performance.now() - getSerializableFormStart; const response: FormResponse = {}; if (!serializableForm.fields) { @@ -131,18 +103,13 @@ export const getServerSideProps = async function getServerSideProps( const decidedAction = matchingRoute.action; - const { createContext } = await import("@calcom/trpc/server/createContext"); - const ctx = await createContext(context); - - const { default: trpcRouter } = await import("@calcom/app-store/routing-forms/trpc/_router"); - const caller = trpcRouter.createCaller(ctx); const { v4: uuidv4 } = await import("uuid"); let teamMembersMatchingAttributeLogic = null; let formResponseId = null; let attributeRoutingConfig = null; try { - const result = await caller.public.response({ - formId: form.id, + const result = await handleResponse({ + form: serializableForm, formFillerId: uuidv4(), response: response, chosenRouteId: matchingRoute.id, @@ -150,6 +117,10 @@ export const getServerSideProps = async function getServerSideProps( teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic; formResponseId = result.formResponse.id; attributeRoutingConfig = result.attributeRoutingConfig; + timeTaken = { + ...timeTaken, + ...result.timeTaken, + }; } catch (e) { if (e instanceof TRPCError) { return { @@ -162,6 +133,9 @@ export const getServerSideProps = async function getServerSideProps( } } + // TODO: To be done using sentry tracing + console.log("Server-Timing", getServerTimingHeader(timeTaken)); + //TODO: Maybe take action after successful mutation if (decidedAction.type === "customPageMessage") { return { diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 53551d4b5d..2f68a55a73 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -23,6 +23,9 @@ }, "pages/api/trpc/appRoutingForms/[trpc].ts": { "memory": 2048 + }, + "pages/router/embed.tsx": { + "memory": 2048 } } } 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 0000000000..3e03178951 --- /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/trpc/response.handler.ts b/packages/app-store/routing-forms/lib/handleResponse.ts similarity index 52% rename from packages/app-store/routing-forms/trpc/response.handler.ts rename to packages/app-store/routing-forms/lib/handleResponse.ts index 4a87665d76..407fd6db5e 100644 --- a/packages/app-store/routing-forms/trpc/response.handler.ts +++ b/packages/app-store/routing-forms/lib/handleResponse.ts @@ -5,58 +5,55 @@ import { emailSchema } from "@calcom/lib/emailSchema"; import logger from "@calcom/lib/logger"; import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic"; import { safeStringify } from "@calcom/lib/safeStringify"; -import type { PrismaClient } from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; +import type { App_RoutingForms_Form } from "@calcom/prisma/client"; import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@calcom/trpc/server"; +import type { ZResponseInputSchema } from "@calcom/trpc/server/routers/viewer/routing-forms/response.schema"; -import { getSerializableForm } from "../lib/getSerializableForm"; import isRouter from "../lib/isRouter"; -import type { FormResponse } from "../types/types"; -import type { TResponseInputSchema } from "./response.schema"; -import { onFormSubmission } from "./utils"; +import { onFormSubmission } from "../trpc/utils"; +import type { FormResponse, SerializableForm } from "../types/types"; -const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/response.handler"] }); +type Form = SerializableForm< + App_RoutingForms_Form & { + user: { + id: number; + email: string; + }; + team: { + parentId: number | null; + } | null; + } +>; -interface ResponseHandlerOptions { - ctx: { - prisma: PrismaClient; - }; - input: TResponseInputSchema; -} -export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => { - const { prisma } = ctx; +const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/handleResponse"] }); + +export const handleResponse = async ({ + response, + form, + // Unused but probably should be used + // formFillerId, + chosenRouteId, +}: { + response: z.infer["response"]; + form: Form; + formFillerId: string; + chosenRouteId: string | null; +}) => { try { - const { response, formId, chosenRouteId } = input; - const form = await prisma.app_RoutingForms_Form.findFirst({ - where: { - id: formId, - }, - include: { - user: { - select: { - id: true, - email: true, - }, - }, - }, - }); - if (!form) { - throw new TRPCError({ - code: "NOT_FOUND", - }); - } - - const serializableForm = await getSerializableForm({ form }); - if (!serializableForm.fields) { + if (!form.fields) { // There is no point in submitting a form that doesn't have fields defined throw new TRPCError({ code: "BAD_REQUEST", }); } + const formTeamId = form.teamId; + const formOrgId = form.team?.parentId ?? null; const serializableFormWithFields = { - ...serializableForm, - fields: serializableForm.fields, + ...form, + fields: form.fields, }; const missingFields = serializableFormWithFields.fields @@ -120,7 +117,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId); let teamMemberIdsMatchingAttributeLogic: number[] | null = null; - + let timeTaken: Record = {}; if (chosenRoute) { if (isRouter(chosenRoute)) { throw new TRPCError({ @@ -128,17 +125,25 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => message: "Chosen route is a router", }); } - const teamMembersMatchingAttributeLogicWithResult = form.teamId - ? await findTeamMembersMatchingAttributeLogic({ - dynamicFieldValueOperands: { - response, - fields: serializableForm.fields || [], - }, - attributesQueryValue: chosenRoute.attributesQueryValue ?? null, - fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue, - teamId: form.teamId, - }) - : null; + + const teamMembersMatchingAttributeLogicWithResult = + formTeamId && formOrgId + ? await findTeamMembersMatchingAttributeLogic( + { + dynamicFieldValueOperands: { + response, + fields: form.fields || [], + }, + attributesQueryValue: chosenRoute.attributesQueryValue ?? null, + fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue, + teamId: formTeamId, + orgId: formOrgId, + }, + { + enablePerf: true, + } + ) + : null; moduleLogger.debug( "teamMembersMatchingAttributeLogic", @@ -151,71 +156,17 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => (member) => member.userId ) : null; + + timeTaken = teamMembersMatchingAttributeLogicWithResult?.timeTaken ?? {}; } else { // It currently happens for a Router route. Such a route id isn't present in the form.routes } - // const chosenRouteName = `Route ${chosenRouteIndex + 1}`; - - // if (input.isPreview) { - // // Detect if response has value for a field that isn't in the field list - // const formFields = serializableFormWithFields.fields.map((field) => field.id); - // const extraFields = Object.keys(response).filter((fieldId) => !formFields.includes(fieldId)); - // const attributeRoutingConfig = - // "attributeRoutingConfig" in chosenRoute ? chosenRoute.attributeRoutingConfig ?? null : null; - - // let previewData = { - // teamMemberIdsMatchingAttributeLogic, - // chosenRoute: { - // name: chosenRouteName, - // action: "action" in chosenRoute ? chosenRoute.action : null, - // }, - // skipContactOwner: attributeRoutingConfig?.skipContactOwner ?? false, - // warnings: [] as string[], - // errors: [] as string[], - // }; - - // if (extraFields.length > 0) { - // // If response submitted directly through the /response.handler, it is useful to know which fields were non-existent - // // If we reach here through router, all extra fields are already removed from here - // previewData.warnings.push( - // `Response contains values for non-existent fields: ${extraFields.join(", ")}` - // ); - // } - - // // Check for values not present in options for SINGLE_SELECT and MULTISELECT fields - // serializableFormWithFields.fields.forEach((field) => { - // if ( - // field.type !== RoutingFormFieldType.SINGLE_SELECT && - // field.type !== RoutingFormFieldType.MULTI_SELECT - // ) { - // return; - // } - - // const fieldResponse = response[field.id]; - - // if (fieldResponse && fieldResponse.value) { - // const values = Array.isArray(fieldResponse.value) ? fieldResponse.value : [fieldResponse.value]; - // const invalidValues = values.filter( - // (value) => !field.options?.some((option) => option.id === value || option.label === value) - // ); - // if (invalidValues.length > 0) { - // previewData.errors.push(`Invalid value(s) for ${field.label}: ${invalidValues.join(", ")}`); - // } - // } - // }); - - // return { - // isPreview: true, - // previewData, - // formResponse: null, - // teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic, - // }; - // } - const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({ data: { - formId, + // TODO: Why do we not save formFillerId available in the input? + // formFillerId, + formId: form.id, response: response, chosenRouteId, }, @@ -237,6 +188,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => ? chosenRoute.attributeRoutingConfig : null : null, + timeTaken, }; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { @@ -249,5 +201,3 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => throw e; } }; - -export default responseHandler; diff --git a/packages/app-store/routing-forms/pages/app-routing.config.tsx b/packages/app-store/routing-forms/pages/app-routing.config.tsx index 775861594a..0c4d581746 100644 --- a/packages/app-store/routing-forms/pages/app-routing.config.tsx +++ b/packages/app-store/routing-forms/pages/app-routing.config.tsx @@ -9,8 +9,6 @@ import { getServerSideProps as getServerSidePropsForms } from "./forms/getServer import * as LayoutHandler from "./layout-handler/[...appPages]"; import * as Reporting from "./reporting/[...appPages]"; import * as RouteBuilder from "./route-builder/[...appPages]"; -import * as Router from "./router/[...appPages]"; -import { getServerSideProps as getServerSidePropsRouter } from "./router/getServerSideProps"; import * as RoutingLink from "./routing-link/[...appPages]"; import { getServerSideProps as getServerSidePropsRoutingLink } from "./routing-link/getServerSideProps"; @@ -19,7 +17,6 @@ const routingConfig = { "route-builder": RouteBuilder, forms: forms, "routing-link": RoutingLink, - router: Router, reporting: Reporting, layoutHandler: LayoutHandler, }; @@ -29,7 +26,6 @@ export const serverSidePropsConfig: Record = { "form-edit": getServerSidePropsSingleForm, "route-builder": getServerSidePropsSingleForm, "routing-link": getServerSidePropsRoutingLink, - router: getServerSidePropsRouter, reporting: getServerSidePropsSingleForm, }; diff --git a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx index 3f80ee1b3f..17b5a572dd 100644 --- a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx @@ -92,7 +92,7 @@ function RoutingForm({ form, profile, ...restProps }: Props) { sdkActionManager?.fire("__routeChanged", {}); }, [customPageMessage]); - const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({ + const responseMutation = trpc.viewer.routingForms.public.response.useMutation({ onSuccess: async (data) => { const { teamMembersMatchingAttributeLogic, formResponse, attributeRoutingConfig } = data; const chosenRouteWithFormResponse = chosenRouteWithFormResponseRef.current; 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 ae4fc6c553..56fb024746 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/_router.ts b/packages/app-store/routing-forms/trpc/_router.ts index 1e6d158cf4..2ccca20840 100644 --- a/packages/app-store/routing-forms/trpc/_router.ts +++ b/packages/app-store/routing-forms/trpc/_router.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure"; -import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure"; import { router } from "@calcom/trpc/server/trpc"; import { ZDeleteFormInputSchema } from "./deleteForm.schema"; @@ -10,7 +9,6 @@ import { ZFormQueryInputSchema } from "./formQuery.schema"; import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema"; import { forms } from "./procedures/forms"; import { ZReportInputSchema } from "./report.schema"; -import { ZResponseInputSchema } from "./response.schema"; // eslint-disable-next-line @typescript-eslint/ban-types const UNSTABLE_HANDLER_CACHE: Record = {}; @@ -53,12 +51,6 @@ export const ZFormByResponseIdInputSchema = z.object({ export type TFormQueryInputSchema = z.infer; const appRoutingForms = router({ - public: router({ - response: publicProcedure.input(ZResponseInputSchema).mutation(async ({ ctx, input }) => { - const handler = await getHandler("response", () => import("./response.handler")); - return handler({ ctx, input }); - }), - }), forms, formQuery: authedProcedure.input(ZFormQueryInputSchema).query(async ({ ctx, input }) => { const handler = await getHandler("formQuery", () => import("./formQuery.handler")); diff --git a/packages/lib/bookings/getRoutedUsers.ts b/packages/lib/bookings/getRoutedUsers.ts index 55ab5d6661..6f6e29e7af 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 638f9ed798..eee677d752 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 3ce8541441..ee91db434a 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[]; + attributesOfTheOrg: Attribute[]; teamMembersWithAttributeOptionValuePerAttribute: TeamMemberWithAttributeOptionValuePerAttribute[]; }; dynamicFieldValueOperands?: dynamicFieldValueOperands; @@ -183,13 +180,13 @@ async function getLogicResultForAllMembers( async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttributeLogicOptions) { const { attributesQueryValue: _attributesQueryValue, - attributesData: { attributesForTeam, teamMembersWithAttributeOptionValuePerAttribute }, + attributesData: { attributesOfTheOrg, teamMembersWithAttributeOptionValuePerAttribute }, dynamicFieldValueOperands, } = data; const { concurrency, enablePerf, enableTroubleshooter } = options; const attributesQueryValue = getAttributesQueryValue({ attributesQueryValue: _attributesQueryValue ?? null, - attributes: attributesForTeam, + attributes: attributesOfTheOrg, dynamicFieldValueOperands, }); @@ -209,7 +206,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib const [attributesQueryBuilderConfig, ttgetAttributesQueryBuilderConfigHavingListofLabels] = pf(() => getAttributesQueryBuilderConfigHavingListofLabels({ dynamicFieldValueOperands, - attributes: attributesForTeam, + attributes: attributesOfTheOrg, }) ); @@ -272,7 +269,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib attributesQueryValue, attributesQueryBuilderConfig, logic, - attributesForTeam, + attributesOfTheOrg, }, }), }; @@ -307,18 +304,14 @@ async function runFallbackAttributeLogic(data: RunAttributeLogicData, options: R }; } -export async function getAttributesForLogic({ teamId }: { teamId: number }) { - const [[attributesForTeam, teamMembersWithAttributeOptionValuePerAttribute], ttAttributes] = - await asyncPerf(async () => { - return [ - await getAttributesForTeam({ teamId: teamId }), - await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: teamId }), - ]; - }); +export async function getAttributesForLogic({ teamId, orgId }: { teamId: number; orgId: number }) { + const [result, ttAttributes] = await asyncPerf(async () => { + return getAttributesAssignmentData({ teamId, orgId }); + }); return { - attributesForTeam, - teamMembersWithAttributeOptionValuePerAttribute, + attributesOfTheOrg: result.attributesOfTheOrg, + teamMembersWithAttributeOptionValuePerAttribute: result.attributesAssignedToTeamMembersWithOptions, timeTaken: ttAttributes, }; } @@ -326,6 +319,7 @@ export async function getAttributesForLogic({ teamId }: { teamId: number }) { export async function findTeamMembersMatchingAttributeLogic( data: { teamId: number; + orgId: number; attributesQueryValue: AttributesQueryValue | null; fallbackAttributesQueryValue?: AttributesQueryValue | null; dynamicFieldValueOperands?: dynamicFieldValueOperands; @@ -343,15 +337,22 @@ export async function findTeamMembersMatchingAttributeLogic( // Any explicit value being passed should cause fallback to be considered. Even undefined const considerFallback = "fallbackAttributesQueryValue" in data; - const { teamId, attributesQueryValue, fallbackAttributesQueryValue, dynamicFieldValueOperands, isPreview } = - data; + const { + teamId, + orgId, + attributesQueryValue, + fallbackAttributesQueryValue, + dynamicFieldValueOperands, + isPreview, + } = data; const { - attributesForTeam, + attributesOfTheOrg, teamMembersWithAttributeOptionValuePerAttribute, timeTaken: ttGetAttributesForLogic, } = await getAttributesForLogic({ teamId, + orgId, }); const runAttributeLogicOptions = { @@ -364,7 +365,7 @@ export async function findTeamMembersMatchingAttributeLogic( const runAttributeLogicData: Omit = { // Change it as per the main/fallback query attributesData: { - attributesForTeam, + attributesOfTheOrg, teamMembersWithAttributeOptionValuePerAttribute, }, dynamicFieldValueOperands, @@ -452,7 +453,7 @@ export async function findTeamMembersMatchingAttributeLogic( type: TroubleshooterCase.MATCH_RESULTS_READY, data: { ...troubleshooter.data, - attributesForTeam, + attributesOfTheOrg, }, }) : null), diff --git a/packages/lib/server/repository/attribute.ts b/packages/lib/server/repository/attribute.ts index 0497aeb0b8..ac3942b9f1 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 12a4e272f2..098956bc69 100644 --- a/packages/lib/server/repository/attributeToUser.ts +++ b/packages/lib/server/repository/attributeToUser.ts @@ -28,4 +28,20 @@ export class AttributeToUserRepository { }, }); } + + static async findManyByOrgMembershipIds({ orgMembershipIds }: { orgMembershipIds: number[] }) { + if (!orgMembershipIds.length) { + return []; + } + + const attributesAssignedToTeamMembers = await prisma.attributeToUser.findMany({ + where: { + memberId: { + in: orgMembershipIds, + }, + }, + }); + + return attributesAssignedToTeamMembers; + } } diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index 39b24a9714..9a52734ed1 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 1073718933..f336647dde 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -258,4 +258,33 @@ export class MembershipRepository { return membershipsWithSelectedCalendars; } + + static async findMembershipsForBothOrgAndTeam({ orgId, teamId }: { orgId: number; teamId: number }) { + const memberships = await prisma.membership.findMany({ + where: { + teamId: { + in: [orgId, teamId], + }, + }, + }); + + type Membership = (typeof memberships)[number]; + + const { teamMemberships, orgMemberships } = memberships.reduce( + (acc, membership) => { + if (membership.teamId === teamId) { + acc.teamMemberships.push(membership); + } else if (membership.teamId === orgId) { + acc.orgMemberships.push(membership); + } + return acc; + }, + { teamMemberships: [] as Membership[], orgMemberships: [] as Membership[] } + ); + + return { + teamMemberships, + orgMemberships, + }; + } } diff --git a/packages/lib/server/repository/routingForm.ts b/packages/lib/server/repository/routingForm.ts new file mode 100644 index 0000000000..4284df24dd --- /dev/null +++ b/packages/lib/server/repository/routingForm.ts @@ -0,0 +1,39 @@ +import prisma from "@calcom/prisma"; + +export class RoutingFormRepository { + static async findFormByIdIncludeUserTeamAndOrg(formId: string) { + return await prisma.app_RoutingForms_Form.findUnique({ + where: { + id: formId, + }, + include: { + user: { + select: { + id: true, + username: true, + email: true, + movedToProfileId: true, + metadata: true, + organization: { + select: { + slug: true, + }, + }, + }, + }, + team: { + select: { + parentId: true, + parent: { + select: { + slug: true, + }, + }, + slug: true, + metadata: true, + }, + }, + }, + }); + } +} diff --git a/packages/lib/service/attribute/server/getAttributes.test.ts b/packages/lib/service/attribute/server/getAttributes.test.ts index f4eb615e86..7c6801b988 100644 --- a/packages/lib/service/attribute/server/getAttributes.test.ts +++ b/packages/lib/service/attribute/server/getAttributes.test.ts @@ -5,11 +5,8 @@ import { describe, expect, it, beforeEach } from "vitest"; import type { AttributeOption } from "@calcom/prisma/client"; import { AttributeType, MembershipRole } from "@calcom/prisma/enums"; -import { - getAttributesForTeam, - getTeamMembersWithAttributeOptionValuePerAttribute, - getUsersAttributes, -} from "./getAttributes"; +import type { Attribute } from "./getAttributes"; +import { getAttributesForTeam, getAttributesAssignmentData, getUsersAttributes } from "./getAttributes"; // Helper functions to create test data async function createMockAttribute({ @@ -43,7 +40,15 @@ async function createMockAttribute({ })), }); - return attribute; + const result = await prismock.attribute.findUnique({ + where: { id: attribute.id }, + // eslint-disable-next-line @calcom/eslint/no-prisma-include-true + include: { options: true }, + }); + if (!result) { + throw new Error("Some error in creating mock attribute"); + } + return result; } async function createMockUserHavingMembershipWithBothTeamAndOrg({ @@ -96,20 +101,36 @@ async function createMockTeam({ orgId }: { orgId: number | null }) { } async function createMockAttributeAssignment({ - membershipId, + orgMembershipId, attributeOptionId, }: { - membershipId: number; + orgMembershipId: number; attributeOptionId: string; }) { return await prismock.attributeToUser.create({ data: { - memberId: membershipId, + memberId: orgMembershipId, attributeOptionId, }, }); } +async function expectAttributeToMatch(attribute: Attribute, expected: Attribute) { + expect(attribute).toMatchObject({ + id: expected.id, + name: expected.name, + slug: expected.slug, + type: expected.type, + }); +} + +async function expectAttributesToMatch(attributes: Attribute[], expected: Attribute[]) { + expect(attributes).toHaveLength(expected.length); + for (let i = 0; i < attributes.length; i++) { + expectAttributeToMatch(attributes[i], expected[i]); + } +} + describe("getAttributes", () => { const orgId = 1001; @@ -157,7 +178,7 @@ describe("getAttributes", () => { }); await createMockAttributeAssignment({ - membershipId: teamMembership.id, + orgMembershipId: teamMembership.id, attributeOptionId: "opt1", }); @@ -172,15 +193,15 @@ describe("getAttributes", () => { }); }); - describe("getTeamMembersWithAttributeOptionValuePerAttribute", () => { + describe("getAttributesAssignmentData", () => { it("should return team members with their assigned attributes", async () => { const team = await createMockTeam({ orgId }); - const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + const { user, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ orgId, teamId: team.id, }); - await createMockAttribute({ + const attr1 = await createMockAttribute({ orgId, id: "attr1", name: "Department", @@ -199,7 +220,7 @@ describe("getAttributes", () => { ], }); - await createMockAttribute({ + const attr2 = await createMockAttribute({ orgId, id: "attr2", name: "Territory", @@ -212,7 +233,7 @@ describe("getAttributes", () => { }); // Unassigned attribute - await createMockAttribute({ + const attr3 = await createMockAttribute({ orgId, id: "attr3", name: "Attr3", @@ -225,25 +246,26 @@ describe("getAttributes", () => { }); await createMockAttributeAssignment({ - membershipId: teamMembership.id, + orgMembershipId: orgMembership.id, // Assigning a group option that has its sub-options not assigned directly to the user attributeOptionId: "engineering-and-sales-id", }); await createMockAttributeAssignment({ - membershipId: teamMembership.id, + orgMembershipId: orgMembership.id, attributeOptionId: "india-id", }); await createMockAttributeAssignment({ - membershipId: teamMembership.id, + orgMembershipId: orgMembership.id, attributeOptionId: "engineering-id", }); - const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id }); + const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } = + await getAttributesAssignmentData({ teamId: team.id, orgId }); - expect(teamMembers).toHaveLength(1); - expect(teamMembers[0]).toEqual({ + expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1); + expect(attributesAssignedToTeamMembersWithOptions[0]).toEqual({ userId: user.id, attributes: { attr1: { @@ -275,16 +297,18 @@ describe("getAttributes", () => { // attr3 - unassigned isn't here }, }); + + expectAttributesToMatch(attributesOfTheOrg, [attr1, attr2, attr3]); }); it("should return contains correctly for group options that have sub-options not assigned directly to the user", async () => { const team = await createMockTeam({ orgId }); - const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + const { user, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ orgId, teamId: team.id, }); - await createMockAttribute({ + const attr1 = await createMockAttribute({ orgId, id: "attr1", name: "Department", @@ -304,15 +328,16 @@ describe("getAttributes", () => { }); await createMockAttributeAssignment({ - membershipId: teamMembership.id, + orgMembershipId: orgMembership.id, // Assigning a group option that has its sub-options not assigned directly to the user attributeOptionId: "engineering-and-sales-id", }); - const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id }); + const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } = + await getAttributesAssignmentData({ teamId: team.id, orgId }); - expect(teamMembers).toHaveLength(1); - expect(teamMembers[0]).toEqual({ + expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1); + expect(attributesAssignedToTeamMembersWithOptions[0]).toEqual({ userId: user.id, attributes: { attr1: { @@ -332,12 +357,15 @@ describe("getAttributes", () => { }, }, }); + + expectAttributesToMatch(attributesOfTheOrg, [attr1]); }); it("should return no attributes for a different org", async () => { const team = await createMockTeam({ orgId }); - const otherTeam = await createMockTeam({ orgId: 1002 }); - const { teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + const otherOrgId = 1002; + const otherOrgsTeam = await createMockTeam({ orgId: otherOrgId }); + const { orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ orgId, teamId: team.id, }); @@ -362,24 +390,27 @@ describe("getAttributes", () => { }); await createMockAttributeAssignment({ - membershipId: teamMembership.id, + orgMembershipId: orgMembership.id, // Assigning a group option that has its sub-options not assigned directly to the user attributeOptionId: "engineering-and-sales-id", }); - const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: otherTeam.id }); + const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } = + await getAttributesAssignmentData({ teamId: otherOrgsTeam.id, orgId: otherOrgId }); - expect(teamMembers).toHaveLength(0); + expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(0); + // Because created attribute is of other org, it should not be returned + expect(attributesOfTheOrg).toHaveLength(0); }); it("should handle multi-select attributes correctly", async () => { const team = await createMockTeam({ orgId }); - const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + const { orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ orgId, teamId: team.id, }); - await createMockAttribute({ + const attr1 = await createMockAttribute({ orgId, id: "attr1", name: "Skills", @@ -393,28 +424,31 @@ describe("getAttributes", () => { // Assign multiple skills await createMockAttributeAssignment({ - membershipId: teamMembership.id, + orgMembershipId: orgMembership.id, attributeOptionId: "opt1", }); await createMockAttributeAssignment({ - membershipId: teamMembership.id, + orgMembershipId: orgMembership.id, attributeOptionId: "opt2", }); - const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id }); + const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } = + await getAttributesAssignmentData({ teamId: team.id, orgId }); - expect(teamMembers).toHaveLength(1); - expect(teamMembers[0].attributes.attr1.attributeOption).toEqual([ + expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1); + expect(attributesAssignedToTeamMembersWithOptions[0].attributes.attr1.attributeOption).toEqual([ { value: "JavaScript", isGroup: false, contains: [] }, { value: "Python", isGroup: false, contains: [] }, ]); + + expectAttributesToMatch(attributesOfTheOrg, [attr1]); }); }); describe("getUsersAttributes", () => { it("should return attributes assigned to specific user in team", async () => { const team = await createMockTeam({ orgId }); - const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + const { user, orgMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ orgId, teamId: team.id, }); @@ -429,7 +463,7 @@ describe("getAttributes", () => { }); await createMockAttributeAssignment({ - membershipId: teamMembership.id, + orgMembershipId: orgMembership.id, attributeOptionId: "opt1", }); diff --git a/packages/lib/service/attribute/server/getAttributes.ts b/packages/lib/service/attribute/server/getAttributes.ts index 8826b2096f..2e98132ada 100644 --- a/packages/lib/service/attribute/server/getAttributes.ts +++ b/packages/lib/service/attribute/server/getAttributes.ts @@ -2,12 +2,32 @@ import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; +import type { AttributeToUser } from "@calcom/prisma/client"; import type { AttributeType } from "@calcom/prisma/enums"; +import { AttributeRepository } from "../../../server/repository/attribute"; +import { AttributeToUserRepository } from "../../../server/repository/attributeToUser"; +import { MembershipRepository } from "../../../server/repository/membership"; import type { AttributeId } from "../types"; -import { findAllAttributesWithTheirOptions } from "./utils"; type UserId = number; +type OrgMembershipId = number; +type AttributeOptionId = string; +type AssignmentForTheTeam = { + userId: number; + attributeOption: { + id: string; + value: string; + slug: string; + contains: string[]; + isGroup: boolean; + }; + attribute: { + id: string; + name: string; + type: AttributeType; + }; +}; export type Attribute = { name: string; @@ -21,6 +41,20 @@ export type Attribute = { }[]; }; +type FullAttribute = { + name: string; + slug: string; + type: AttributeType; + id: string; + options: { + id: string; + value: string; + slug: string; + isGroup: boolean; + contains: string[]; + }[]; +}; + export type AttributeOptionValue = { value: string; isGroup: boolean; @@ -36,122 +70,17 @@ export type AttributeOptionValueWithType = { attributeOption: AttributeOptionValue | AttributeOptionValue[]; }; -/** - * Note: assignedAttributeOptions[x].attributeOption isn't unique. It is returned multiple times depending on how many users it is assigned to - */ -async function getAssignedAttributeOptions({ teamId }: { teamId: number }) { - const log = logger.getSubLogger({ prefix: ["getAssignedAttributeOptions"] }); - const whereClauseForAssignment = { - member: { - user: { - teams: { - some: { - teamId, - }, - }, - }, - }, - }; - - log.debug( - safeStringify({ - teamId, - whereClauseForAssignment, - }) - ); - - const assignedAttributeOptions = await prisma.attributeToUser.findMany({ - where: whereClauseForAssignment, - select: { - member: { - select: { - userId: true, - }, - }, - attributeOption: { - select: { - id: true, - value: true, - slug: true, - contains: true, - isGroup: true, - attribute: { - select: { id: true, name: true, type: true, slug: true }, - }, - }, - }, - }, - }); - - log.debug("Returned assignedAttributeOptions", safeStringify({ assignedAttributeOptions })); - return assignedAttributeOptions; -} - -async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) { - const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] }); - - const whereClauseForAttributesAssignedToMembersOfTeam = { - options: { - some: { - assignedUsers: { - some: { - member: { - userId, - user: { - teams: { - some: { - teamId, - }, - }, - }, - }, - }, - }, - }, - }, - }; - - log.debug( - safeStringify({ - teamId, - whereClauseForAttributesAssignedToMembersOfTeam, - }) - ); - - const assignedAttributeOptions = await prisma.attribute.findMany({ - where: whereClauseForAttributesAssignedToMembersOfTeam, - select: { - id: true, - name: true, - type: true, - options: { - select: { - id: true, - value: true, - slug: true, - }, - }, - slug: true, - }, - }); - return assignedAttributeOptions; -} - -export async function getAttributesForTeam({ teamId }: { teamId: number }) { - const attributes = await getAttributesAssignedToMembersOfTeam({ teamId }); - return attributes satisfies Attribute[]; -} - -export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamId }: { teamId: number }) { - const [assignedAttributeOptions, allAttributesWithTheirOptions] = await Promise.all([ - getAssignedAttributeOptions({ teamId }), - // We need to fetch even the unassigned options, to know what all sub-options are there in an option group. Because it is possible that sub-options aren't assigned directly to the user - findAllAttributesWithTheirOptions({ teamId }), - ]); - - const teamMembersThatHaveOptionAssigned = assignedAttributeOptions.reduce((acc, attributeToUser) => { - const { userId } = attributeToUser.member; - const { attribute, ...attributeOption } = attributeToUser.attributeOption; +function _prepareAssignmentData({ + assignmentsForTheTeam, + attributesOfTheOrg, +}: { + assignmentsForTheTeam: AssignmentForTheTeam[]; + attributesOfTheOrg: Attribute[]; +}) { + const teamMembersThatHaveOptionAssigned = assignmentsForTheTeam.reduce((acc, attributeToUser) => { + const userId = attributeToUser.userId; + const attributeOption = attributeToUser.attributeOption; + const attribute = attributeToUser.attribute; if (!acc[userId]) { acc[userId] = { userId, attributes: {} }; @@ -208,7 +137,7 @@ export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamI }) { return contains .map((optionId) => { - const allOptions = allAttributesWithTheirOptions.get(attribute.id); + const allOptions = attributesOfTheOrg.find((_attribute) => _attribute.id === attribute.id)?.options; const option = allOptions?.find((option) => option.id === optionId); if (!option) { console.error( @@ -228,6 +157,210 @@ export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamI } } +function _getAttributeFromAttributeOption({ + allAttributesOfTheOrg, + attributeOptionId, +}: { + allAttributesOfTheOrg: Attribute[]; + attributeOptionId: AttributeOptionId; +}) { + return allAttributesOfTheOrg.find((attribute) => + attribute.options.some((option) => option.id === attributeOptionId) + ); +} + +function _getAttributeOptionFromAttributeOption({ + allAttributesOfTheOrg, + attributeOptionId, +}: { + allAttributesOfTheOrg: FullAttribute[]; + attributeOptionId: AttributeOptionId; +}) { + const matchingOption = allAttributesOfTheOrg.reduce((found, attribute) => { + if (found) return found; + return attribute.options.find((option) => option.id === attributeOptionId) || null; + }, null as null | (typeof allAttributesOfTheOrg)[number]["options"][number]); + return matchingOption; +} + +async function _getOrgMembershipToUserIdForTeam({ orgId, teamId }: { orgId: number; teamId: number }) { + const { orgMemberships, teamMemberships } = await MembershipRepository.findMembershipsForBothOrgAndTeam({ + orgId, + teamId, + }); + + // Using map for performance lookup as it matters in the below loop working with 1000s of records + const orgMembershipsByUserId = new Map(orgMemberships.map((m) => [m.userId, m])); + + /** + * Holds the records of orgMembershipId to userId for the sub-team's members only. + */ + const orgMembershipToUserIdForTeamMembers = new Map(); + + /** + * For an organization with 3000 users and 10 teams, with every team having around 300 members, the total memberships we get for a team are 3000+300 = 3300 + * So, these are not a lot of records and we could afford to do in memory computations on them. + */ + teamMemberships.forEach((teamMembership) => { + const orgMembership = orgMembershipsByUserId.get(teamMembership.userId); + if (!orgMembership) { + console.error( + `Org membership not found for userId ${teamMembership.userId} in the organization's memberships` + ); + return; + } + orgMembershipToUserIdForTeamMembers.set(orgMembership.id, orgMembership.userId); + }); + + return orgMembershipToUserIdForTeamMembers; +} + +async function _queryAllData({ orgId, teamId }: { orgId: number; teamId: number }) { + const [orgMembershipToUserIdForTeamMembers, attributesOfTheOrg] = await Promise.all([ + _getOrgMembershipToUserIdForTeam({ orgId, teamId }), + AttributeRepository.findManyByOrgId({ orgId }), + ]); + + const orgMembershipIds = Array.from(orgMembershipToUserIdForTeamMembers.keys()); + + // Get all the attributes assigned to the members of the team + const attributesToUsersForTeam = await AttributeToUserRepository.findManyByOrgMembershipIds({ + orgMembershipIds, + }); + + return { + attributesOfTheOrg, + attributesToUsersForTeam, + orgMembershipToUserIdForTeamMembers, + }; +} + +async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) { + const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] }); + + const whereClauseForAttributesAssignedToMembersOfTeam = { + options: { + some: { + assignedUsers: { + some: { + member: { + userId, + user: { + teams: { + some: { + teamId, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + log.debug( + safeStringify({ + teamId, + whereClauseForAttributesAssignedToMembersOfTeam, + }) + ); + + const assignedAttributeOptions = await prisma.attribute.findMany({ + where: whereClauseForAttributesAssignedToMembersOfTeam, + select: { + id: true, + name: true, + type: true, + options: { + select: { + id: true, + value: true, + slug: true, + contains: true, + isGroup: true, + }, + }, + slug: true, + }, + }); + + return assignedAttributeOptions; +} + +function _buildAssignmentsForTeam({ + attributesToUsersForTeam, + orgMembershipToUserIdForTeamMembers, + attributesOfTheOrg, +}: { + attributesToUsersForTeam: AttributeToUser[]; + orgMembershipToUserIdForTeamMembers: Map; + attributesOfTheOrg: FullAttribute[]; +}) { + return attributesToUsersForTeam + .map((attributeToUser) => { + const orgMembershipId = attributeToUser.memberId; + const userId = orgMembershipToUserIdForTeamMembers.get(orgMembershipId); + if (!userId) { + console.error(`No org membership found for membership id ${orgMembershipId}`); + return null; + } + const attribute = _getAttributeFromAttributeOption({ + allAttributesOfTheOrg: attributesOfTheOrg, + attributeOptionId: attributeToUser.attributeOptionId, + }); + + const attributeOption = _getAttributeOptionFromAttributeOption({ + allAttributesOfTheOrg: attributesOfTheOrg, + attributeOptionId: attributeToUser.attributeOptionId, + }); + + if (!attributeOption || !attribute) { + console.error( + `Attribute option with id ${attributeToUser.attributeOptionId} not found in the organization's attributes` + ); + return null; + } + + return { + ...attributeToUser, + userId, + attribute, + attributeOption, + }; + }) + .filter((assignment): assignment is NonNullable => assignment !== null); +} + +export async function getAttributesAssignmentData({ orgId, teamId }: { orgId: number; teamId: number }) { + const { attributesOfTheOrg, attributesToUsersForTeam, orgMembershipToUserIdForTeamMembers } = + await _queryAllData({ + orgId, + teamId, + }); + + const assignmentsForTheTeam = _buildAssignmentsForTeam({ + attributesToUsersForTeam, + orgMembershipToUserIdForTeamMembers, + attributesOfTheOrg, + }); + + const attributesAssignedToTeamMembersWithOptions = _prepareAssignmentData({ + attributesOfTheOrg, + assignmentsForTheTeam, + }); + + return { + attributesOfTheOrg, + attributesAssignedToTeamMembersWithOptions, + }; +} + +export async function getAttributesForTeam({ teamId }: { teamId: number }) { + const attributes = await getAttributesAssignedToMembersOfTeam({ teamId }); + return attributes satisfies Attribute[]; +} + export async function getUsersAttributes({ userId, teamId }: { userId: number; teamId: number }) { return await getAttributesAssignedToMembersOfTeam({ teamId, userId }); } diff --git a/packages/lib/service/attribute/server/utils.ts b/packages/lib/service/attribute/server/utils.ts index 5bcd7f6942..4b6b5fd890 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 0000000000..0b1cd89b9c --- /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 183f84aea5..7d19baf7c9 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 cc68f0024f..8b417a954a 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 0a591ac154..cfa9cba0bb 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 c97a39e11c..fb6a423a73 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}`, + }, + }); - orgMembersInDb.push(orgMemberInDb); + return 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 84ca226a4c..dcdf04349e 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/_router.ts b/packages/trpc/server/routers/viewer/routing-forms/_router.ts index 7cb5e9413b..a41ccccee8 100644 --- a/packages/trpc/server/routers/viewer/routing-forms/_router.ts +++ b/packages/trpc/server/routers/viewer/routing-forms/_router.ts @@ -1,6 +1,8 @@ import authedProcedure from "../../../procedures/authedProcedure"; +import publicProcedure from "../../../procedures/publicProcedure"; import { router, importHandler } from "../../../trpc"; import { ZFindTeamMembersMatchingAttributeLogicOfRouteInputSchema } from "./findTeamMembersMatchingAttributeLogicOfRoute.schema"; +import { ZResponseInputSchema } from "./response.schema"; const NAMESPACE = "routingForms"; @@ -16,4 +18,11 @@ export const routingFormsRouter = router({ ); return handler({ ctx, input }); }), + + public: router({ + response: publicProcedure.input(ZResponseInputSchema).mutation(async ({ ctx, input }) => { + const handler = await importHandler(namespaced("response"), () => import("./response.handler")); + return handler({ ctx, input }); + }), + }), }); 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 407bbd88bf..218f86f305 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; diff --git a/packages/trpc/server/routers/viewer/routing-forms/response.handler.ts b/packages/trpc/server/routers/viewer/routing-forms/response.handler.ts new file mode 100644 index 0000000000..6b4da77b2a --- /dev/null +++ b/packages/trpc/server/routers/viewer/routing-forms/response.handler.ts @@ -0,0 +1,49 @@ +import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm"; +import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse"; +import type { PrismaClient } from "@calcom/prisma"; +import { TRPCError } from "@calcom/trpc/server"; + +import type { TResponseInputSchema } from "./response.schema"; + +interface ResponseHandlerOptions { + ctx: { + prisma: PrismaClient; + }; + input: TResponseInputSchema; +} +export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => { + const { prisma } = ctx; + const { formId, response, formFillerId, chosenRouteId = null } = input; + const form = await prisma.app_RoutingForms_Form.findFirst({ + where: { + id: formId, + }, + include: { + team: { + select: { + parentId: true, + }, + }, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }); + + if (!form) { + throw new TRPCError({ + code: "NOT_FOUND", + }); + } + + const serializableForm = await getSerializableForm({ + form, + }); + + return handleResponse({ response, form: serializableForm, formFillerId, chosenRouteId }); +}; + +export default responseHandler; diff --git a/packages/app-store/routing-forms/trpc/response.schema.ts b/packages/trpc/server/routers/viewer/routing-forms/response.schema.ts similarity index 100% rename from packages/app-store/routing-forms/trpc/response.schema.ts rename to packages/trpc/server/routers/viewer/routing-forms/response.schema.ts