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