diff --git a/packages/app-store/routing-forms/TODO.md b/packages/app-store/routing-forms/TODO.md index 1b25c29786..e7ce90225f 100644 --- a/packages/app-store/routing-forms/TODO.md +++ b/packages/app-store/routing-forms/TODO.md @@ -31,15 +31,20 @@ - [ ] getAttributes - [ ] Querying Logic Test - [ ] getRoutedUsers tests +- [ ] getAvailableSlots + - [ ] should use routedTeamMemberIds in availability query -### Documentation + +### Documentation / Tooltip - [ ] Document well that the option label in Routing Form Field and Attribute Option label must be same to connect them. - Due to the connection requirement b/w Attribute Option and Field Option, we use label(lowercased) to match attributes instead of attribute slug +- [ ] Fixed hosts of the event will be included through attribute routing as well. They aren't tested for attribute routing logic. ### V2.0 - [ ] Fallback for when no team member matches the criteria. - Fallback will be attributes query builder that would match a different set of users. Though the booking will use the team members assigned to the event type, it might be better to be able to identify such a scenario and use a different set of users. It also makes it easy to identify when the fallback scenario happens. - [ ] cal.routedTeamMembersIds query param - Could possible become a big payload and possibly break the URL limit. We could work on a short-lived row in a table that would hold that info and we pass the id of that row only in query param. handleNewBooking can then retrieve the routedTeamMembersIds from that short-lived row and delete the entry after successfully creating a booking. +- [ ] Better ability to test with contact owner from Routing Form Preview itself(if possible). Right now, we need to test the entire booking flow to verify that. ## TODO - Attributes diff --git a/packages/app-store/routing-forms/__tests__/getEventTypeRedirectUrl.test.ts b/packages/app-store/routing-forms/__tests__/getEventTypeRedirectUrl.test.ts new file mode 100644 index 0000000000..0e9e6f724b --- /dev/null +++ b/packages/app-store/routing-forms/__tests__/getEventTypeRedirectUrl.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; + +import { CAL_URL } from "@calcom/lib/constants"; + +import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl"; + +describe("getAbsoluteEventTypeRedirectUrl", () => { + const defaultForm = { + team: null, + nonOrgUsername: null, + nonOrgTeamslug: null, + userOrigin: "https://user.cal.com", + teamOrigin: "https://team.cal.com", + }; + + const defaultParams = { + eventTypeRedirectUrl: "user/event", + form: defaultForm, + allURLSearchParams: new URLSearchParams(), + isEmbed: false, + }; + + it("should return CAL_URL for non-migrated user", () => { + const result = getAbsoluteEventTypeRedirectUrl({ + ...defaultParams, + eventTypeRedirectUrl: "user/event", + form: { ...defaultForm, nonOrgUsername: "user" }, + }); + expect(result).toBe(`${CAL_URL}/user/event?`); + }); + + it("should return user origin for migrated user", () => { + const result = getAbsoluteEventTypeRedirectUrl(defaultParams); + expect(result).toBe("https://user.cal.com/user/event?"); + }); + + it("should return CAL_URL for non-migrated team", () => { + const result = getAbsoluteEventTypeRedirectUrl({ + ...defaultParams, + eventTypeRedirectUrl: "team/team1/event", + form: { ...defaultForm, nonOrgTeamslug: "team1" }, + }); + expect(result).toBe(`${CAL_URL}/team/team1/event?`); + }); + + it("should return team origin for migrated team", () => { + const result = getAbsoluteEventTypeRedirectUrl({ + ...defaultParams, + eventTypeRedirectUrl: "team/team1/event", + }); + expect(result).toBe("https://team.cal.com/team/team1/event?"); + }); + + it("should append URL search params", () => { + const result = getAbsoluteEventTypeRedirectUrl({ + ...defaultParams, + allURLSearchParams: new URLSearchParams("foo=bar&baz=qux"), + }); + expect(result).toBe("https://user.cal.com/user/event?foo=bar&baz=qux"); + }); + + it("should append /embed for embedded views", () => { + const result = getAbsoluteEventTypeRedirectUrl({ + ...defaultParams, + allURLSearchParams: new URLSearchParams("foo=bar&baz=qux"), + isEmbed: true, + }); + expect(result).toBe("https://user.cal.com/user/event/embed?foo=bar&baz=qux"); + }); + + it("should throw an error if invalid team event redirect URL is provided", () => { + expect(() => + getAbsoluteEventTypeRedirectUrl({ + ...defaultParams, + eventTypeRedirectUrl: "team/", + }) + ).toThrow("eventTypeRedirectUrl must have username or teamSlug"); + }); +}); diff --git a/packages/app-store/routing-forms/test/lib/jsonLogicToPrisma.test.ts b/packages/app-store/routing-forms/__tests__/jsonLogicToPrisma.test.ts similarity index 99% rename from packages/app-store/routing-forms/test/lib/jsonLogicToPrisma.test.ts rename to packages/app-store/routing-forms/__tests__/jsonLogicToPrisma.test.ts index fb46caa2d0..e7b6636644 100644 --- a/packages/app-store/routing-forms/test/lib/jsonLogicToPrisma.test.ts +++ b/packages/app-store/routing-forms/__tests__/jsonLogicToPrisma.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, afterEach, vi } from "vitest"; -import { jsonLogicToPrisma } from "../../jsonLogicToPrisma"; +import { jsonLogicToPrisma } from "../jsonLogicToPrisma"; afterEach(() => { vi.resetAllMocks(); diff --git a/packages/app-store/routing-forms/components/SingleForm.tsx b/packages/app-store/routing-forms/components/SingleForm.tsx index 0ecf14ec43..f0627897d6 100644 --- a/packages/app-store/routing-forms/components/SingleForm.tsx +++ b/packages/app-store/routing-forms/components/SingleForm.tsx @@ -259,7 +259,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF function testRouting() { const route = findMatchingRoute({ form, response }); - + if (route?.action?.type === "eventTypeRedirectUrl") { setEventTypeUrl( enrichedWithUserProfileForm @@ -271,9 +271,9 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF : "" ); } - + setChosenRoute(route || null); - + if (!route) return; findTeamMembersMatchingAttributeLogicMutation.mutate({ diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts index 9a6c4455e9..7465193343 100644 --- a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts @@ -1,6 +1,5 @@ // This is taken from "react-awesome-query-builder/lib/config/basic"; import type { - Operators as RAQBOperators, Conjunction as RAQBConjunction, Widget as RAQBWidget, Type as RAQBType, @@ -8,11 +7,14 @@ import type { Operator as RAQBOperator, } from "react-awesome-query-builder"; -export type Conjunction = Omit; +export type Conjunction = Omit< + RAQBConjunction, + "formatConj" | "sqlFormatConj" | "spelFormatConj" | "mongoConj" +>; export type Conjunctions = Record; export type Operator = RAQBOperator & { _jsonLogicIsExclamationOp?: boolean; -} +}; export type Operators = Record; export type WidgetWithoutFactory = Omit & { type: string; @@ -450,4 +452,4 @@ export default { widgets, types, settings, -}; \ No newline at end of file +}; diff --git a/packages/app-store/routing-forms/getEventTypeRedirectUrl.ts b/packages/app-store/routing-forms/getEventTypeRedirectUrl.ts index fb82008f34..d74d195fc7 100644 --- a/packages/app-store/routing-forms/getEventTypeRedirectUrl.ts +++ b/packages/app-store/routing-forms/getEventTypeRedirectUrl.ts @@ -1,5 +1,5 @@ import { CAL_URL } from "@calcom/lib/constants"; -import { Ensure } from "@calcom/types/utils"; +import type { Ensure } from "@calcom/types/utils"; function getUserAndEventTypeSlug(eventTypeRedirectUrl: string) { if (eventTypeRedirectUrl.startsWith("/")) { @@ -14,6 +14,8 @@ function getUserAndEventTypeSlug(eventTypeRedirectUrl: string) { } /** + * @param eventTypeRedirectUrl - The event path without a starting slash + * * Handles the following cases * 1. A team form where the team isn't a sub-team * 1.1 A team form where team isn't a sub-team and the user is migrated. i.e. User has been migrated but not the team diff --git a/packages/app-store/routing-forms/lib/InitialConfig.tsx b/packages/app-store/routing-forms/lib/InitialConfig.tsx index 9481cdeafb..78a6d9fc1e 100644 --- a/packages/app-store/routing-forms/lib/InitialConfig.tsx +++ b/packages/app-store/routing-forms/lib/InitialConfig.tsx @@ -1,4 +1,7 @@ -import { FormFieldsBaseConfig, AttributesBaseConfig } from "../components/react-awesome-query-builder/config/config"; +import { + FormFieldsBaseConfig, + AttributesBaseConfig, +} from "../components/react-awesome-query-builder/config/config"; export const FormFieldsInitialConfig = FormFieldsBaseConfig; export const AttributesInitialConfig = AttributesBaseConfig; diff --git a/packages/app-store/routing-forms/lib/evaluateRaqbLogic.ts b/packages/app-store/routing-forms/lib/evaluateRaqbLogic.ts index acaa40f2ee..3e48a3cfcc 100644 --- a/packages/app-store/routing-forms/lib/evaluateRaqbLogic.ts +++ b/packages/app-store/routing-forms/lib/evaluateRaqbLogic.ts @@ -1,8 +1,11 @@ "use client"; import { Utils as QbUtils, type JsonTree } from "react-awesome-query-builder"; -import jsonLogic from "./jsonLogic"; + import { safeStringify } from "@calcom/lib/safeStringify"; + +import jsonLogic from "./jsonLogic"; + export const enum RaqbLogicResult { MATCH = "MATCH", NO_MATCH = "NO_MATCH", @@ -30,7 +33,10 @@ export const evaluateRaqbLogic = ({ if (beStrictWithEmptyLogic && queryValue.children1 && Object.keys(queryValue.children1).length > 0) { throw new Error("Couldn't build the logic from the query value"); } - console.log("No logic found", safeStringify({ queryValue, queryBuilderConfigFields: queryBuilderConfig.fields })); + console.log( + "No logic found", + safeStringify({ queryValue, queryBuilderConfigFields: queryBuilderConfig.fields }) + ); // If no logic is provided, then consider it a match return RaqbLogicResult.LOGIC_NOT_FOUND_SO_MATCHED; } diff --git a/packages/app-store/routing-forms/lib/getAttributes.ts b/packages/app-store/routing-forms/lib/getAttributes.ts index 3fd94e660d..50db6c5f79 100644 --- a/packages/app-store/routing-forms/lib/getAttributes.ts +++ b/packages/app-store/routing-forms/lib/getAttributes.ts @@ -2,10 +2,11 @@ import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; + import type { Attribute } from "../types/types"; async function getAttributeToUserWithMembershipAndAttributesForTeam({ teamId }: { teamId: number }) { - let log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] }); + const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] }); const whereClauseForAttributesAssignedToMembersOfTeam = { member: { @@ -52,9 +53,9 @@ async function getAttributeToUserWithMembershipAndAttributesForTeam({ teamId }: } async function getAttributesAssignedToMembersOfTeam({ teamId }: { teamId: number }) { - let log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] }); + const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] }); - const whereClauseForAttributesAssignedToMembersOfTeam = { + const whereClauseForAttributesAssignedToMembersOfTeam = { options: { some: { assignedUsers: { @@ -72,7 +73,7 @@ async function getAttributesAssignedToMembersOfTeam({ teamId }: { teamId: number }, }, }, - } + }; log.debug( safeStringify({ diff --git a/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts b/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts index 8f2ef33886..c776d077ec 100644 --- a/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts +++ b/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts @@ -1,4 +1,5 @@ import { AttributeType } from "@calcom/prisma/enums"; + import type { RoutingForm, Attribute } from "../types/types"; import { FieldTypes, RoutingFormFieldType } from "./FieldTypes"; import { AttributesInitialConfig, FormFieldsInitialConfig } from "./InitialConfig"; diff --git a/packages/app-store/routing-forms/lib/getSerializableForm.ts b/packages/app-store/routing-forms/lib/getSerializableForm.ts index 8940ad1fbb..5ccacbd909 100644 --- a/packages/app-store/routing-forms/lib/getSerializableForm.ts +++ b/packages/app-store/routing-forms/lib/getSerializableForm.ts @@ -2,6 +2,8 @@ import type { App_RoutingForms_Form } from "@prisma/client"; import type { z } from "zod"; import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; import type { SerializableForm, SerializableFormTeamMembers } from "../types/types"; @@ -11,8 +13,7 @@ import getConnectedForms from "./getConnectedForms"; import isRouter from "./isRouter"; import isRouterLinkedField from "./isRouterLinkedField"; import { getFieldWithOptions } from "./selectOptions"; -import logger from "@calcom/lib/logger"; -import { safeStringify } from "@calcom/lib/safeStringify"; + const log = logger.getSubLogger({ prefix: ["getSerializableForm"] }); /** * Doesn't have deleted fields by default diff --git a/packages/app-store/routing-forms/lib/processRoute.tsx b/packages/app-store/routing-forms/lib/processRoute.tsx index 1d9222e5d9..3a17eef731 100644 --- a/packages/app-store/routing-forms/lib/processRoute.tsx +++ b/packages/app-store/routing-forms/lib/processRoute.tsx @@ -5,10 +5,10 @@ import type { z } from "zod"; import type { FormResponse, Route, SerializableForm } from "../types/types"; import type { zodNonRouterRoute } from "../zod"; +import { evaluateRaqbLogic, RaqbLogicResult } from "./evaluateRaqbLogic"; import { getQueryBuilderConfigForFormFields } from "./getQueryBuilderConfig"; import { isFallbackRoute } from "./isFallbackRoute"; import isRouter from "./isRouter"; -import { evaluateRaqbLogic, RaqbLogicResult } from "./evaluateRaqbLogic"; export function findMatchingRoute({ form, @@ -54,7 +54,7 @@ export function findMatchingRoute({ queryBuilderConfig, data: responseValues, }); - + if (result === RaqbLogicResult.MATCH || result === RaqbLogicResult.LOGIC_NOT_FOUND_SO_MATCHED) { chosenRoute = route; break; diff --git a/packages/app-store/routing-forms/package.json b/packages/app-store/routing-forms/package.json index 95b864ae3a..1b6fec7222 100644 --- a/packages/app-store/routing-forms/package.json +++ b/packages/app-store/routing-forms/package.json @@ -11,6 +11,10 @@ "json-logic-js": "^2.0.2", "react-awesome-query-builder": "^5.1.2" }, + "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix" + }, "devDependencies": { "@calcom/types": "*", "@types/json-logic-js": "^1.2.1" diff --git a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx index 45fd0ee5de..c372a04c22 100644 --- a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx @@ -22,14 +22,15 @@ import { useInViewObserver } from "@lib/hooks/useInViewObserver"; import SingleForm, { getServerSidePropsForSingleFormView as getServerSideProps, } from "../../components/SingleForm"; -import type {FormFieldsBaseConfig} from "../../components/react-awesome-query-builder/config/config"; import "../../components/react-awesome-query-builder/styles.css"; import type { JsonLogicQuery } from "../../jsonLogicToPrisma"; -import { getQueryBuilderConfigForFormFields,type FormFieldsQueryBuilderConfigWithRaqbFields } from "../../lib/getQueryBuilderConfig"; +import { + getQueryBuilderConfigForFormFields, + type FormFieldsQueryBuilderConfigWithRaqbFields, +} from "../../lib/getQueryBuilderConfig"; export { getServerSideProps }; - const Result = ({ formId, jsonLogicQuery }: { formId: string; jsonLogicQuery: JsonLogicQuery | null }) => { const { t } = useLocale(); @@ -167,7 +168,7 @@ const Reporter = ({ form }: { form: inferSSRProps["fo return (
{ onChange(immutableTree, config as unknown as FormFieldsQueryBuilderConfigWithRaqbFields); diff --git a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx index 37b8c1a25e..1a1bfcf506 100644 --- a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx @@ -10,7 +10,9 @@ import type { UseFormReturn } from "react-hook-form"; import Shell from "@calcom/features/shell/Shell"; import { areTheySiblingEntitites } from "@calcom/lib/entityPermissionUtils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { App_RoutingForms_Form } from "@calcom/prisma/client"; +import type { App_RoutingForms_Form } from "@calcom/prisma/client"; +import { SchedulingType } from "@calcom/prisma/client"; +import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import { @@ -21,6 +23,7 @@ import { TextField, Badge, Divider, + Switch, } from "@calcom/ui"; import type { RoutingFormWithResponseCount } from "../../components/SingleForm"; @@ -55,10 +58,40 @@ type LocalRouteWithRaqbStates = LocalRoute & { attributesQueryBuilderState: AttributesQueryBuilderState | null; }; +type Form = inferSSRProps["form"]; + type Route = LocalRouteWithRaqbStates | GlobalRoute; +const RoundRobinContactOwnerOverrideSwitch = ({ + route, + setAttributeRoutingConfig, +}: { + route: LocalRouteWithRaqbStates; + setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial) => void; +}) => { + return ( +
+ { + setAttributeRoutingConfig(route.id, { + skipContactOwner, + }); + }} + /> +
+ ); +}; + type AttributesQueryValue = NonNullable; type FormFieldsQueryValue = LocalRoute["queryValue"]; +type AttributeRoutingConfig = NonNullable; const hasRules = (route: Route) => { if (isRouter(route)) return false; @@ -82,42 +115,22 @@ const getEmptyRoute = (): Exclude => { }; }; -const Route = ({ +const buildEventsData = ({ + eventTypesByGroup, form, route, - routes, - setRoute, - formFieldsQueryBuilderConfig, - attributesQueryBuilderConfig, - setRoutes, - moveUp, - moveDown, - appUrl, - disabled = false, - fieldIdentifiers, }: { - form: inferSSRProps["form"]; + eventTypesByGroup: RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined; + form: Form; route: Route; - routes: Route[]; - setRoute: (id: string, route: Partial) => void; - formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields; - attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null; - setRoutes: React.Dispatch>; - fieldIdentifiers: string[]; - moveUp?: { fn: () => void; check: () => boolean } | null; - moveDown?: { fn: () => void; check: () => boolean } | null; - appUrl: string; - disabled?: boolean; }) => { - const { t } = useLocale(); - const isTeamForm = form.teamId !== null; - const index = routes.indexOf(route); - - const { data: eventTypesByGroup, isLoading } = trpc.viewer.eventTypes.getByViewer.useQuery({ - forRoutingForms: true, - }); - - const eventOptions: { label: string; value: string }[] = []; + const eventOptions: { label: string; value: string; eventTypeId: number }[] = []; + const eventTypesMap = new Map< + number, + { + schedulingType: SchedulingType | null; + } + >(); eventTypesByGroup?.eventTypeGroups.forEach((group) => { const eventTypeValidInContext = areTheySiblingEntitites({ entity1: { @@ -139,14 +152,59 @@ const Route = ({ if (!isRouteAlreadyInUse && !eventTypeValidInContext) { return; } - + eventTypesMap.set(eventType.id, { + schedulingType: eventType.schedulingType, + }); eventOptions.push({ label: uniqueSlug, value: uniqueSlug, + eventTypeId: eventType.id, }); }); }); + return { eventOptions, eventTypesMap }; +}; + +const Route = ({ + form, + route, + routes, + setRoute, + setAttributeRoutingConfig, + formFieldsQueryBuilderConfig, + attributesQueryBuilderConfig, + setRoutes, + moveUp, + moveDown, + appUrl, + disabled = false, + fieldIdentifiers, +}: { + form: Form; + route: Route; + routes: Route[]; + setRoute: (id: string, route: Partial) => void; + setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial) => void; + formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields; + attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null; + setRoutes: React.Dispatch>; + fieldIdentifiers: string[]; + moveUp?: { fn: () => void; check: () => boolean } | null; + moveDown?: { fn: () => void; check: () => boolean } | null; + appUrl: string; + disabled?: boolean; +}) => { + const { t } = useLocale(); + const isTeamForm = form.teamId !== null; + const index = routes.indexOf(route); + + const { data: eventTypesByGroup, isLoading } = trpc.viewer.eventTypes.getByViewer.useQuery({ + forRoutingForms: true, + }); + + const { eventOptions, eventTypesMap } = buildEventsData({ eventTypesByGroup, form, route }); + // /team/{TEAM_SLUG}/{EVENT_SLUG} -> /team/{TEAM_SLUG} const eventTypePrefix = eventOptions.length !== 0 @@ -233,6 +291,28 @@ const Route = ({ } const shouldShowFormFieldsQueryBuilder = (route.isFallback && hasRules(route)) || !route.isFallback; + const eventTypeRedirectUrlOptions = + eventOptions.length !== 0 + ? [{ label: t("custom"), value: "custom", eventTypeId: 0 }].concat(eventOptions) + : []; + + const eventTypeRedirectUrlSelectedOption = + eventOptions.length !== 0 && route.action.value !== "" + ? eventOptions.find( + (eventOption) => eventOption.value === route.action.value && !customEventTypeSlug.length + ) || { + label: t("custom"), + value: "custom", + eventTypeId: 0, + } + : undefined; + + const chosenEventTypeForRedirect = eventTypeRedirectUrlSelectedOption?.eventTypeId + ? eventTypesMap.get(eventTypeRedirectUrlSelectedOption.eventTypeId) + : null; + + const isRoundRobinEventSelectedForRedirect = + chosenEventTypeForRedirect?.schedulingType === SchedulingType.ROUND_ROBIN; const formFieldsQueryBuilder = shouldShowFormFieldsQueryBuilder ? (
@@ -256,13 +336,19 @@ const Route = ({ ) : null; const attributesQueryBuilder = - route.action?.type === "eventTypeRedirectUrl" ? ( + route.action?.type === "eventTypeRedirectUrl" && isTeamForm ? (
- {isTeamForm && ( - - and use only the Team Members that match the following criteria(matches all by default) - - )} + + and use only the Team Members that match the following criteria(matches all by default) + + + {isRoundRobinEventSelectedForRedirect ? ( + + ) : null} +
{route.attributesQueryBuilderState && attributesQueryBuilderConfig && ( { if (!option) { return; } if (option.value !== "custom") { - setRoute(route.id, { action: { ...route.action, value: option.value } }); + setRoute(route.id, { + action: { ...route.action, value: option.value }, + }); setCustomEventTypeSlug(""); } else { setRoute(route.id, { action: { ...route.action, value: "custom" } }); setCustomEventTypeSlug(""); } }} - value={ - eventOptions.length !== 0 && route.action.value !== "" - ? eventOptions.find( - (eventOption) => - eventOption.value === route.action.value && !customEventTypeSlug.length - ) || { - label: t("custom"), - value: "custom", - } - : undefined - } + value={eventTypeRedirectUrlSelectedOption} /> {eventOptions.length !== 0 && route.action.value !== "" && @@ -467,7 +541,7 @@ const deserializeRoute = ({ config: attributesQueryBuilderConfig, }) : null; - + return { ...route, formFieldsQueryBuilderState: buildState({ @@ -633,11 +707,26 @@ const Routes = ({ const setRoute = (id: string, route: Partial) => { const index = routes.findIndex((route) => route.id === id); + const existingRoute = routes[index]; const newRoutes = [...routes]; - newRoutes[index] = { ...routes[index], ...route }; + newRoutes[index] = { ...existingRoute, ...route }; setRoutes(newRoutes); }; + const setAttributeRoutingConfig = (id: string, attributeRoutingConfig: Partial) => { + const existingRoute = routes.find((route) => route.id === id); + if (!existingRoute) { + throw new Error("Route not found"); + } + + const existingAttributeRoutingConfig = + "attributeRoutingConfig" in existingRoute ? existingRoute.attributeRoutingConfig : {}; + + setRoute(id, { + attributeRoutingConfig: { ...existingAttributeRoutingConfig, ...attributeRoutingConfig }, + }); + }; + const swap = (from: number, to: number) => { setRoutes((routes) => { const newRoutes = [...routes]; @@ -654,6 +743,7 @@ const Routes = ({ } return { id: route.id, + attributeRoutingConfig: route.attributeRoutingConfig, action: route.action, isFallback: route.isFallback, queryValue: route.queryValue, @@ -695,6 +785,7 @@ const Routes = ({ }} routes={routes} setRoute={setRoute} + setAttributeRoutingConfig={setAttributeRoutingConfig} setRoutes={setRoutes} /> ); @@ -761,6 +852,7 @@ const Routes = ({ setRoutes={setRoutes} appUrl={appUrl} fieldIdentifiers={fieldIdentifiers} + setAttributeRoutingConfig={setAttributeRoutingConfig} />
@@ -778,7 +870,7 @@ export default function RouteBuilder({ form={form} appUrl={appUrl} enrichedWithUserProfileForm={enrichedWithUserProfileForm} - Page={({ hookForm, form }) => { + Page={function Page({ hookForm, form }) { const { t } = useLocale(); const values = hookForm.getValues(); const { data: attributes, isPending: isAttributesLoading } = diff --git a/packages/app-store/routing-forms/pages/router/getServerSideProps.ts b/packages/app-store/routing-forms/pages/router/getServerSideProps.ts index 891264be66..4a088e3d46 100644 --- a/packages/app-store/routing-forms/pages/router/getServerSideProps.ts +++ b/packages/app-store/routing-forms/pages/router/getServerSideProps.ts @@ -139,6 +139,7 @@ export const getServerSideProps = async function getServerSideProps( 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, @@ -148,6 +149,7 @@ export const getServerSideProps = async function getServerSideProps( }); teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic; formResponseId = result.formResponse.id; + attributeRoutingConfig = result.attributeRoutingConfig; } catch (e) { if (e instanceof TRPCError) { return { @@ -188,6 +190,7 @@ export const getServerSideProps = async function getServerSideProps( teamMembersMatchingAttributeLogic, // formResponseId is guaranteed to be set because in catch block of trpc request we return from the function and otherwise it would have been set formResponseId: formResponseId!, + attributeRoutingConfig: attributeRoutingConfig ?? null, }), isEmbed: pageProps.isEmbed, }), 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 24eb800425..3f80ee1b3f 100644 --- a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx @@ -24,7 +24,7 @@ import getFieldIdentifier from "../../lib/getFieldIdentifier"; import { findMatchingRoute } from "../../lib/processRoute"; import { substituteVariables } from "../../lib/substituteVariables"; import { getFieldResponseForJsonLogic } from "../../lib/transformResponse"; -import type { NonRouterRoute, FormResponse, Route } from "../../types/types"; +import type { NonRouterRoute, FormResponse } from "../../types/types"; import { getServerSideProps } from "./getServerSideProps"; import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward"; @@ -94,7 +94,7 @@ function RoutingForm({ form, profile, ...restProps }: Props) { const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({ onSuccess: async (data) => { - const {teamMembersMatchingAttributeLogic, formResponse} = data; + const { teamMembersMatchingAttributeLogic, formResponse, attributeRoutingConfig } = data; const chosenRouteWithFormResponse = chosenRouteWithFormResponseRef.current; if (!chosenRouteWithFormResponse) { return; @@ -109,6 +109,7 @@ function RoutingForm({ form, profile, ...restProps }: Props) { fields, searchParams: new URLSearchParams(window.location.search), teamMembersMatchingAttributeLogic, + attributeRoutingConfig: attributeRoutingConfig ?? null, }); const chosenRoute = chosenRouteWithFormResponse.route; const decidedAction = chosenRoute.action; @@ -126,7 +127,7 @@ function RoutingForm({ form, profile, ...restProps }: Props) { form, eventTypeRedirectUrl: eventTypeUrlWithResolvedVariables, allURLSearchParams, - isEmbed: !!isEmbed + isEmbed: !!isEmbed, }) ); } else if (decidedAction.type === "externalRedirectUrl") { diff --git a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts index d571ff1bfa..ee1727589b 100644 --- a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts +++ b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts @@ -66,6 +66,7 @@ describe("getUrlSearchParamsToForward", () => { searchParams, teamMembersMatchingAttributeLogic: null, formResponseId: 1, + attributeRoutingConfig: null, }); expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); }); @@ -99,6 +100,7 @@ describe("getUrlSearchParamsToForward", () => { searchParams, teamMembersMatchingAttributeLogic: null, formResponseId: 1, + attributeRoutingConfig: null, }); expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); }); @@ -149,6 +151,7 @@ describe("getUrlSearchParamsToForward", () => { searchParams, teamMembersMatchingAttributeLogic: null, formResponseId: 1, + attributeRoutingConfig: null, }); expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); }); @@ -199,6 +202,7 @@ describe("getUrlSearchParamsToForward", () => { searchParams, teamMembersMatchingAttributeLogic: null, formResponseId: 1, + attributeRoutingConfig: null, }); expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); }); @@ -224,7 +228,96 @@ describe("getUrlSearchParamsToForward", () => { fields, searchParams, teamMembersMatchingAttributeLogic: null, - formResponseId: 1 + formResponseId: 1, + attributeRoutingConfig: null, + }); + expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); + }); + + it("should add cal.skipContactOwner when attributeRoutingConfig.skipContactOwner is true", () => { + const field1Id = uuidv4(); + const field2Id = uuidv4(); + const formResponse = { + [field1Id]: { value: "value1", label: "Field 1" }, + [field2Id]: { value: ["option1", "option2"], label: "Field 2" }, + }; + + const fields = [ + { id: field1Id, identifier: "f1", type: "text", label: "Field 1" }, + { + id: field2Id, + identifier: "f2", + label: "Field 2", + type: "multiselect", + options: [ + { id: "option1", label: "Option 1" }, + { id: "option2", label: "Option 2" }, + ], + }, + ]; + + const searchParams = new URLSearchParams("?query1=value1&query2=value2"); + const expectedParams = { + f1: "value1", + f2: ["Option 1", "Option 2"], + query1: "value1", + query2: "value2", + "cal.routingFormResponseId": "1", + "cal.skipContactOwner": "true", + }; + + const result = getUrlSearchParamsToForward({ + formResponse, + fields, + searchParams, + teamMembersMatchingAttributeLogic: null, + formResponseId: 1, + attributeRoutingConfig: { + skipContactOwner: true, + }, + }); + expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); + }); + + it("should add cal.routedTeamMemberIds to query params", () => { + const field1Id = uuidv4(); + const field2Id = uuidv4(); + const formResponse = { + [field1Id]: { value: "value1", label: "Field 1" }, + [field2Id]: { value: ["option1", "option2"], label: "Field 2" }, + }; + + const fields = [ + { id: field1Id, identifier: "f1", type: "text", label: "Field 1" }, + { + id: field2Id, + identifier: "f2", + label: "Field 2", + type: "multiselect", + options: [ + { id: "option1", label: "Option 1" }, + { id: "option2", label: "Option 2" }, + ], + }, + ]; + + const searchParams = new URLSearchParams("?query1=value1&query2=value2"); + const expectedParams = { + f1: "value1", + f2: ["Option 1", "Option 2"], + query1: "value1", + query2: "value2", + "cal.routingFormResponseId": "1", + "cal.routedTeamMemberIds": "1,2", + }; + + const result = getUrlSearchParamsToForward({ + formResponse, + fields, + searchParams, + teamMembersMatchingAttributeLogic: [1, 2], + formResponseId: 1, + attributeRoutingConfig: null, }); expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams); }); diff --git a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts index aa093873a6..83c86a47d3 100644 --- a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts +++ b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts @@ -1,15 +1,18 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import getFieldIdentifier from "../../lib/getFieldIdentifier"; +import type { LocalRoute } from "../../types/types"; import type { getServerSideProps } from "./getServerSideProps"; type Props = inferSSRProps; +type AttributeRoutingConfig = NonNullable; export function getUrlSearchParamsToForward({ formResponse, fields, searchParams, teamMembersMatchingAttributeLogic, formResponseId, + attributeRoutingConfig, }: { formResponse: Record< string, @@ -24,6 +27,7 @@ export function getUrlSearchParamsToForward({ searchParams: URLSearchParams; formResponseId: number; teamMembersMatchingAttributeLogic: number[] | null; + attributeRoutingConfig: AttributeRoutingConfig | null; }) { type Params = Record; const paramsFromResponse: Params = {}; @@ -76,9 +80,10 @@ export function getUrlSearchParamsToForward({ // In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value. ...paramsFromResponse, ...(teamMembersMatchingAttributeLogic - ? { ['cal.routedTeamMemberIds']: teamMembersMatchingAttributeLogic.join(",") } + ? { ["cal.routedTeamMemberIds"]: teamMembersMatchingAttributeLogic.join(",") } : null), - ['cal.routingFormResponseId']: String(formResponseId), + ["cal.routingFormResponseId"]: String(formResponseId), + ...(attributeRoutingConfig?.skipContactOwner ? { ["cal.skipContactOwner"]: "true" } : {}), }; const allQueryURLSearchParams = new URLSearchParams(); diff --git a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts index fc95160854..a92b88d4e8 100644 --- a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts +++ b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts @@ -346,7 +346,7 @@ test.describe("Routing Forms", () => { ); await expect(page.locator("text=Multiselect chosen")).toBeVisible({ timeout: 10000 }); - // Negative test - Ensures that the "No logic" situation where a Route is considered to be always passing isn't hitting + // Negative test - Ensures that the "No logic" situation where a Route is considered to be always passing isn't hitting await page.goto(`/router?form=${routingForm.id}&Test field=kuchbhi`); await page.waitForURL((url) => { return url.searchParams.get("Test field") === "kuchbhi"; diff --git a/packages/app-store/routing-forms/trpc/_router.ts b/packages/app-store/routing-forms/trpc/_router.ts index 20b3a11177..a7fcdcd555 100644 --- a/packages/app-store/routing-forms/trpc/_router.ts +++ b/packages/app-store/routing-forms/trpc/_router.ts @@ -3,13 +3,13 @@ import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure"; import { router } from "@calcom/trpc/server/trpc"; import { ZDeleteFormInputSchema } from "./deleteForm.schema"; +import { ZFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema"; import { ZFormMutationInputSchema } from "./formMutation.schema"; 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"; -import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema"; -import { ZFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema"; // eslint-disable-next-line @typescript-eslint/ban-types const UNSTABLE_HANDLER_CACHE: Record = {}; diff --git a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts b/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts index 60ce96c90c..c357649093 100644 --- a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts +++ b/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export const ZFindTeamMembersMatchingAttributeLogicInputSchema = z.object({ formId: z.string(), response: z.record(z.string(), z.any()), - routeId: z.string() + routeId: z.string(), }); export type TFindTeamMembersMatchingAttributeLogicInputSchema = z.infer< diff --git a/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts index 45b811abe8..5ebe7aa17e 100644 --- a/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts +++ b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts @@ -1,9 +1,10 @@ -import { TRPCError } from "@trpc/server"; +import { MembershipRepository } from "@calcom/lib/server/repository/membership"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; +import { TRPCError } from "@trpc/server"; + +import { getAttributesForTeam } from "../lib/getAttributes"; import type { TGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema"; -import { getAttributesForTeam } from "../lib/getAttributes"; -import { MembershipRepository } from "@calcom/lib/server/repository/membership"; type GetAttributesForTeamHandlerOptions = { ctx: { @@ -28,4 +29,4 @@ export default async function getAttributesForTeamHandler({ } return getAttributesForTeam({ teamId }); -} \ No newline at end of file +} diff --git a/packages/app-store/routing-forms/trpc/raqbUtils.ts b/packages/app-store/routing-forms/trpc/raqbUtils.ts index 51992f02f7..796b080484 100644 --- a/packages/app-store/routing-forms/trpc/raqbUtils.ts +++ b/packages/app-store/routing-forms/trpc/raqbUtils.ts @@ -1,14 +1,12 @@ -import type { App_RoutingForms_Form, User } from "@prisma/client"; -import { JsonGroup, JsonItem, JsonRule, JsonTree } from "react-awesome-query-builder"; +import type { App_RoutingForms_Form } from "@prisma/client"; +import type { JsonGroup, JsonItem, JsonRule, JsonTree } from "react-awesome-query-builder"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { - AttributesQueryBuilderConfigWithRaqbFields, - getQueryBuilderConfigForAttributes, -} from "../lib/getQueryBuilderConfig"; -import { Attribute } from "../types/types"; +import type { AttributesQueryBuilderConfigWithRaqbFields } from "../lib/getQueryBuilderConfig"; +import { getQueryBuilderConfigForAttributes } from "../lib/getQueryBuilderConfig"; +import type { Attribute } from "../types/types"; import type { LocalRoute } from "../types/types"; import type { FormResponse, SerializableForm } from "../types/types"; import type { SerializableField } from "../types/types"; @@ -175,10 +173,11 @@ const attributeChangeCompatibility = { attributesQueryValue: NonNullable; attributeId: string; }) { - const fieldTypeFromAttributesQueryValue = raqbQueryValueUtils.getValueTypeFromAttributesQueryValueForRaqbField({ - attributesQueryValue, - raqbFieldId: attributeId, - }); + const fieldTypeFromAttributesQueryValue = + raqbQueryValueUtils.getValueTypeFromAttributesQueryValueForRaqbField({ + attributesQueryValue, + raqbFieldId: attributeId, + }); if (fieldTypeFromAttributesQueryValue === "multiselect") { return ensureArray(attributeValue); @@ -195,7 +194,7 @@ function getAttributesData({ attributesQueryValue: NonNullable; }) { return Object.entries(attributesData).reduce((acc, [attributeId, value]) => { - let compatibleValueForAttributeAndFormFieldMatching = compatibleForAttributeAndFormFieldMatch(value); + const compatibleValueForAttributeAndFormFieldMatching = compatibleForAttributeAndFormFieldMatch(value); // We do this to ensure that correct jsonLogic is generated for an existing route even if the attribute's type changes acc[attributeId] = attributeChangeCompatibility.ensureAttributeValueToBeOfRaqbFieldValueType({ @@ -264,7 +263,7 @@ function getAttributesQueryBuilderConfig({ const attributesQueryBuilderConfigFieldsWithCompatibleListValues = Object.fromEntries( Object.entries(attributesQueryBuilderConfig.fields).map(([raqbFieldId, raqbField]) => { - let raqbFieldType = attributeChangeCompatibility.getRaqbFieldTypeCompatibleWithQueryValue({ + const raqbFieldType = attributeChangeCompatibility.getRaqbFieldTypeCompatibleWithQueryValue({ attributesQueryValue, raqbField, raqbFieldId, diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts index 8933b84471..728355a844 100644 --- a/packages/app-store/routing-forms/trpc/response.handler.ts +++ b/packages/app-store/routing-forms/trpc/response.handler.ts @@ -145,7 +145,19 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => dbFormResponse.response as FormResponse ); - return { formResponse: dbFormResponse, teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic }; + const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId); + if (!chosenRoute) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Chosen route not found", + }); + } + return { + formResponse: dbFormResponse, + teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic, + attributeRoutingConfig: + "attributeRoutingConfig" in chosenRoute ? chosenRoute.attributeRoutingConfig ?? null : null, + }; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e.code === "P2002") { diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts index f7574964b1..1f768395f3 100644 --- a/packages/app-store/routing-forms/trpc/utils.ts +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -18,8 +18,11 @@ import type { SerializableField, OrderedResponses } from "../types/types"; import type { FormResponse, SerializableForm } from "../types/types"; import { acrossQueryValueCompatiblity } from "./raqbUtils"; -const { getAttributesData: getAttributes, getAttributesQueryBuilderConfig, getAttributesQueryValue } = - acrossQueryValueCompatiblity; +const { + getAttributesData: getAttributes, + getAttributesQueryBuilderConfig, + getAttributesQueryValue, +} = acrossQueryValueCompatiblity; const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/utils"] }); @@ -104,7 +107,7 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute({ if (!route) { return null; } - let teamMembersMatchingAttributeLogic: { + const teamMembersMatchingAttributeLogic: { userId: number; result: RaqbLogicResult; }[] = []; @@ -162,9 +165,7 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute({ moduleLogger.debug(`Team member ${member.userId} matches attributes logic`); teamMembersMatchingAttributeLogic.push({ userId: member.userId, result }); } else { - moduleLogger.debug( - `Team member ${member.userId} does not match attributes logic` - ); + moduleLogger.debug(`Team member ${member.userId} does not match attributes logic`); } }); } diff --git a/packages/app-store/routing-forms/types/types.d.ts b/packages/app-store/routing-forms/types/types.d.ts index 2e2077ec52..061c72d1f0 100644 --- a/packages/app-store/routing-forms/types/types.d.ts +++ b/packages/app-store/routing-forms/types/types.d.ts @@ -1,7 +1,7 @@ import type { App_RoutingForms_Form } from "@prisma/client"; import type z from "zod"; -import { AttributeType } from "@calcom/prisma/client"; +import type { AttributeType } from "@calcom/prisma/client"; import type { RoutingFormSettings } from "@calcom/prisma/zod-utils"; import type QueryBuilderInitialConfig from "../components/react-awesome-query-builder/config/config"; @@ -72,4 +72,4 @@ export type Attribute = { export type AttributesQueryValue = NonNullable; export type FormFieldsQueryValue = LocalRoute["queryValue"]; -export type SerializableField = NonNullable["fields"]>[number]; \ No newline at end of file +export type SerializableField = NonNullable["fields"]>[number]; diff --git a/packages/app-store/routing-forms/zod.ts b/packages/app-store/routing-forms/zod.ts index 536b291acf..02da376a4e 100644 --- a/packages/app-store/routing-forms/zod.ts +++ b/packages/app-store/routing-forms/zod.ts @@ -57,6 +57,12 @@ const queryValueSchema = z.object({ export const zodNonRouterRoute = z.object({ id: z.string(), + attributeRoutingConfig: z + .object({ + skipContactOwner: z.boolean().optional(), + }) + .nullish(), + // TODO: It should be renamed to formFieldsQueryValue but it would take some effort /** * RAQB query value for form fields diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index 0fbe2c1072..1a80c0570d 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -2,6 +2,7 @@ import { v4 as uuidv4 } from "uuid"; import dayjs from "@calcom/dayjs"; import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams"; +import { fromEntriesWithDuplicateKeys } from "@calcom/lib/fromEntriesWithDuplicateKeys"; import { parseRecurringDates } from "@calcom/lib/parse-dates"; import type { BookerEvent, BookingCreateBody, RecurringBookingCreateBody } from "../../types"; @@ -46,6 +47,8 @@ export const mapBookingToMutationInput = ({ const routedTeamMemberIds = getRoutedTeamMemberIdsFromSearchParams(searchParams); const routingFormResponseIdParam = searchParams.get("cal.routingFormResponseId"); const routingFormResponseId = routingFormResponseIdParam ? Number(routingFormResponseIdParam) : undefined; + const skipContactOwner = searchParams.get("cal.skipContactOwner") === "true"; + return { ...values, user: username, @@ -68,7 +71,8 @@ export const mapBookingToMutationInput = ({ teamMemberEmail, orgSlug, routedTeamMemberIds, - routingFormResponseId + routingFormResponseId, + skipContactOwner, }; }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 45760e1485..16d23f890c 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -150,6 +150,36 @@ type BookingDataSchemaGetter = | typeof getBookingDataSchema | typeof import("@calcom/features/bookings/lib/getBookingDataSchemaForApi").default; +/** + * Adds the contact owner to be the only lucky user + * @returns + */ +function buildLuckyUsersWithJustContactOwner({ + contactOwnerEmail, + availableUsers, + fixedUserPool, +}: { + contactOwnerEmail: string | null; + availableUsers: IsFixedAwareUser[]; + fixedUserPool: IsFixedAwareUser[]; +}) { + const luckyUsers: Awaited> = []; + if (!contactOwnerEmail) { + return luckyUsers; + } + + const isContactOwnerAFixedHostAlready = fixedUserPool.some((user) => user.email === contactOwnerEmail); + if (isContactOwnerAFixedHostAlready) { + return luckyUsers; + } + + const teamMember = availableUsers.find((user) => user.email === contactOwnerEmail); + if (teamMember) { + luckyUsers.push(teamMember); + } + return luckyUsers; +} + async function handler( req: NextApiRequest & { userId?: number | undefined; @@ -271,6 +301,10 @@ async function handler( logger: loggerWithEventDetails, }); + const contactOwnerFromReq = reqBody.teamMemberEmail ?? null; + const skipContactOwner = reqBody.skipContactOwner ?? false; + const contactOwnerEmail = skipContactOwner ? null : contactOwnerFromReq; + let users = await loadAndValidateUsers({ req, eventType, @@ -278,6 +312,7 @@ async function handler( dynamicUserList, logger: loggerWithEventDetails, routedTeamMemberIds: routedTeamMemberIds ?? null, + contactOwnerEmail, }); let { locationBodyString, organizerOrFirstDynamicGroupMemberDefaultLocationUrl } = getLocationValuesForDb( @@ -384,7 +419,6 @@ async function handler( }, loggerWithEventDetails ); - const luckyUsers: typeof users = []; const luckyUserPool: IsFixedAwareUser[] = []; const fixedUserPool: IsFixedAwareUser[] = []; availableUsers.forEach((user) => { @@ -401,17 +435,14 @@ async function handler( }) ); - if (reqBody.teamMemberEmail) { - // If requested user is not a fixed host, assign the lucky user as the team member - if (!fixedUserPool.some((user) => user.email === reqBody.teamMemberEmail)) { - const teamMember = availableUsers.find((user) => user.email === reqBody.teamMemberEmail); - if (teamMember) { - luckyUsers.push(teamMember); - } - } - } + const luckyUsers: typeof users = buildLuckyUsersWithJustContactOwner({ + contactOwnerEmail: contactOwnerEmail, + availableUsers, + fixedUserPool, + }); // loop through all non-fixed hosts and get the lucky users + // This logic doesn't run when contactOwner is used because in that case, luckUsers.length === 1 while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) { const freeUsers = luckyUserPool.filter( (user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id) @@ -632,7 +663,6 @@ async function handler( const attendeesList = [...invitee, ...guests]; const responses = reqBody.responses || null; - const evtName = !eventType?.isDynamic ? eventType.eventName : responses?.title; const eventNameObject = { //TODO: Can we have an unnamed attendee? If not, I would really like to throw an error here. diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts index 7af0cef324..6380ec7314 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts @@ -27,6 +27,7 @@ type InputProps = { dynamicUserList: string[]; logger: Logger; routedTeamMemberIds: number[] | null; + contactOwnerEmail: string | null; }; export async function loadAndValidateUsers({ @@ -36,8 +37,9 @@ export async function loadAndValidateUsers({ dynamicUserList, logger, routedTeamMemberIds, + contactOwnerEmail, }: InputProps): Promise { - let users: Users = await loadUsers({ eventType, dynamicUserList, req, routedTeamMemberIds }); + let users: Users = await loadUsers({ eventType, dynamicUserList, req, routedTeamMemberIds, contactOwnerEmail }); const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking); if (!isDynamicAllowed && !eventTypeId) { logger.warn({ diff --git a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts index 121f769f58..c06e1123e2 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts @@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client"; import type { IncomingMessage } from "http"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { getRoutedUsers } from "@calcom/lib/bookings/getRoutedUsers"; +import { getRoutedUsersWithContactOwnerAndFixedUsers } from "@calcom/lib/bookings/getRoutedUsers"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -21,18 +21,20 @@ export const loadUsers = async ({ dynamicUserList, req, routedTeamMemberIds, + contactOwnerEmail, }: { eventType: EventType; dynamicUserList: string[]; req: IncomingMessage; routedTeamMemberIds: number[] | null; + contactOwnerEmail: string | null; }) => { try { const { currentOrgDomain } = orgDomainConfig(req); const users = eventType.id ? await loadUsersByEventType(eventType) : await loadDynamicUsers(dynamicUserList, currentOrgDomain); - return getRoutedUsers({ users, routedTeamMemberIds }); + return getRoutedUsersWithContactOwnerAndFixedUsers({ users, routedTeamMemberIds, contactOwnerEmail }); } catch (error) { log.error("Unable to load users", safeStringify(error)); if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index 2323f7df86..fb8e6a2b75 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -46,8 +46,8 @@ export const useSchedule = ({ selectedDate, }); const searchParams = useSearchParams(); - const routedTeamMemberIds = searchParams ? getRoutedTeamMemberIdsFromSearchParams(searchParams) : null; + const skipContactOwner = searchParams ? searchParams.get("cal.skipContactOwner") === "true" : false; return trpc.viewer.public.slots.getSchedule.useQuery( { isTeamEvent, @@ -67,6 +67,7 @@ export const useSchedule = ({ orgSlug, teamMemberEmail, routedTeamMemberIds, + skipContactOwner, }, { trpc: { diff --git a/packages/lib/bookings/getRoutedUsers.ts b/packages/lib/bookings/getRoutedUsers.ts index 7873a68565..3352a956de 100644 --- a/packages/lib/bookings/getRoutedUsers.ts +++ b/packages/lib/bookings/getRoutedUsers.ts @@ -1,14 +1,18 @@ -import { safeStringify } from "@calcom/lib/safeStringify"; import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; const log = logger.getSubLogger({ prefix: ["[getRoutedUsers]"] }); -export const getRoutedHosts = ({ +export const getRoutedHostsWithContactOwnerAndFixedHosts = < + T extends { user: { id: number; email: string }; isFixed?: boolean } +>({ routedTeamMemberIds, hosts, + contactOwnerEmail, }: { routedTeamMemberIds: number[] | null; hosts: T[]; + contactOwnerEmail: string | null; }) => { // We don't want to enter a scenario where we have no team members to be booked // So, let's just fallback to regular flow if no routedTeamMemberIds are provided @@ -16,23 +20,38 @@ export const getRoutedHosts = routedTeamMemberIds.includes(host.user.id) || host.isFixed); + log.debug( + "filtering hosts as per routedTeamMemberIds", + safeStringify({ routedTeamMemberIds, contactOwnerEmail }) + ); + return hosts.filter( + (host) => + routedTeamMemberIds.includes(host.user.id) || host.isFixed || host.user.email === contactOwnerEmail + ); }; -export const getRoutedUsers = ({ +export const getRoutedUsersWithContactOwnerAndFixedUsers = < + T extends { id: number; isFixed?: boolean; email: string } +>({ routedTeamMemberIds, users, + contactOwnerEmail, }: { routedTeamMemberIds: number[] | null; users: T[]; + contactOwnerEmail: string | null; }) => { - // We don't want to enter a scenario where we have no team members to be booked + // We don't want to enter a scenario where we have no team members to be booked // So, let's just fallback to regular flow if no routedTeamMemberIds are provided if (!routedTeamMemberIds || !routedTeamMemberIds.length) { return users; } - log.debug("filtering users as per routedTeamMemberIds", safeStringify({ routedTeamMemberIds })); - return users.filter((user) => routedTeamMemberIds.includes(user.id) || user.isFixed); + log.debug( + "filtering users as per routedTeamMemberIds", + safeStringify({ routedTeamMemberIds, contactOwnerEmail }) + ); + return users.filter( + (user) => routedTeamMemberIds.includes(user.id) || user.isFixed || user.email === contactOwnerEmail + ); }; diff --git a/packages/lib/fromEntriesWithDuplicateKeys.ts b/packages/lib/fromEntriesWithDuplicateKeys.ts new file mode 100644 index 0000000000..6cc389215f --- /dev/null +++ b/packages/lib/fromEntriesWithDuplicateKeys.ts @@ -0,0 +1,24 @@ +export function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string, string]> | null) { + const result: Record = {}; + + if (entries === null) { + return result; + } + + // Consider setting atleast ES2015 as target + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + for (const [key, value] of entries) { + if (result.hasOwnProperty(key)) { + let currentValue = result[key]; + if (!Array.isArray(currentValue)) { + currentValue = [currentValue]; + } + currentValue.push(value); + result[key] = currentValue; + } else { + result[key] = value; + } + } + return result; +} \ No newline at end of file diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 5b7cee43fc..23cb4b2a04 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -250,6 +250,7 @@ export const bookingCreateBodySchema = z.object({ teamMemberEmail: z.string().nullish(), routedTeamMemberIds: z.array(z.number()).nullish(), routingFormResponseId: z.number().optional(), + skipContactOwner: z.boolean().optional(), }); export const requiredCustomInputSchema = z.union([ diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index acc9e426b2..f5f7c26f30 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -26,6 +26,7 @@ export const getScheduleSchema = z orgSlug: z.string().nullish(), teamMemberEmail: z.string().nullish(), routedTeamMemberIds: z.array(z.number()).nullish(), + skipContactOwner: z.boolean().nullish(), }) .transform((val) => { // Need this so we can pass a single username in the query string form public API diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 3354d99eb6..a22fa3f899 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -11,6 +11,7 @@ import dayjs from "@calcom/dayjs"; import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains"; import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import { parseBookingLimit, parseDurationLimit } from "@calcom/lib"; +import { getRoutedHostsWithContactOwnerAndFixedHosts } from "@calcom/lib/bookings/getRoutedUsers"; import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants"; import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; @@ -31,7 +32,7 @@ import { BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { EventBusyDate } from "@calcom/types/Calendar"; -import { getRoutedHosts } from "@calcom/lib/bookings/getRoutedUsers"; + import { TRPCError } from "@trpc/server"; import type { GetScheduleOptions } from "./getSchedule.handler"; @@ -342,6 +343,47 @@ export interface IGetAvailableSlots { >; } +/** + * Returns (Contact Owner plus Fixed Hosts) OR All Hosts + */ +function getUsersWithCredentialsConsideringContactOwner({ + contactOwnerEmail, + hosts, +}: { + contactOwnerEmail: string | null | undefined; + hosts: { + isFixed?: boolean; + user: GetAvailabilityUser; + }[]; +}) { + const contactOwnerHost = hosts.find((host) => host.user.email === contactOwnerEmail); + /** + * It could still have contact owner, if it was one of the assigned hosts of the event. + * In case of routedTeamMemberIds, hosts will only have the Routed Team Members and in that case, contact owner would be here only if it matches + */ + const allHosts = hosts.map(({ isFixed, user }) => ({ isFixed, ...user })); + + const contactOwnerExists = contactOwnerEmail && contactOwnerHost; + + if (!contactOwnerExists) { + return allHosts; + } + + if (contactOwnerHost?.isFixed) { + // If contact owner is a fixed host, we return all hosts which also includes contact owner + return allHosts; + } + + const contactOwnerAndFixedHosts = hosts.reduce((usersArray, host) => { + if (host.isFixed || host.user.email === contactOwnerEmail) + usersArray.push({ ...host.user, isFixed: host.isFixed }); + + return usersArray; + }, [] as (GetAvailabilityUser & { isFixed?: boolean })[]); + + return contactOwnerAndFixedHosts; +} + export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise { const orgDetails = input?.orgSlug ? { @@ -402,17 +444,26 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro } let currentSeats: CurrentSeats | undefined; - let eventHosts = + const eventHosts = eventType.hosts?.length && eventType.schedulingType ? eventType.hosts : eventType.users.map((user) => { return { isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE, + email: user.email, user: user, }; }); - let hosts = getRoutedHosts({ hosts: eventHosts, routedTeamMemberIds: input.routedTeamMemberIds ?? null }); + const contactOwnerEmailFromInput = input.teamMemberEmail ?? null; + const skipContactOwner = input.skipContactOwner; + const contactOwnerEmail = skipContactOwner ? null : contactOwnerEmailFromInput; + + let routedHostsWithContactOwnerAndFixedHosts = getRoutedHostsWithContactOwnerAndFixedHosts({ + hosts: eventHosts, + routedTeamMemberIds: input.routedTeamMemberIds ?? null, + contactOwnerEmail, + }); if ( input.rescheduleUid && @@ -430,21 +481,17 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro userId: true, }, }); - hosts = hosts.filter((host) => host.user.id === originalRescheduledBooking?.userId || 0); + routedHostsWithContactOwnerAndFixedHosts = routedHostsWithContactOwnerAndFixedHosts.filter((host) => host.user.id === originalRescheduledBooking?.userId || 0); } - const teamMemberHost = hosts.find((host) => host.user.email === input?.teamMemberEmail); + const usersWithCredentials = getUsersWithCredentialsConsideringContactOwner({ + contactOwnerEmail, + hosts: routedHostsWithContactOwnerAndFixedHosts, + }); - // If the requested team member is a fixed host proceed as normal else get availability like the requested member is a fixed host - const usersWithCredentials = - !input.teamMemberEmail || !teamMemberHost || teamMemberHost.isFixed - ? hosts.map(({ isFixed, user }) => ({ isFixed, ...user })) - : hosts.reduce((usersArray, host) => { - if (host.isFixed || host.user.email === input.teamMemberEmail) - usersArray.push({ ...host.user, isFixed: host.isFixed }); - - return usersArray; - }, [] as (GetAvailabilityUser & { isFixed: boolean })[]); + loggerWithEventDetails.debug("Using users", { + usersWithCredentials: usersWithCredentials.map((user) => user.email), + }); const durationToUse = input.duration || 0;