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:
co-authored by
Omar López
parent
de503add83
commit
03d0b98155
@@ -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
-1
@@ -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({
|
||||
|
||||
+6
-4
@@ -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") {
|
||||
|
||||
+94
-1
@@ -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> = {};
|
||||
|
||||
+1
-1
@@ -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
@@ -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];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user