feat: Skip contact owner even if it matches attribute routing (#17015)

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Hariom Balhara
2024-10-09 07:30:12 -07:00
committed by GitHub
co-authored by Omar López
parent de503add83
commit 03d0b98155
38 changed files with 608 additions and 159 deletions
+6 -1
View File
@@ -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
@@ -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");
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it, afterEach, vi } from "vitest";
import { jsonLogicToPrisma } from "../../jsonLogicToPrisma";
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
afterEach(() => {
vi.resetAllMocks();
@@ -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({
@@ -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<RAQBConjunction, "formatConj" | "sqlFormatConj" | "spelFormatConj" | "mongoConj">;
export type Conjunction = Omit<
RAQBConjunction,
"formatConj" | "sqlFormatConj" | "spelFormatConj" | "mongoConj"
>;
export type Conjunctions = Record<string, Conjunction>;
export type Operator = RAQBOperator & {
_jsonLogicIsExclamationOp?: boolean;
}
};
export type Operators = Record<string, Operator>;
export type WidgetWithoutFactory = Omit<RAQBWidget, "factory"> & {
type: string;
@@ -450,4 +452,4 @@ export default {
widgets,
types,
settings,
};
};
@@ -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
@@ -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;
@@ -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;
}
@@ -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({
@@ -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";
@@ -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
@@ -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;
@@ -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"
@@ -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<typeof getServerSideProps>["fo
return (
<div className="cal-query-builder">
<Query
{...config as unknown as Config}
{...(config as unknown as Config)}
value={query.state.tree}
onChange={(immutableTree, config) => {
onChange(immutableTree, config as unknown as FormFieldsQueryBuilderConfigWithRaqbFields);
@@ -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<typeof getServerSideProps>["form"];
type Route = LocalRouteWithRaqbStates | GlobalRoute;
const RoundRobinContactOwnerOverrideSwitch = ({
route,
setAttributeRoutingConfig,
}: {
route: LocalRouteWithRaqbStates;
setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => void;
}) => {
return (
<div className="mt-4 flex flex-col">
<Switch
label={
route.attributeRoutingConfig?.skipContactOwner
? "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)"
: "Contact owner will be the Round Robin host if available"
}
tooltip="Contact owner can only be used if the routed event has it enabled through Salesforce app"
checked={route.attributeRoutingConfig?.skipContactOwner ?? false}
onCheckedChange={(skipContactOwner) => {
setAttributeRoutingConfig(route.id, {
skipContactOwner,
});
}}
/>
</div>
);
};
type AttributesQueryValue = NonNullable<LocalRoute["attributesQueryValue"]>;
type FormFieldsQueryValue = LocalRoute["queryValue"];
type AttributeRoutingConfig = NonNullable<LocalRoute["attributeRoutingConfig"]>;
const hasRules = (route: Route) => {
if (isRouter(route)) return false;
@@ -82,42 +115,22 @@ const getEmptyRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
};
};
const Route = ({
const buildEventsData = ({
eventTypesByGroup,
form,
route,
routes,
setRoute,
formFieldsQueryBuilderConfig,
attributesQueryBuilderConfig,
setRoutes,
moveUp,
moveDown,
appUrl,
disabled = false,
fieldIdentifiers,
}: {
form: inferSSRProps<typeof getServerSideProps>["form"];
eventTypesByGroup: RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined;
form: Form;
route: Route;
routes: Route[];
setRoute: (id: string, route: Partial<Route>) => void;
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
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<Route>) => void;
setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => void;
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
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 ? (
<div>
@@ -256,13 +336,19 @@ const Route = ({
) : null;
const attributesQueryBuilder =
route.action?.type === "eventTypeRedirectUrl" ? (
route.action?.type === "eventTypeRedirectUrl" && isTeamForm ? (
<div className="mt-4">
{isTeamForm && (
<span className="text-emphasis flex w-full items-center text-sm">
and use only the Team Members that match the following criteria(matches all by default)
</span>
)}
<span className="text-emphasis flex w-full items-center text-sm">
and use only the Team Members that match the following criteria(matches all by default)
</span>
{isRoundRobinEventSelectedForRedirect ? (
<RoundRobinContactOwnerOverrideSwitch
route={route}
setAttributeRoutingConfig={setAttributeRoutingConfig}
/>
) : null}
<div className="mt-2">
{route.attributesQueryBuilderState && attributesQueryBuilderConfig && (
<Query
@@ -359,34 +445,22 @@ const Route = ({
<Select
required
isDisabled={disabled}
options={
eventOptions.length !== 0
? [{ label: t("custom"), value: "custom" }].concat(eventOptions)
: []
}
options={eventTypeRedirectUrlOptions}
onChange={(option) => {
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<Route>) => {
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<AttributeRoutingConfig>) => {
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}
/>
</div>
</div>
@@ -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 } =
@@ -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,
}),
@@ -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") {
@@ -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);
});
@@ -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<typeof getServerSideProps>;
type AttributeRoutingConfig = NonNullable<LocalRoute["attributeRoutingConfig"]>;
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<string, string | string[]>;
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();
@@ -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";
@@ -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<string, Function> = {};
@@ -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<
@@ -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 });
}
}
@@ -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<LocalRoute["attributesQueryValue"]>;
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<LocalRoute["attributesQueryValue"]>;
}) {
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,
@@ -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") {
@@ -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`);
}
});
}
+2 -2
View File
@@ -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<LocalRoute["attributesQueryValue"]>;
export type FormFieldsQueryValue = LocalRoute["queryValue"];
export type SerializableField = NonNullable<SerializableForm<App_RoutingForms_Form>["fields"]>[number];
export type SerializableField = NonNullable<SerializableForm<App_RoutingForms_Form>["fields"]>[number];
+6
View File
@@ -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
@@ -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,
};
};
@@ -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<ReturnType<typeof loadAndValidateUsers>> = [];
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.
@@ -27,6 +27,7 @@ type InputProps = {
dynamicUserList: string[];
logger: Logger<unknown>;
routedTeamMemberIds: number[] | null;
contactOwnerEmail: string | null;
};
export async function loadAndValidateUsers({
@@ -36,8 +37,9 @@ export async function loadAndValidateUsers({
dynamicUserList,
logger,
routedTeamMemberIds,
contactOwnerEmail,
}: InputProps): Promise<Users> {
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({
@@ -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) {
@@ -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: {
+27 -8
View File
@@ -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 = <T extends { user: { id: number }; isFixed?: boolean }>({
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 = <T extends { user: { id: number }; isFixed?: boole
return hosts;
}
log.debug("filtering hosts as per routedTeamMemberIds", safeStringify({ routedTeamMemberIds }));
return hosts.filter((host) => 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 = <T extends { id: number; isFixed?: boolean }>({
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
);
};
@@ -0,0 +1,24 @@
export function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string, string]> | null) {
const result: Record<string, string | string[]> = {};
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;
}
+1
View File
@@ -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([
@@ -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
@@ -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<IGetAvailableSlots> {
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;