feat: Attribute based routing from Team Routing Form to Team Event(with assigned users matching the attribute logic) (#16823)

* Initial commit

* routingForm to Booking a particular team member working

* Happy path working

* Fixes

* Fix router query params forwarding

* Add basicConfig within app

* Tests

* More tests

* Update packages/app-store/routing-forms/components/SingleForm.tsx

Co-authored-by: Omar López <zomars@me.com>

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
This commit is contained in:
Hariom Balhara
2024-10-08 21:12:32 +05:30
committed by GitHub
co-authored by Omar López Benny Joo
parent 7928933852
commit 654a204878
53 changed files with 2784 additions and 410 deletions
@@ -2629,6 +2629,8 @@
"limit_team_booking_frequency_description": "Limit how many times members can be booked across all team event types",
"booking_limits_updated_successfully": "Booking limits updated successfully",
"you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking",
"matching_members": "Matching members",
"no_matching_members": "No matching members. It will fallback to using the team members assigned to the event type.",
"hide_calendar_event_details": "Hide calendar event details on shared calendars",
"description_hide_calendar_event_details": "When a calendar is shared, events are visible to readers but their details are hidden from those without write access.",
"last_number_of_days": "last {{count}} days",
@@ -1,5 +1,32 @@
# Routing Forms App
## Vocabulary
- RaqbField refers to the field in the context of React Awesome Query Builder(RAQB)
- See `getQueryBuilderConfig`
- Field refers to the field in the Routing Form
## Understanding the structure of queryValue(type JsonTree)
### AttributeQueryValue
- {
id: "Some-id-generated-and-used-internally-by-raqb"
type: "group",
children1: {
"Some-other-id-generated-and-used-internally-by-raqb": {
type: "rule",
properties: {
field: "ATTRIBUTE.id",
operator: "multiselect_equals", // One of many operators possible
value: ["AttributeOption.id"],
// This is the type of the attribute and the operator corresponds to it. The data provided to jsonLogic on which the logic(generated from RAQB config and this queryValue) is applied must also be as per the requirement of the operator.
valueType: ["multiselect"]
}
}
}
}
## How to run Tests
`yarn e2e:app-store` runs all Apps' tests. You can use `describe.only()` to run Routing Forms tests only.
+49
View File
@@ -0,0 +1,49 @@
## TODO - Routing
- [x] Use teamMemberIds to calculate availabilithy as well in addition to doing it in handleNewBooking
- [x] Don't let the Routing Form with attributesQueryValue be picked
- [x] Use a subset of event assignees using teamMemberIds
- [x] Ensure that renaming an attribute option doesn't remove it from routing logic
- [x] Add support for multiselect_contains and multiselect_not_contains operators in attributes
- [x] Fix bug with Multiselect attribute
- [x] Connect Form Response to Booking
- [x] Test with non option based attributes and form field input
- [ ] Switching from Select to MultiSelect in attribute
- [x] It immediately makes the Routing logic to not work. Fix it.
- [ ] It removes the selected option from Routing. Fix it.
- [ ] Warnings are needed with recommendation to create new attribute instead in unsupported scenarios. We need to test for more such changes to Attribute types.
- [ ] When choosing 'Value of Field' option in RAQB, show a warning somewhere if all options of the Form field aren't present in the attribute option.
- So, if Form Field is 'What is Comapny Size' with options Enterprise, Large, Medium, Small and Attribute for which Value of 'What is compnay size' has options Enterprise, Large, Medium. It would show a warning that 'Small' option is not present in the attribute.
### Performance Tests and Improvements
- [ ] Test with ~1000 team members and ~100 attributes.
- [ ] getAttributes query optimization. It needs info from various tables(Attribute, AttributeOption, AttributeToUser, Membership)
- [ ] findTeamMembersMatchingAttributesQuery - Parallelize jsonLogic.apply across team members.
### Unit Tests
- [ ] findTeamMembersMatchingAttributesQuery
- [x] Basic Tests
- [ ] Performance Tests with say 1000 team members and 100 attributes to ensure no regression
- [ ] evaluateRAQB
- [x] Basic Tests
- [ ] More Detailed Tests
- [ ] jsonLogic
- [x] Basic Tests
- [ ] getAttributes
- [ ] Querying Logic Test
- [ ] getRoutedUsers tests
### Documentation
- [ ] 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
### 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.
## TODO - Attributes
- [ ] Attributes configuration
1. through DB initially for POC
2. through CSV upload
3. through Salesforce Connect
@@ -2,7 +2,7 @@ import type { App_RoutingForms_Form } from "@prisma/client";
import type { Dispatch, SetStateAction } from "react";
import getFieldIdentifier from "../lib/getFieldIdentifier";
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
import { getQueryBuilderConfigForFormFields } from "../lib/getQueryBuilderConfig";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import { getUIOptionsForSelect } from "../lib/selectOptions";
import { getFieldResponseForJsonLogic } from "../lib/transformResponse";
@@ -17,7 +17,7 @@ type Props = {
export default function FormInputFields(props: Props) {
const { form, response, setResponse } = props;
const queryBuilderConfig = getQueryBuilderConfig(form);
const formFieldsQueryBuilderConfig = getQueryBuilderConfigForFormFields(form);
return (
<>
@@ -26,7 +26,7 @@ export default function FormInputFields(props: Props) {
// @ts-expect-error FIXME @hariombalhara
field = field.routerField;
}
const widget = queryBuilderConfig.widgets[field.type];
const widget = formFieldsQueryBuilderConfig.widgets[field.type];
if (!("factory" in widget)) {
return null;
}
@@ -35,8 +35,8 @@ import {
import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
import { RoutingPages } from "../lib/RoutingPages";
import { isFallbackRoute } from "../lib/isFallbackRoute";
import { processRoute } from "../lib/processRoute";
import type { FormResponse, Route, SerializableForm } from "../types/types";
import { findMatchingRoute } from "../lib/processRoute";
import type { FormResponse, NonRouterRoute, SerializableForm } from "../types/types";
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
import FormInputFields from "./FormInputFields";
import RoutingNavBar from "./RoutingNavBar";
@@ -236,27 +236,51 @@ type SingleFormComponentProps = {
function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleFormComponentProps) {
const utils = trpc.useUtils();
const { t } = useLocale();
const isTeamForm = !!form.teamId;
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
const [response, setResponse] = useState<FormResponse>({});
const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null);
const [chosenRoute, setChosenRoute] = useState<NonRouterRoute | null>(null);
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
const [eventTypeUrl, setEventTypeUrl] = useState("");
const [teamMembersMatchingAttributeLogic, setTeamMembersMatchingAttributeLogic] = useState<
| {
id: number;
name: string | null;
email: string;
}[]
| null
>([]);
const findTeamMembersMatchingAttributeLogicMutation =
trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({
onSuccess(data) {
setTeamMembersMatchingAttributeLogic(data);
},
});
function testRouting() {
const action = processRoute({ form, response });
if (action.type === "eventTypeRedirectUrl") {
const route = findMatchingRoute({ form, response });
if (route?.action?.type === "eventTypeRedirectUrl") {
setEventTypeUrl(
enrichedWithUserProfileForm
? getAbsoluteEventTypeRedirectUrl({
eventTypeRedirectUrl: action.value,
eventTypeRedirectUrl: route.action.value,
form: enrichedWithUserProfileForm,
allURLSearchParams: new URLSearchParams(),
})
: ""
);
}
setDecidedAction(action);
setChosenRoute(route || null);
if (!route) return;
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response,
routeId: route.id,
});
}
const hookForm = useFormContext<RoutingFormWithResponseCount>();
@@ -306,6 +330,100 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
});
const connectedForms = form.connectedForms;
const testFormDialog = (() => {
const testResult = chosenRoute ? (
<div className="bg-subtle text-default mt-5 rounded-md p-3">
<div className="font-bold ">{t("route_to")}:</div>
<div className="mt-2">
{RoutingPages.map((page) => {
if (page.value !== chosenRoute.action.type) return null;
return (
<span key={page.value} data-testid="test-routing-result-type">
{page.label}
</span>
);
})}
:{" "}
{chosenRoute.action.type === "customPageMessage" ? (
<span className="text-default" data-testid="test-routing-result">
{chosenRoute.action.value}
</span>
) : chosenRoute.action.type === "externalRedirectUrl" ? (
<span className="text-default underline">
<a
target="_blank"
data-testid="test-routing-result"
href={
chosenRoute.action.value.includes("https://") ||
chosenRoute.action.value.includes("http://")
? chosenRoute.action.value
: `http://${chosenRoute.action.value}`
}
rel="noreferrer">
{chosenRoute.action.value}
</a>
</span>
) : (
<div className="flex flex-col space-y-2">
<span className="text-default underline">
<a target="_blank" href={eventTypeUrl} rel="noreferrer" data-testid="test-routing-result">
{chosenRoute.action.value}
</a>
</span>
{isTeamForm ? (
<div>
<span>{t("matching_members")}:</span>{" "}
{!findTeamMembersMatchingAttributeLogicMutation.isPending ? (
<div>
{teamMembersMatchingAttributeLogic?.map((member) => member.email).join(", ") ||
t("no_matching_members")}
</div>
) : (
<div>Loading...</div>
)}
</div>
) : null}
</div>
)}
</div>
</div>
) : null;
return (
<Dialog open={isTestPreviewOpen} onOpenChange={setIsTestPreviewOpen}>
<DialogContent enableOverflow>
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
<div>
<form
onSubmit={(e) => {
e.preventDefault();
testRouting();
}}>
<div className="px-1">
{form && <FormInputFields form={form} response={response} setResponse={setResponse} />}
</div>
<div>{testResult}</div>
<DialogFooter>
<DialogClose
color="secondary"
onClick={() => {
setIsTestPreviewOpen(false);
setChosenRoute(null);
setResponse({});
}}>
{t("close")}
</DialogClose>
<Button type="submit" data-testid="test-routing">
{t("test_routing")}
</Button>
</DialogFooter>
</form>
</div>
</DialogContent>
</Dialog>
);
})();
return (
<>
<Form
@@ -503,84 +621,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
</ShellMain>
</FormActionsProvider>
</Form>
<Dialog open={isTestPreviewOpen} onOpenChange={setIsTestPreviewOpen}>
<DialogContent enableOverflow>
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
<div>
<form
onSubmit={(e) => {
e.preventDefault();
testRouting();
}}>
<div className="px-1">
{form && <FormInputFields form={form} response={response} setResponse={setResponse} />}
</div>
<div>
{decidedAction && (
<div className="bg-subtle text-default mt-5 rounded-md p-3">
<div className="font-bold ">{t("route_to")}:</div>
<div className="mt-2">
{RoutingPages.map((page) => {
if (page.value !== decidedAction.type) return null;
return (
<span key={page.value} data-testid="test-routing-result-type">
{page.label}
</span>
);
})}
:{" "}
{decidedAction.type === "customPageMessage" ? (
<span className="text-default" data-testid="test-routing-result">
{decidedAction.value}
</span>
) : decidedAction.type === "externalRedirectUrl" ? (
<span className="text-default underline">
<a
target="_blank"
data-testid="test-routing-result"
href={
decidedAction.value.includes("https://") ||
decidedAction.value.includes("http://")
? decidedAction.value
: `http://${decidedAction.value}`
}
rel="noreferrer">
{decidedAction.value}
</a>
</span>
) : (
<span className="text-default underline">
<a
target="_blank"
href={eventTypeUrl}
rel="noreferrer"
data-testid="test-routing-result">
{decidedAction.value}
</a>
</span>
)}
</div>
</div>
)}
</div>
<DialogFooter>
<DialogClose
color="secondary"
onClick={() => {
setIsTestPreviewOpen(false);
setDecidedAction(null);
setResponse({});
}}>
{t("close")}
</DialogClose>
<Button type="submit" data-testid="test-routing">
{t("test_routing")}
</Button>
</DialogFooter>
</form>
</div>
</DialogContent>
</Dialog>
{testFormDialog}
</>
);
}
@@ -0,0 +1,453 @@
// 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,
Settings as RAQBSettings,
Operator as RAQBOperator,
} from "react-awesome-query-builder";
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;
jsType: string;
toJS: (val: any) => any;
};
export type WidgetsWithoutFactory = Record<string, WidgetWithoutFactory>;
export type Type = RAQBType;
export type Types = Record<string, Type>;
export type Settings = RAQBSettings;
const conjunctions: Conjunctions = {
AND: {
label: "And",
jsonLogicConj: "and",
reversedConj: "OR",
},
OR: {
label: "Or",
jsonLogicConj: "or",
reversedConj: "AND",
},
};
const operators: Operators = {
equal: {
label: "Equals",
labelForFormat: "==",
reversedOp: "not_equal",
jsonLogic: "==",
},
not_equal: {
isNotOp: true,
label: "Does not equal",
labelForFormat: "!=",
reversedOp: "equal",
jsonLogic: "!=",
},
less: {
label: "Less than",
labelForFormat: "<",
reversedOp: "greater_or_equal",
jsonLogic: "<",
},
less_or_equal: {
label: "Less than or equal to",
labelForFormat: "<=",
reversedOp: "greater",
jsonLogic: "<=",
},
greater: {
label: "Greater than",
labelForFormat: ">",
reversedOp: "less_or_equal",
jsonLogic: ">",
},
greater_or_equal: {
label: "Greater than or equal to",
labelForFormat: ">=",
reversedOp: "less",
jsonLogic: ">=",
},
like: {
label: "Contains",
labelForFormat: "Contains",
reversedOp: "not_like",
jsonLogic: "in",
_jsonLogicIsRevArgs: true,
valueSources: ["value"],
},
not_like: {
isNotOp: true,
label: "Not contains",
reversedOp: "like",
labelForFormat: "Not Contains",
valueSources: ["value"],
},
/**
* Not supported with JSONLogic. Implement them and add these back -> https://github.com/jwadhams/json-logic-js/issues/81
*/
// starts_with: {
// label: "Starts with",
// labelForFormat: "Starts with",
// jsonLogic: undefined, // not supported
// valueSources: ["value"],
// },
// ends_with: {
// label: "Ends with",
// labelForFormat: "Ends with",
// jsonLogic: undefined, // not supported
// valueSources: ["value"],
// },
between: {
label: "Between",
labelForFormat: "BETWEEN",
cardinality: 2,
valueLabels: ["Value from", "Value to"],
reversedOp: "not_between",
jsonLogic: "<=",
},
not_between: {
isNotOp: true,
label: "Not between",
labelForFormat: "NOT BETWEEN",
cardinality: 2,
valueLabels: ["Value from", "Value to"],
reversedOp: "between",
},
is_empty: {
label: "Is empty",
labelForFormat: "IS EMPTY",
cardinality: 0,
reversedOp: "is_not_empty",
jsonLogic: "!",
},
is_not_empty: {
isNotOp: true,
label: "Is not empty",
labelForFormat: "IS NOT EMPTY",
cardinality: 0,
reversedOp: "is_empty",
jsonLogic: "!!",
},
/**
* We don't need these operators
*/
// is_null: {
// label: "Is null",
// labelForFormat: "IS NULL",
// cardinality: 0,
// reversedOp: "is_not_null",
// jsonLogic: "==",
// },
// is_not_null: {
// label: "Is not null",
// labelForFormat: "IS NOT NULL",
// cardinality: 0,
// reversedOp: "is_null",
// jsonLogic: "!=",
// },
select_equals: {
label: "Equals",
labelForFormat: "==",
reversedOp: "select_not_equals",
jsonLogic: "==",
},
select_not_equals: {
isNotOp: true,
label: "Does not equal",
labelForFormat: "!=",
reversedOp: "select_equals",
jsonLogic: "!=",
},
select_any_in: {
label: "Any in",
labelForFormat: "IN",
reversedOp: "select_not_any_in",
jsonLogic: "in",
},
select_not_any_in: {
isNotOp: true,
label: "Not in",
labelForFormat: "NOT IN",
reversedOp: "select_any_in",
},
multiselect_equals: {
// TODO: Consider renaming it to "includes" or similar due to faulty implementation of jsonLogic and the operator is in use by users.
label: "Equals",
labelForFormat: "==",
reversedOp: "multiselect_not_equals",
// jsonLogic2: "all-in",
jsonLogic: (field: any, op: any, vals: any) => ({
// This is wrongly implemented as "includes". This isn't "equals". Because if field is ["a" ] and vals is ["a", "b"], it still matches. Expectation would probably be that it should be a strict match(["a", "b"] or ["b", "a"])
all: [field, { in: [{ var: "" }, vals] }],
}),
},
multiselect_not_equals: {
isNotOp: true,
label: "Not equals",
labelForFormat: "!=",
reversedOp: "multiselect_equals",
},
some: {
label: "Some",
labelForFormat: "SOME",
cardinality: 0,
jsonLogic: "some",
},
all: {
label: "All",
labelForFormat: "ALL",
cardinality: 0,
},
none: {
label: "None",
labelForFormat: "NONE",
cardinality: 0,
jsonLogic: "none",
},
};
const widgets: WidgetsWithoutFactory = {
text: {
type: "text",
jsType: "string",
valueSrc: "value" as const,
valueLabel: "String",
valuePlaceholder: "Enter string",
toJS: (val: any) => val,
},
textarea: {
type: "text",
jsType: "string",
valueSrc: "value" as const,
valueLabel: "Text",
valuePlaceholder: "Enter text",
toJS: (val: any) => val,
},
number: {
type: "number",
jsType: "number",
valueSrc: "value" as const,
valueLabel: "Number",
valuePlaceholder: "Enter number",
toJS: (val: any) => val,
},
select: {
type: "select",
jsType: "string",
valueSrc: "value" as const,
valueLabel: "Value",
valuePlaceholder: "Select value",
toJS: (val: any) => val,
},
multiselect: {
type: "multiselect",
jsType: "array",
valueSrc: "value" as const,
valueLabel: "Values",
valuePlaceholder: "Select values",
toJS: (val: any) => val,
},
};
const types: Types = {
text: {
defaultOperator: "equal",
mainWidget: "text",
widgets: {
text: {
// Note: any operator mentioned here but isn't defined in operators will be automatically excluded
operators: [
"equal",
"not_equal",
"like",
"not_like",
"starts_with",
"ends_with",
"proximity",
"is_empty",
"is_not_empty",
"is_null",
"is_not_null",
],
widgetProps: {},
opProps: {},
},
textarea: {
operators: [
"equal",
"not_equal",
"like",
"not_like",
"starts_with",
"ends_with",
"is_empty",
"is_not_empty",
"is_null",
"is_not_null",
],
widgetProps: {},
opProps: {},
},
},
},
number: {
defaultOperator: "equal",
mainWidget: "number",
widgets: {
number: {
operators: [
"equal",
"not_equal",
"less",
"less_or_equal",
"greater",
"greater_or_equal",
"between",
"not_between",
// "is_empty",
// "is_not_empty",
"is_null",
"is_not_null",
],
},
// slider: {
// operators: [
// "equal",
// "not_equal",
// "less",
// "less_or_equal",
// "greater",
// "greater_or_equal",
// // "is_empty",
// // "is_not_empty",
// "is_null",
// "is_not_null",
// ],
// },
},
},
select: {
mainWidget: "select",
defaultOperator: "select_equals",
widgets: {
select: {
operators: [
"select_equals",
"select_not_equals",
// "is_empty",
// "is_not_empty",
"is_null",
"is_not_null",
],
widgetProps: {
customProps: {
showSearch: true,
},
},
},
multiselect: {
operators: [
// "select_any_in",
// "select_not_any_in",
// "is_empty",
// "is_not_empty",
"is_null",
"is_not_null",
],
},
},
},
multiselect: {
defaultOperator: "multiselect_equals",
widgets: {
multiselect: {
operators: [
"multiselect_equals",
"multiselect_not_equals",
// "is_empty",
// "is_not_empty",
"is_null",
"is_not_null",
],
},
},
},
// "!group": {
// defaultOperator: "some",
// mainWidget: "number",
// widgets: {
// number: {
// widgetProps: {
// min: 0,
// },
// operators: [
// // w/o operand
// "some",
// "all",
// "none",
// // w/ operand - count
// "equal",
// "not_equal",
// "less",
// "less_or_equal",
// "greater",
// "greater_or_equal",
// "between",
// "not_between",
// ],
// opProps: {
// equal: {
// label: "Count ==",
// },
// not_equal: {
// label: "Count !=",
// },
// less: {
// label: "Count <",
// },
// less_or_equal: {
// label: "Count <=",
// },
// greater: {
// label: "Count >",
// },
// greater_or_equal: {
// label: "Count >=",
// },
// between: {
// label: "Count between",
// },
// not_between: {
// label: "Count not between",
// },
// },
// },
// },
// },
// case_value: {
// mainWidget: "case_value",
// widgets: {
// case_value: {},
// },
// },
};
const settings: Settings = {
setOpOnChangeField: ["keep" as const, "default" as const], // 'default' (default if present), 'keep' (keep prev from last field), 'first', 'none'
};
export default {
conjunctions,
operators,
widgets,
types,
settings,
};
@@ -1,14 +1,16 @@
import type { ChangeEvent } from "react";
import type { Settings, Widgets, SelectWidgetProps } from "react-awesome-query-builder";
import type { Settings, Widgets, SelectWidgetProps, SelectWidget } from "react-awesome-query-builder";
import widgetsComponents from "../widgets";
// Figure out why routing-forms/env.d.ts doesn't work
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
import BasicConfig from "react-awesome-query-builder/lib/config/basic";
import { EmailField } from "@calcom/ui";
import widgetsComponents from "../widgets";
import BasicConfig, { Operators, Types } from "./BasicConfig";
const enum ConfigFor {
FormFields = "FormFields",
Attributes = "Attributes",
}
const {
TextWidget,
TextAreaWidget,
@@ -20,6 +22,7 @@ const {
Button,
ButtonGroup,
Provider,
EmailWidget,
} = widgetsComponents;
const renderComponent = function <T1>(props: T1 | undefined, Component: React.FC<T1>) {
@@ -29,128 +32,206 @@ const renderComponent = function <T1>(props: T1 | undefined, Component: React.FC
return <Component {...props} />;
};
const settings: Settings = {
...BasicConfig.settings,
function getSettings(_configFor: ConfigFor) {
const settings: Settings = {
...BasicConfig.settings,
renderField: (props) => renderComponent(props, FieldSelect),
renderOperator: (props) => renderComponent(props, FieldSelect),
renderFunc: (props) => renderComponent(props, FieldSelect),
renderConjs: (props) => renderComponent(props, Conjs),
renderButton: (props) => renderComponent(props, Button),
renderButtonGroup: (props) => renderComponent(props, ButtonGroup),
renderProvider: (props) => renderComponent(props, Provider),
renderField: (props) => renderComponent(props, FieldSelect),
renderOperator: (props) => renderComponent(props, FieldSelect),
renderFunc: (props) => renderComponent(props, FieldSelect),
renderConjs: (props) => renderComponent(props, Conjs),
renderButton: (props) => renderComponent(props, Button),
renderButtonGroup: (props) => renderComponent(props, ButtonGroup),
renderProvider: (props) => renderComponent(props, Provider),
groupActionsPosition: "bottomCenter",
groupActionsPosition: "bottomCenter",
// Disable groups
maxNesting: 1,
};
// Disable groups
maxNesting: 1,
};
return settings;
}
// react-query-builder types have missing type property on Widget
//TODO: Reuse FormBuilder Components - FormBuilder components are built considering Cal.com design system and coding guidelines. But when awesome-query-builder renders these components, it passes its own props which are different from what our Components expect.
// So, a mapper should be written here that maps the props provided by awesome-query-builder to the props that our components expect.
const widgets: Widgets & { [key in keyof Widgets]: Widgets[key] & { type: string } } = {
...BasicConfig.widgets,
text: {
...BasicConfig.widgets.text,
factory: (props) => renderComponent(props, TextWidget),
},
textarea: {
...BasicConfig.widgets.textarea,
factory: (props) => renderComponent(props, TextAreaWidget),
},
number: {
...BasicConfig.widgets.number,
factory: (props) => renderComponent(props, NumberWidget),
},
multiselect: {
...BasicConfig.widgets.multiselect,
factory: (
props: SelectWidgetProps & {
listValues: { title: string; value: string }[];
}
) => renderComponent(props, MultiSelectWidget),
},
select: {
...BasicConfig.widgets.select,
factory: (
props: SelectWidgetProps & {
listValues: { title: string; value: string }[];
}
) => renderComponent(props, SelectWidget),
},
phone: {
...BasicConfig.widgets.text,
factory: (props) => {
if (!props) {
return <div />;
}
return <TextWidget type="tel" {...props} />;
function getWidgets(_configFor: ConfigFor) {
const widgets: Widgets & { [key in keyof Widgets]: Widgets[key] & { type: string } } = {
...BasicConfig.widgets,
text: {
...BasicConfig.widgets.text,
factory: (props) => renderComponent(props, TextWidget),
},
valuePlaceholder: "Enter Phone Number",
},
email: {
...BasicConfig.widgets.text,
factory: (props) => {
if (!props) {
return <div />;
}
textarea: {
...BasicConfig.widgets.textarea,
factory: (props) => renderComponent(props, TextAreaWidget),
},
number: {
...BasicConfig.widgets.number,
factory: (props) => renderComponent(props, NumberWidget),
},
multiselect: {
...BasicConfig.widgets.multiselect,
factory: (
props?: SelectWidgetProps & {
listValues: { title: string; value: string }[];
}
) => renderComponent(props, MultiSelectWidget),
} as SelectWidget,
select: {
...BasicConfig.widgets.select,
factory: (
props: SelectWidgetProps & {
listValues: { title: string; value: string }[];
}
) => renderComponent(props, SelectWidget),
} as SelectWidget,
phone: {
...BasicConfig.widgets.text,
factory: (props) => {
if (!props) {
return <div />;
}
return <TextWidget type="tel" {...props} />;
},
valuePlaceholder: "Enter Phone Number",
},
email: {
...BasicConfig.widgets.text,
factory: (props) => {
if (!props) {
return <div />;
}
return (
<EmailField
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
props.setValue(val);
}}
containerClassName="w-full"
className="dark:placeholder:text-darkgray-600 focus:border-brand border-subtle dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500"
{...props}
/>
);
return (
<EmailWidget
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
props.setValue(val);
}}
containerClassName="w-full"
className="dark:placeholder:text-darkgray-600 focus:border-brand border-subtle dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500"
{...props}
/>
);
},
},
},
};
return widgets;
}
function getTypes(_configFor: ConfigFor) {
const types: Types = {
...BasicConfig.types,
phone: {
...BasicConfig.types.text,
widgets: {
...BasicConfig.types.text.widgets,
},
},
email: {
...BasicConfig.types.text,
widgets: {
...BasicConfig.types.text.widgets,
},
},
multiselect: {
...BasicConfig.types.multiselect,
widgets: {
...BasicConfig.types.multiselect.widgets,
multiselect: {
...BasicConfig.types.multiselect.widgets.multiselect,
operators: [
...(BasicConfig.types.multiselect.widgets.multiselect.operators || []),
// TODO: First verify the definition of multiselect_contains and multiselect_not_contains and then uncomment these operators
// "multiselect_contains",
// "multiselect_not_contains",
],
},
},
},
};
return types;
}
function getOperators(configFor: ConfigFor) {
// Clone to avoid mutating the original object
const operators: Operators = {
...BasicConfig.operators,
// Attributes don't need reporting at the moment. So, we can support contains and not contains operators for attributes.
...(configFor === ConfigFor.Attributes
? {
multiselect_contains: {
label: "Contains",
labelForFormat: "CONTAINS",
reversedOp: "multiselect_not_contains",
// jsonLogic2: "some-in",
jsonLogic: function (e, t, r) {
return {
some: [
e,
{
in: [
{
var: "",
},
r,
],
},
],
};
},
},
multiselect_not_contains: {
isNotOp: !0,
label: "Not contains",
labelForFormat: "NOT CONTAINS",
reversedOp: "multiselect_contains",
// jsonLogic2: "!some-in",
jsonLogic: function (e, t, r) {
return {
"!": {
some: [
e,
{
in: [
{
var: "",
},
r,
],
},
],
},
};
},
_jsonLogicIsExclamationOp: !0,
},
}
: {}),
};
return operators;
}
function getConjunctions(_configFor: ConfigFor) {
return {
...BasicConfig.conjunctions,
};
}
export const FormFieldsBaseConfig = {
conjunctions: getConjunctions(ConfigFor.FormFields),
operators: getOperators(ConfigFor.FormFields),
types: getTypes(ConfigFor.FormFields),
widgets: getWidgets(ConfigFor.FormFields),
settings: getSettings(ConfigFor.FormFields),
};
const types = {
...BasicConfig.types,
phone: {
...BasicConfig.types.text,
widgets: {
...BasicConfig.types.text.widgets,
},
},
email: {
...BasicConfig.types.text,
widgets: {
...BasicConfig.types.text.widgets,
},
},
};
const operators = BasicConfig.operators;
operators.equal.label = operators.select_equals.label = "Equals";
operators.greater_or_equal.label = "Greater than or equal to";
operators.greater.label = "Greater than";
operators.less_or_equal.label = "Less than or equal to";
operators.less.label = "Less than";
operators.not_equal.label = operators.select_not_equals.label = "Does not equal";
operators.between.label = "Between";
delete operators.proximity;
delete operators.is_null;
delete operators.is_not_null;
/**
* Not supported with JSONLogic. Implement them and add these back -> https://github.com/jwadhams/json-logic-js/issues/81
*/
delete operators.starts_with;
delete operators.ends_with;
const config = {
conjunctions: BasicConfig.conjunctions,
operators,
types,
widgets,
settings,
};
export default config;
export const AttributesBaseConfig = {
conjunctions: getConjunctions(ConfigFor.Attributes),
operators: getOperators(ConfigFor.Attributes),
types: getTypes(ConfigFor.Attributes),
widgets: getWidgets(ConfigFor.Attributes),
settings: getSettings(ConfigFor.Attributes),
};
@@ -8,7 +8,7 @@ import type {
ProviderProps,
} from "react-awesome-query-builder";
import { Button as CalButton, TextField, TextArea } from "@calcom/ui";
import { Button as CalButton, TextField, TextArea, EmailField } from "@calcom/ui";
import { Icon } from "@calcom/ui";
const Select = dynamic(
@@ -299,7 +299,7 @@ function Conjs({ not, setNot, config, conjunctionOptions, setConjunction, disabl
value = value == "any" ? "none" : "all";
}
const selectValue = options.find((option) => option.value === value);
const summary = !config.operators.__calReporting ? "Rule group when" : "Query where";
const summary = !config.operators.__calReporting ? "where" : "Query where";
return (
<div className="flex items-center text-sm">
<span>{summary}</span>
@@ -371,6 +371,7 @@ const widgets = {
ButtonGroup,
Conjs,
Provider,
EmailWidget: EmailField,
};
export default widgets;
@@ -1,30 +1,40 @@
export const enum RoutingFormFieldType {
TEXT = "text",
NUMBER = "number",
TEXTAREA = "textarea",
SINGLE_SELECT = "select",
MULTI_SELECT = "multiselect",
PHONE = "phone",
EMAIL = "email",
}
export const FieldTypes = [
{
label: "Short Text",
value: "text",
value: RoutingFormFieldType.TEXT,
},
{
label: "Number",
value: "number",
value: RoutingFormFieldType.NUMBER,
},
{
label: "Long Text",
value: "textarea",
value: RoutingFormFieldType.TEXTAREA,
},
{
label: "Single Selection",
value: "select",
value: RoutingFormFieldType.SINGLE_SELECT,
},
{
label: "Multiple Selection",
value: "multiselect",
value: RoutingFormFieldType.MULTI_SELECT,
},
{
label: "Phone",
value: "phone",
value: RoutingFormFieldType.PHONE,
},
{
label: "Email",
value: "email",
value: RoutingFormFieldType.EMAIL,
},
] as const;
@@ -1,3 +1,4 @@
import QueryBuilderInitialConfig from "../components/react-awesome-query-builder/config/config";
import { FormFieldsBaseConfig, AttributesBaseConfig } from "../components/react-awesome-query-builder/config/config";
export const InitialConfig = QueryBuilderInitialConfig;
export const FormFieldsInitialConfig = FormFieldsBaseConfig;
export const AttributesInitialConfig = AttributesBaseConfig;
@@ -2,12 +2,12 @@ import { describe, it, expect } from "vitest";
import { vi } from "vitest";
import type { RoutingForm } from "../../types/types";
import { InitialConfig } from "../InitialConfig";
import { getQueryBuilderConfig } from "../getQueryBuilderConfig";
import { FormFieldsInitialConfig } from "../InitialConfig";
import { getQueryBuilderConfigForFormFields } from "../getQueryBuilderConfig";
type MockedForm = Pick<RoutingForm, "fields">;
vi.mock("../InitialConfig", () => ({
InitialConfig: {
FormFieldsInitialConfig: {
widgets: {
text: { type: "text" },
select: { type: "select" },
@@ -46,7 +46,7 @@ describe("getQueryBuilderConfig", () => {
};
it("should generate correct config for all field types", () => {
const config = getQueryBuilderConfig(mockForm);
const config = getQueryBuilderConfigForFormFields(mockForm);
expect(config.fields).toHaveProperty("field1");
expect(config.fields).toHaveProperty("field2");
@@ -103,7 +103,7 @@ describe("getQueryBuilderConfig", () => {
],
};
const config = getQueryBuilderConfig(formWithRouterField);
const config = getQueryBuilderConfigForFormFields(formWithRouterField);
expect(config.fields).toHaveProperty("innerField");
expect(config.fields.innerField).toEqual({
@@ -126,13 +126,13 @@ describe("getQueryBuilderConfig", () => {
],
};
expect(() => getQueryBuilderConfig(formWithUnsupportedField)).toThrow(
expect(() => getQueryBuilderConfigForFormFields(formWithUnsupportedField)).toThrow(
"Unsupported field type:unsupported"
);
});
it("should remove specific operators when forReporting is true", () => {
const config = getQueryBuilderConfig(mockForm, true);
const config = getQueryBuilderConfigForFormFields(mockForm, true);
expect(config.operators).not.toHaveProperty("is_empty");
expect(config.operators).not.toHaveProperty("is_not_empty");
@@ -142,7 +142,7 @@ describe("getQueryBuilderConfig", () => {
});
it("should include all operators when forReporting is false", () => {
const config = getQueryBuilderConfig(mockForm, false);
const config = getQueryBuilderConfigForFormFields(mockForm, false);
expect(config.operators).toHaveProperty("is_empty");
expect(config.operators).toHaveProperty("is_not_empty");
@@ -152,11 +152,11 @@ describe("getQueryBuilderConfig", () => {
});
it("should use InitialConfig as base for the returned config", () => {
const config = getQueryBuilderConfig(mockForm);
const config = getQueryBuilderConfigForFormFields(mockForm);
expect(config).toEqual(
expect.objectContaining({
...InitialConfig,
...FormFieldsInitialConfig,
fields: expect.any(Object),
})
);
@@ -1,6 +1,6 @@
import { Utils as QbUtils } from "react-awesome-query-builder";
import type { GlobalRoute, SerializableRoute } from "../types/types";
import type { GlobalRoute, LocalRoute, SerializableRoute } from "../types/types";
export const createFallbackRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
const uuid = QbUtils.uuid();
@@ -11,6 +11,6 @@ export const createFallbackRoute = (): Exclude<SerializableRoute, GlobalRoute> =
type: "customPageMessage",
value: "Thank you for your interest! We will be in touch soon.",
},
queryValue: { id: uuid, type: "group" },
queryValue: { id: uuid, type: "group" } as LocalRoute["queryValue"],
};
};
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from "vitest";
import { FormFieldsBaseConfig } from "../components/react-awesome-query-builder/config/config";
import { evaluateRaqbLogic, RaqbLogicResult } from "./evaluateRaqbLogic";
vi.mock("../components/react-awesome-query-builder/widgets", () => ({
default: {},
}));
vi.mock("@calcom/ui");
describe("evaluateRaqbLogic", () => {
it("should return a match for multiselect_equals", () => {
const result = evaluateRaqbLogic({
queryValue: {
id: "88bbb99b-cdef-4012-b456-71925b0e4b7f",
type: "group",
children1: {
"b9aa9b89-0123-4456-b89a-b19264fe16af": {
type: "rule",
properties: {
field: "0f18439b-2e28-45eb-bd79-8ed9e0236d6e",
value: [["nevada"]],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
},
},
},
},
queryBuilderConfig: {
...FormFieldsBaseConfig,
fields: {
"0f18439b-2e28-45eb-bd79-8ed9e0236d6e": {
label: "Geographic States",
type: "multiselect",
valueSources: ["value"],
fieldSettings: {
listValues: [
{ title: "Arizona", value: "arizona" },
{ title: "Nevada", value: "nevada" },
{ title: "California", value: "california" },
],
},
},
},
},
data: {
"15a746c6-179f-4457-bc9f-76d14164dc13": "no",
"0f18439b-2e28-45eb-bd79-8ed9e0236d6e": ["nevada"],
},
});
expect(result).toBe(RaqbLogicResult.MATCH);
});
});
@@ -0,0 +1,40 @@
"use client";
import { Utils as QbUtils, type JsonTree } from "react-awesome-query-builder";
import jsonLogic from "./jsonLogic";
import { safeStringify } from "@calcom/lib/safeStringify";
export const enum RaqbLogicResult {
MATCH = "MATCH",
NO_MATCH = "NO_MATCH",
LOGIC_NOT_FOUND_SO_MATCHED = "LOGIC_NOT_FOUND_SO_MATCHED",
}
export const evaluateRaqbLogic = ({
queryValue,
queryBuilderConfig,
data,
beStrictWithEmptyLogic = false,
}: {
queryValue: JsonTree;
queryBuilderConfig: any;
data: Record<string, unknown>;
beStrictWithEmptyLogic?: boolean;
}): RaqbLogicResult => {
const state = {
tree: QbUtils.checkTree(QbUtils.loadTree(queryValue), queryBuilderConfig),
config: queryBuilderConfig,
};
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
const logic = jsonLogicQuery.logic;
if (!logic) {
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 }));
// If no logic is provided, then consider it a match
return RaqbLogicResult.LOGIC_NOT_FOUND_SO_MATCHED;
}
console.log("Checking logic with data", safeStringify({ logic, data }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return !!jsonLogic.apply(logic as any, data) ? RaqbLogicResult.MATCH : RaqbLogicResult.NO_MATCH;
};
@@ -0,0 +1,138 @@
// TODO: Queries in this file are not optimized. Need to optimize them.
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 whereClauseForAttributesAssignedToMembersOfTeam = {
member: {
user: {
teams: {
some: {
teamId,
},
},
},
},
};
log.debug(
safeStringify({
teamId,
whereClauseForAttributesAssignedToMembersOfTeam,
})
);
const attributesToUser = await prisma.attributeToUser.findMany({
where: whereClauseForAttributesAssignedToMembersOfTeam,
select: {
member: {
select: {
userId: true,
},
},
attributeOption: {
select: {
id: true,
value: true,
slug: true,
attribute: {
select: { id: true, name: true, type: true, slug: true },
},
},
},
},
});
log.debug("Returned attributesToUser", safeStringify({ attributesToUser }));
return attributesToUser;
}
async function getAttributesAssignedToMembersOfTeam({ teamId }: { teamId: number }) {
let log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] });
const whereClauseForAttributesAssignedToMembersOfTeam = {
options: {
some: {
assignedUsers: {
some: {
member: {
user: {
teams: {
some: {
teamId,
},
},
},
},
},
},
},
},
}
log.debug(
safeStringify({
teamId,
whereClauseForAttributesAssignedToMembersOfTeam,
})
);
const attributesToUser = await prisma.attribute.findMany({
where: whereClauseForAttributesAssignedToMembersOfTeam,
select: {
id: true,
name: true,
type: true,
options: {
select: {
id: true,
value: true,
slug: true,
},
},
slug: true,
},
});
return attributesToUser;
}
export async function getAttributesForTeam({ teamId }: { teamId: number }) {
const attributes = await getAttributesAssignedToMembersOfTeam({ teamId });
return attributes satisfies Attribute[];
}
type AttributeId = string;
type AttributeOptionValue = string | string[];
export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamId }: { teamId: number }) {
const attributesToUser = await getAttributeToUserWithMembershipAndAttributesForTeam({ teamId });
const teamMembers = attributesToUser.reduce((acc, attributeToUser) => {
const { userId } = attributeToUser.member;
const { attribute, value } = attributeToUser.attributeOption;
if (!acc[userId]) {
acc[userId] = { userId, attributes: {} };
}
const attributes = acc[userId].attributes;
const attributeValue = attributes[attribute.id];
if (attributeValue instanceof Array) {
// Push to existing array
attributeValue.push(value);
} else if (attributeValue) {
// Make it an array
attributes[attribute.id] = [attributeValue, value];
} else {
// Set it as a string
attributes[attribute.id] = value;
}
return acc;
}, {} as Record<number, { userId: number; attributes: Record<AttributeId, AttributeOptionValue> }>);
return Object.values(teamMembers);
}
@@ -1,23 +1,42 @@
import type { QueryBuilderUpdatedConfig, RoutingForm } from "../types/types";
import { FieldTypes } from "./FieldTypes";
import { InitialConfig } from "./InitialConfig";
import { AttributeType } from "@calcom/prisma/enums";
import type { RoutingForm, Attribute } from "../types/types";
import { FieldTypes, RoutingFormFieldType } from "./FieldTypes";
import { AttributesInitialConfig, FormFieldsInitialConfig } from "./InitialConfig";
import { getUIOptionsForSelect } from "./selectOptions";
export function getQueryBuilderConfig(form: Pick<RoutingForm, "fields">, forReporting = false) {
const fields: Record<
string,
{
label: string;
type: string;
valueSources: ["value"];
fieldSettings: {
listValues?: {
value: string;
title: string;
}[];
};
}
> = {};
type RaqbConfigFields = Record<
string,
{
label: string;
type: string;
valueSources: ["value"];
fieldSettings: {
listValues?: {
value: string;
title: string;
}[];
};
}
>;
// FIXME: Add TS Magic to ensure all types of attributes are mapped to RoutingFormFieldType
const attributeTypesMap = new Map<keyof typeof AttributeType, RoutingFormFieldType>([
[AttributeType.SINGLE_SELECT, RoutingFormFieldType.SINGLE_SELECT],
[AttributeType.MULTI_SELECT, RoutingFormFieldType.MULTI_SELECT],
[AttributeType.TEXT, RoutingFormFieldType.TEXT],
[AttributeType.NUMBER, RoutingFormFieldType.NUMBER],
]);
export type FormFieldsQueryBuilderConfigWithRaqbFields = ReturnType<
typeof getQueryBuilderConfigForFormFields
>;
export type AttributesQueryBuilderConfigWithRaqbFields = ReturnType<
typeof getQueryBuilderConfigForAttributes
>;
export function getQueryBuilderConfigForFormFields(form: Pick<RoutingForm, "fields">, forReporting = false) {
const fields: RaqbConfigFields = {};
form.fields?.forEach((field) => {
if ("routerField" in field) {
field = field.routerField;
@@ -27,7 +46,7 @@ export function getQueryBuilderConfig(form: Pick<RoutingForm, "fields">, forRepo
if (FieldTypes.map((f) => f.value).includes(fieldType)) {
const options = getUIOptionsForSelect(field);
const widget = InitialConfig.widgets[fieldType];
const widget = FormFieldsInitialConfig.widgets[fieldType];
const widgetType = widget.type;
fields[field.id] = {
@@ -44,7 +63,13 @@ export function getQueryBuilderConfig(form: Pick<RoutingForm, "fields">, forRepo
}
});
const initialConfigCopy = { ...InitialConfig, operators: { ...InitialConfig.operators } };
const initialConfigCopy = {
...FormFieldsInitialConfig,
operators: { ...FormFieldsInitialConfig.operators } as typeof FormFieldsInitialConfig.operators & {
__calReporting?: boolean;
},
};
if (forReporting) {
// Empty and Not empty doesn't work well with JSON querying in prisma. Try to implement these when we desperately need these operators.
delete initialConfigCopy.operators.is_empty;
@@ -57,9 +82,80 @@ export function getQueryBuilderConfig(form: Pick<RoutingForm, "fields">, forRepo
initialConfigCopy.operators.__calReporting = true;
}
// You need to provide your own config. See below 'Config format'
const config: QueryBuilderUpdatedConfig = {
const config = {
...initialConfigCopy,
fields: fields,
};
return config;
}
function transformAttributesToCompatibleFormat(attributes: Attribute[]) {
return attributes.map((attribute) => {
const mappedType = attributeTypesMap.get(attribute.type);
if (!mappedType) {
throw new Error(`Unsupported attribute type:${attribute.type}`);
}
return {
label: attribute.name,
id: attribute.id,
type: mappedType,
options: attribute.options.map((option) => ({
title: option.value,
// We have to use something that doesn't change often. ID of attribute never changes. Changing means a saved value will become invalid
value: option.id,
})),
};
});
}
export function getQueryBuilderConfigForAttributes({
attributes,
form,
}: {
attributes: Attribute[];
form: Pick<RoutingForm, "fields">;
}) {
const transformedAttributes = transformAttributesToCompatibleFormat(attributes);
const fields: RaqbConfigFields = {};
transformedAttributes.forEach((attribute) => {
const attributeType = attribute.type as (typeof FieldTypes)[number]["value"];
if (FieldTypes.map((f) => f.value).includes(attributeType)) {
// We can assert the type because otherwise we throw 'Unsupported field type' error
const widget = FormFieldsInitialConfig.widgets[attributeType];
const widgetType = widget.type;
const attributeOptions = attribute.options.concat(
(() => {
const formFields = form.fields || [];
const formFieldsOptions = formFields.map((field) => ({
title: `Value of field '${field.label}'`,
value: `{field:${field.id}}`,
}));
return formFieldsOptions;
})()
);
// These are RAQB fields
fields[attribute.id] = {
label: attribute.label,
type: widgetType,
valueSources: ["value"],
fieldSettings: {
// IMPORTANT: listValues must be undefined for non-select/multiselect fields otherwise RAQB doesn't like it. It ends up considering all the text values as per the listValues too which could be empty as well making all values invalid
listValues:
attributeType === "select" || attributeType === "multiselect" ? attributeOptions : undefined,
},
};
} else {
throw new Error(`Unsupported field type:${attribute.type}`);
}
});
const initialConfigCopy = {
...AttributesInitialConfig,
operators: { ...AttributesInitialConfig.operators },
};
return {
...initialConfigCopy,
fields: fields,
};
}
@@ -11,7 +11,9 @@ 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
*/
@@ -25,6 +27,7 @@ export async function getSerializableForm<TForm extends App_RoutingForms_Form>({
const prisma = (await import("@calcom/prisma")).default;
const routesParsed = zodRoutes.safeParse(form.routes);
if (!routesParsed.success) {
log.error("Error parsing routes", safeStringify(routesParsed.error));
throw new Error("Error parsing routes");
}
@@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import jsonLogic from "./jsonLogic";
describe("jsonLogic", () => {
describe("== operation", () => {
it("should compare strings case-insensitively", () => {
expect(jsonLogic.apply({ "==": ["hello", "HELLO"] })).toBe(true);
expect(jsonLogic.apply({ "==": ["hello", "world"] })).toBe(false);
});
it.skip("should compare arrays case-insensitively", () => {
expect(
jsonLogic.apply({
"==": [
["hello", "WORLD"],
["HELLO", "world"],
],
})
).toBe(true);
expect(
jsonLogic.apply({
"==": [
["hello", "WORLD"],
["hi", "earth"],
],
})
).toBe(false);
});
});
describe("=== operation", () => {
it("should compare strings case-insensitively", () => {
expect(jsonLogic.apply({ "===": ["hello", "HELLO"] })).toBe(true);
expect(jsonLogic.apply({ "===": ["hello", "world"] })).toBe(false);
});
});
describe("!== operation", () => {
it("should compare strings case-insensitively", () => {
expect(jsonLogic.apply({ "!==": ["hello", "HELLO"] })).toBe(false);
expect(jsonLogic.apply({ "!==": ["hello", "world"] })).toBe(true);
});
});
describe("!= operation", () => {
it("should compare strings case-insensitively", () => {
expect(jsonLogic.apply({ "!=": ["hello", "HELLO"] })).toBe(false);
expect(jsonLogic.apply({ "!=": ["hello", "world"] })).toBe(true);
});
});
describe("in operation", () => {
it("should check if a string is in another string case-insensitively", () => {
expect(jsonLogic.apply({ in: ["lo", "HELLO"] })).toBe(true);
expect(jsonLogic.apply({ in: ["hi", "HELLO"] })).toBe(false);
});
it("should check if a string is in an array case-insensitively", () => {
expect(jsonLogic.apply({ in: ["hello", ["HELLO", "WORLD"]] })).toBe(true);
expect(jsonLogic.apply({ in: ["hi", ["HELLO", "WORLD"]] })).toBe(false);
});
it("should return false if the second argument is falsy", () => {
expect(jsonLogic.apply({ in: ["hello", false] })).toBe(false);
expect(jsonLogic.apply({ in: ["hello", ""] })).toBe(false);
});
});
describe("all-in", () => {
it("should return true if the variable value is an array and contains the string", () => {
expect(
jsonLogic.apply(
{
and: [
{
all: [{ var: "location" }, { in: [{ var: "" }, ["nevada", "abc"]] }],
},
],
},
{
there: "no",
location: ["nevada"],
}
)
).toBe(true);
});
});
describe("some-in", () => {
it("should return true if the variable value is an array and contains the string", () => {
expect(
jsonLogic.apply(
{
some: [
{ var: "location" },
{
in: [
{
var: "",
},
["nevada", "ABC"],
],
},
],
},
{
there: "no",
location: ["nevada"],
}
)
).toBe(true);
});
});
});
@@ -1,28 +1,27 @@
"use client";
import type { App_RoutingForms_Form } from "@prisma/client";
import { Utils as QbUtils } from "react-awesome-query-builder";
import type { z } from "zod";
import type { FormResponse, Route, SerializableForm } from "../types/types";
import type { zodNonRouterRoute } from "../zod";
import { getQueryBuilderConfig } from "./getQueryBuilderConfig";
import { getQueryBuilderConfigForFormFields } from "./getQueryBuilderConfig";
import { isFallbackRoute } from "./isFallbackRoute";
import isRouter from "./isRouter";
import jsonLogic from "./jsonLogicOverrides";
import { evaluateRaqbLogic, RaqbLogicResult } from "./evaluateRaqbLogic";
export function processRoute({
export function findMatchingRoute({
form,
response,
}: {
form: SerializableForm<App_RoutingForms_Form>;
response: Record<string, Pick<FormResponse[string], "value">>;
}) {
const queryBuilderConfig = getQueryBuilderConfig(form);
const queryBuilderConfig = getQueryBuilderConfigForFormFields(form);
const routes = form.routes || [];
let decidedAction: Route["action"] | null = null;
let chosenRoute: Route | null = null;
const fallbackRoute = routes.find(isFallbackRoute);
@@ -41,41 +40,30 @@ export function processRoute({
// After above flat map, all routes are non router routes.
.concat([fallbackRoute]) as z.infer<typeof zodNonRouterRoute>[];
routesWithFallbackInEnd.some((route) => {
for (const route of routesWithFallbackInEnd) {
if (!route) {
return false;
}
const state = {
tree: QbUtils.checkTree(QbUtils.loadTree(route.queryValue), queryBuilderConfig),
config: queryBuilderConfig,
};
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
const logic = jsonLogicQuery.logic;
let result = false;
const responseValues: Record<string, FormResponse[string]["value"]> = {};
for (const [uuid, { value }] of Object.entries(response)) {
responseValues[uuid] = value;
continue;
}
if (logic) {
// Leave the logs for debugging of routing form logic test in production
console.log("Checking logic with response", logic, responseValues);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = jsonLogic.apply(logic as any, responseValues);
} else {
// If no logic is provided, then consider it a match
result = true;
}
if (result) {
decidedAction = route.action;
return true;
}
});
const responseValues: Record<string, FormResponse[string]["value"]> = Object.fromEntries(
Object.entries(response).map(([uuid, { value }]) => [uuid, value])
);
if (!decidedAction) {
const result = evaluateRaqbLogic({
queryValue: route.queryValue,
queryBuilderConfig,
data: responseValues,
});
if (result === RaqbLogicResult.MATCH || result === RaqbLogicResult.LOGIC_NOT_FOUND_SO_MATCHED) {
chosenRoute = route;
break;
}
}
if (!chosenRoute) {
return null;
}
// Without type assertion, it is never. See why https://github.com/microsoft/TypeScript/issues/16928
return decidedAction as Route["action"];
return chosenRoute;
}
@@ -1,10 +1,10 @@
import slugify from "@calcom/lib/slugify";
import type { FormResponse, Route, Field } from "../types/types";
import type { FormResponse, NonRouterRoute, Field } from "../types/types";
import getFieldIdentifier from "./getFieldIdentifier";
export const substituteVariables = (
routeValue: Route["action"]["value"],
routeValue: NonRouterRoute["action"]["value"],
response: FormResponse,
fields: Field[]
) => {
@@ -22,14 +22,13 @@ import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import SingleForm, {
getServerSidePropsForSingleFormView as getServerSideProps,
} from "../../components/SingleForm";
import type QueryBuilderInitialConfig from "../../components/react-awesome-query-builder/config/config";
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 { getQueryBuilderConfig } from "../../lib/getQueryBuilderConfig";
import { getQueryBuilderConfigForFormFields,type FormFieldsQueryBuilderConfigWithRaqbFields } from "../../lib/getQueryBuilderConfig";
export { getServerSideProps };
type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
const Result = ({ formId, jsonLogicQuery }: { formId: string; jsonLogicQuery: JsonLogicQuery | null }) => {
const { t } = useLocale();
@@ -129,10 +128,10 @@ const Result = ({ formId, jsonLogicQuery }: { formId: string; jsonLogicQuery: Js
);
};
const getInitialQuery = (config: ReturnType<typeof getQueryBuilderConfig>) => {
const getInitialQuery = (config: ReturnType<typeof getQueryBuilderConfigForFormFields>) => {
const uuid = QbUtils.uuid();
const queryValue: JsonTree = { id: uuid, type: "group" } as JsonTree;
const tree = QbUtils.checkTree(QbUtils.loadTree(queryValue), config);
const tree = QbUtils.checkTree(QbUtils.loadTree(queryValue), config as unknown as Config);
return {
state: { tree, config },
queryValue,
@@ -140,17 +139,17 @@ const getInitialQuery = (config: ReturnType<typeof getQueryBuilderConfig>) => {
};
const Reporter = ({ form }: { form: inferSSRProps<typeof getServerSideProps>["form"] }) => {
const config = getQueryBuilderConfig(form, true);
const config = getQueryBuilderConfigForFormFields(form, true);
const [query, setQuery] = useState(getInitialQuery(config));
const [jsonLogicQuery, setJsonLogicQuery] = useState<JsonLogicResult | null>(null);
const onChange = (immutableTree: ImmutableTree, config: QueryBuilderUpdatedConfig) => {
const onChange = (immutableTree: ImmutableTree, config: FormFieldsQueryBuilderConfigWithRaqbFields) => {
const jsonTree = QbUtils.getTree(immutableTree);
setQuery(() => {
const newValue = {
state: { tree: immutableTree, config: config },
queryValue: jsonTree,
};
setJsonLogicQuery(QbUtils.jsonLogicFormat(newValue.state.tree, config));
setJsonLogicQuery(QbUtils.jsonLogicFormat(newValue.state.tree, config as unknown as Config));
return newValue;
});
};
@@ -168,10 +167,10 @@ const Reporter = ({ form }: { form: inferSSRProps<typeof getServerSideProps>["fo
return (
<div className="cal-query-builder">
<Query
{...config}
{...config as unknown as Config}
value={query.state.tree}
onChange={(immutableTree, config) => {
onChange(immutableTree, config as QueryBuilderUpdatedConfig);
onChange(immutableTree, config as unknown as FormFieldsQueryBuilderConfigWithRaqbFields);
}}
renderBuilder={renderBuilder}
/>
@@ -4,13 +4,13 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import Link from "next/link";
import React, { useCallback, useState, useEffect } from "react";
import { Query, Builder, Utils as QbUtils } from "react-awesome-query-builder";
// types
import type { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
import type { ImmutableTree, BuilderProps, Config } from "react-awesome-query-builder";
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 { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import {
@@ -30,51 +30,65 @@ import SingleForm, {
import "../../components/react-awesome-query-builder/styles.css";
import { RoutingPages } from "../../lib/RoutingPages";
import { createFallbackRoute } from "../../lib/createFallbackRoute";
import { getQueryBuilderConfig } from "../../lib/getQueryBuilderConfig";
import {
getQueryBuilderConfigForFormFields,
getQueryBuilderConfigForAttributes,
type FormFieldsQueryBuilderConfigWithRaqbFields,
type AttributesQueryBuilderConfigWithRaqbFields,
} from "../../lib/getQueryBuilderConfig";
import isRouter from "../../lib/isRouter";
import type {
GlobalRoute,
LocalRoute,
QueryBuilderUpdatedConfig,
SerializableRoute,
} from "../../types/types";
import type { SerializableForm } from "../../types/types";
import type { GlobalRoute, LocalRoute, SerializableRoute, Attribute } from "../../types/types";
export { getServerSideProps };
type FormFieldsQueryBuilderState = {
tree: ImmutableTree;
config: FormFieldsQueryBuilderConfigWithRaqbFields;
};
type AttributesQueryBuilderState = {
tree: ImmutableTree;
config: AttributesQueryBuilderConfigWithRaqbFields;
};
type LocalRouteWithRaqbStates = LocalRoute & {
formFieldsQueryBuilderState: FormFieldsQueryBuilderState;
attributesQueryBuilderState: AttributesQueryBuilderState | null;
};
type Route = LocalRouteWithRaqbStates | GlobalRoute;
type AttributesQueryValue = NonNullable<LocalRoute["attributesQueryValue"]>;
type FormFieldsQueryValue = LocalRoute["queryValue"];
const hasRules = (route: Route) => {
if (isRouter(route)) return false;
route.queryValue.children1 && Object.keys(route.queryValue.children1).length;
};
const getEmptyRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
const uuid = QbUtils.uuid();
const formFieldsQueryValue = { id: uuid, type: "group" } as FormFieldsQueryValue;
const attributesQueryValue = { id: uuid, type: "group" } as AttributesQueryValue;
return {
id: uuid,
action: {
type: "eventTypeRedirectUrl",
value: "",
},
queryValue: { id: uuid, type: "group" },
// It is actually formFieldsQueryValue
queryValue: formFieldsQueryValue,
attributesQueryValue: attributesQueryValue,
};
};
type Route =
| (LocalRoute & {
// This is what's persisted
queryValue: JsonTree;
// `queryValue` is parsed to create state
state: {
tree: ImmutableTree;
config: QueryBuilderUpdatedConfig;
};
})
| GlobalRoute;
const Route = ({
form,
route,
routes,
setRoute,
config,
formFieldsQueryBuilderConfig,
attributesQueryBuilderConfig,
setRoutes,
moveUp,
moveDown,
@@ -86,7 +100,8 @@ const Route = ({
route: Route;
routes: Route[];
setRoute: (id: string, route: Partial<Route>) => void;
config: QueryBuilderUpdatedConfig;
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
fieldIdentifiers: string[];
moveUp?: { fn: () => void; check: () => boolean } | null;
@@ -95,7 +110,7 @@ const Route = ({
disabled?: boolean;
}) => {
const { t } = useLocale();
const isTeamForm = form.teamId !== null;
const index = routes.indexOf(route);
const { data: eventTypesByGroup, isLoading } = trpc.viewer.eventTypes.getByViewer.useQuery({
@@ -148,11 +163,27 @@ const Route = ({
}
}, [isLoading]);
const onChange = (route: Route, immutableTree: ImmutableTree, config: QueryBuilderUpdatedConfig) => {
const onChangeFormFieldsQuery = (
route: Route,
immutableTree: ImmutableTree,
config: FormFieldsQueryBuilderConfigWithRaqbFields
) => {
const jsonTree = QbUtils.getTree(immutableTree) as LocalRoute["queryValue"];
setRoute(route.id, {
formFieldsQueryBuilderState: { tree: immutableTree, config: config },
queryValue: jsonTree,
});
};
const onChangeTeamMembersQuery = (
route: Route,
immutableTree: ImmutableTree,
config: AttributesQueryBuilderConfigWithRaqbFields
) => {
const jsonTree = QbUtils.getTree(immutableTree);
setRoute(route.id, {
state: { tree: immutableTree, config: config },
queryValue: jsonTree,
attributesQueryBuilderState: { tree: immutableTree, config: config },
attributesQueryValue: jsonTree as AttributesQueryValue,
});
};
@@ -201,6 +232,56 @@ const Route = ({
);
}
const shouldShowFormFieldsQueryBuilder = (route.isFallback && hasRules(route)) || !route.isFallback;
const formFieldsQueryBuilder = shouldShowFormFieldsQueryBuilder ? (
<div>
<span className="text-emphasis flex w-full items-center text-sm">
For responses matching the following criteria(matches all by default)
</span>
<Query
{...(formFieldsQueryBuilderConfig as unknown as Config)}
value={route.formFieldsQueryBuilderState.tree}
onChange={(immutableTree, formFieldsQueryBuilderConfig) => {
onChangeFormFieldsQuery(
route,
immutableTree,
formFieldsQueryBuilderConfig as unknown as FormFieldsQueryBuilderConfigWithRaqbFields
);
}}
renderBuilder={renderBuilder}
/>
<Divider className="mb-6 mt-6" />
</div>
) : null;
const attributesQueryBuilder =
route.action?.type === "eventTypeRedirectUrl" ? (
<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>
)}
<div className="mt-2">
{route.attributesQueryBuilderState && attributesQueryBuilderConfig && (
<Query
{...(attributesQueryBuilderConfig as unknown as Config)}
value={route.attributesQueryBuilderState.tree}
onChange={(immutableTree, attributesQueryBuilderConfig) => {
onChangeTeamMembersQuery(
route,
immutableTree,
attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
);
}}
renderBuilder={renderBuilder}
/>
)}
</div>
</div>
) : null;
return (
<FormCard
className="mb-6"
@@ -216,6 +297,7 @@ const Route = ({
}}>
<div className="-mx-4 mb-4 flex w-full items-center sm:mx-0">
<div className="cal-query-builder w-full ">
{formFieldsQueryBuilder}
<div>
<div className="text-emphasis flex w-full items-center text-sm">
<div className="flex flex-grow-0 whitespace-nowrap">
@@ -343,20 +425,7 @@ const Route = ({
)
) : null}
</div>
{((route.isFallback && hasRules(route)) || !route.isFallback) && (
<>
<Divider className="mb-6 mt-3" />
<Query
{...config}
value={route.state.tree}
onChange={(immutableTree, config) => {
onChange(route, immutableTree, config as QueryBuilderUpdatedConfig);
}}
renderBuilder={renderBuilder}
/>
</>
)}
{attributesQueryBuilder}
</div>
</div>
</div>
@@ -364,16 +433,48 @@ const Route = ({
);
};
const deserializeRoute = (
route: Exclude<SerializableRoute, GlobalRoute>,
config: QueryBuilderUpdatedConfig
): Route => {
const buildState = <
T extends
| {
queryValue: FormFieldsQueryValue;
config: FormFieldsQueryBuilderConfigWithRaqbFields;
}
| {
queryValue: AttributesQueryValue;
config: AttributesQueryBuilderConfigWithRaqbFields;
}
>({
queryValue,
config,
}: T) => ({
tree: QbUtils.checkTree(QbUtils.loadTree(queryValue), config as unknown as Config),
config,
});
const deserializeRoute = ({
route,
formFieldsQueryBuilderConfig,
attributesQueryBuilderConfig,
}: {
route: Exclude<SerializableRoute, GlobalRoute>;
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
}): Route => {
const attributesQueryBuilderState =
route.attributesQueryValue && attributesQueryBuilderConfig
? buildState({
queryValue: route.attributesQueryValue,
config: attributesQueryBuilderConfig,
})
: null;
return {
...route,
state: {
tree: QbUtils.checkTree(QbUtils.loadTree(route.queryValue), config),
config: config,
},
formFieldsQueryBuilderState: buildState({
queryValue: route.queryValue,
config: formFieldsQueryBuilderConfig,
}),
attributesQueryBuilderState,
};
};
@@ -381,22 +482,31 @@ const Routes = ({
form,
hookForm,
appUrl,
attributes,
}: {
form: inferSSRProps<typeof getServerSideProps>["form"];
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
appUrl: string;
attributes: Attribute[] | null;
}) => {
const { routes: serializedRoutes } = hookForm.getValues();
const { t } = useLocale();
const config = getQueryBuilderConfig(hookForm.getValues());
const formFieldsQueryBuilderConfig = getQueryBuilderConfigForFormFields(hookForm.getValues());
const attributesQueryBuilderConfig = attributes
? getQueryBuilderConfigForAttributes({
attributes: attributes,
form: hookForm.getValues(),
})
: null;
const [routes, setRoutes] = useState(() => {
const transformRoutes = () => {
const _routes = serializedRoutes || [getEmptyRoute()];
_routes.forEach((r) => {
if (isRouter(r)) return;
if (!r.queryValue?.id) {
r.queryValue = { id: QbUtils.uuid(), type: "group" };
r.queryValue = { id: QbUtils.uuid(), type: "group" } as LocalRoute["queryValue"];
}
});
return _routes;
@@ -404,12 +514,25 @@ const Routes = ({
return transformRoutes().map((route) => {
if (isRouter(route)) return route;
return deserializeRoute(route, config);
return deserializeRoute({
route,
formFieldsQueryBuilderConfig,
attributesQueryBuilderConfig,
});
});
});
const { data: allForms } = trpc.viewer.appRoutingForms.forms.useQuery();
const notHaveAttributesQuery = ({ form }: { form: SerializableForm<App_RoutingForms_Form> }) => {
return form.routes?.every((route) => {
if (isRouter(route)) {
return true;
}
return !route.attributesQueryValue;
});
};
const availableRouters =
allForms?.filtered
.filter(({ form: router }) => {
@@ -426,6 +549,11 @@ const Routes = ({
});
return router.id !== hookForm.getValues().id && routerValidInContext;
})
// We don't want to support picking forms that have attributes query. We can consider it later.
// This is mainly because the Router picker feature is pretty much not used and we don't want to complicate things
.filter(({ form }) => {
return notHaveAttributesQuery({ form: form });
})
.map(({ form: router }) => {
return {
value: router.id,
@@ -483,7 +611,11 @@ const Routes = ({
});
if (!fallbackRoute) {
fallbackRoute = deserializeRoute(createFallbackRoute(), config);
fallbackRoute = deserializeRoute({
route: createFallbackRoute(),
formFieldsQueryBuilderConfig,
attributesQueryBuilderConfig,
});
setRoutes((routes) => {
// Even though it's obvious that fallbackRoute is defined here but TypeScript just can't figure it out.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -525,6 +657,7 @@ const Routes = ({
action: route.action,
isFallback: route.isFallback,
queryValue: route.queryValue,
attributesQueryValue: route.attributesQueryValue,
};
});
@@ -543,7 +676,8 @@ const Routes = ({
form={form}
appUrl={appUrl}
key={route.id}
config={config}
formFieldsQueryBuilderConfig={formFieldsQueryBuilderConfig}
attributesQueryBuilderConfig={attributesQueryBuilderConfig}
route={route}
fieldIdentifiers={fieldIdentifiers}
moveUp={{
@@ -583,10 +717,17 @@ const Routes = ({
...routes,
{
...newEmptyRoute,
state: {
tree: QbUtils.checkTree(QbUtils.loadTree(newEmptyRoute.queryValue), config),
config,
},
formFieldsQueryBuilderState: buildState({
queryValue: newEmptyRoute.queryValue,
config: formFieldsQueryBuilderConfig,
}),
attributesQueryBuilderState:
attributesQueryBuilderConfig && newEmptyRoute.attributesQueryValue
? buildState({
queryValue: newEmptyRoute.attributesQueryValue,
config: attributesQueryBuilderConfig,
})
: null,
},
];
@@ -612,7 +753,8 @@ const Routes = ({
<div>
<Route
form={form}
config={config}
formFieldsQueryBuilderConfig={formFieldsQueryBuilderConfig}
attributesQueryBuilderConfig={attributesQueryBuilderConfig}
route={fallbackRoute}
routes={routes}
setRoute={setRoute}
@@ -637,14 +779,32 @@ export default function RouteBuilder({
appUrl={appUrl}
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
Page={({ hookForm, form }) => {
const { t } = useLocale();
const values = hookForm.getValues();
const { data: attributes, isPending: isAttributesLoading } =
trpc.viewer.appRoutingForms.getAttributesForTeam.useQuery(
{ teamId: values.teamId! },
{ enabled: !!values.teamId }
);
// If hookForm hasn't been initialized, don't render anything
// This is important here because some states get initialized which aren't reset when the hookForm is reset with the form values and they don't get the updated values
if (!hookForm.getValues().id) {
return null;
}
// Only team form needs attributes
if (values.teamId) {
if (isAttributesLoading) {
return <div>Loading...</div>;
}
if (!attributes) {
return <div>{t("something_went_wrong")}</div>;
}
}
return (
<div className="route-config">
<Routes hookForm={hookForm} appUrl={appUrl} form={form} />
<Routes hookForm={hookForm} appUrl={appUrl} form={form} attributes={attributes || null} />
</div>
);
}}
@@ -659,3 +819,5 @@ RouteBuilder.getLayout = (page: React.ReactElement) => {
</Shell>
);
};
export { getServerSideProps };
@@ -10,11 +10,12 @@ import { enrichFormWithMigrationData } from "../../enrichFormWithMigrationData";
import { getAbsoluteEventTypeRedirectUrl } from "../../getEventTypeRedirectUrl";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { processRoute } from "../../lib/processRoute";
import { findMatchingRoute } from "../../lib/processRoute";
import { substituteVariables } from "../../lib/substituteVariables";
import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
import type { FormResponse } from "../../types/types";
import { isAuthorizedToViewTheForm } from "../routing-link/getServerSideProps";
import { getUrlSearchParamsToForward } from "../routing-link/getUrlSearchParamsToForward";
const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] });
@@ -109,24 +110,31 @@ export const getServerSideProps = async function getServerSideProps(
};
});
const decidedAction = processRoute({ form: serializableForm, response });
const matchingRoute = findMatchingRoute({ form: serializableForm, response });
if (!decidedAction) {
if (!matchingRoute) {
throw new Error("No matching route could be found");
}
const decidedAction = matchingRoute.action;
const { createContext } = await import("@calcom/trpc/server/createContext");
const ctx = await createContext(context);
const { default: trpcRouter } = await import("@calcom/app-store/routing-forms/trpc/_router");
const caller = trpcRouter.createCaller(ctx);
const { v4: uuidv4 } = await import("uuid");
let teamMembersMatchingAttributeLogic = null;
let formResponseId = null;
try {
await caller.public.response({
const result = await caller.public.response({
formId: form.id,
formFillerId: uuidv4(),
response: response,
chosenRouteId: matchingRoute.id,
});
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
formResponseId = result.formResponse.id;
} catch (e) {
if (e instanceof TRPCError) {
return {
@@ -152,12 +160,20 @@ export const getServerSideProps = async function getServerSideProps(
response,
serializableForm.fields
);
return {
redirect: {
destination: getAbsoluteEventTypeRedirectUrl({
eventTypeRedirectUrl: eventTypeUrlWithResolvedVariables,
form: serializableForm,
allURLSearchParams: new URLSearchParams(stringify(context.query)),
allURLSearchParams: getUrlSearchParamsToForward({
formResponse: response,
fields: serializableForm.fields,
searchParams: new URLSearchParams(stringify(fieldsResponses)),
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!,
}),
}),
permanent: false,
},
@@ -21,10 +21,10 @@ import { Button, showToast, useCalcomTheme } from "@calcom/ui";
import FormInputFields from "../../components/FormInputFields";
import { getAbsoluteEventTypeRedirectUrl } from "../../getEventTypeRedirectUrl";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { processRoute } from "../../lib/processRoute";
import { findMatchingRoute } from "../../lib/processRoute";
import { substituteVariables } from "../../lib/substituteVariables";
import { getFieldResponseForJsonLogic } from "../../lib/transformResponse";
import type { NonRouterRoute, FormResponse } from "../../types/types";
import type { NonRouterRoute, FormResponse, Route } from "../../types/types";
import { getServerSideProps } from "./getServerSideProps";
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
@@ -60,28 +60,29 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
// - like a network error
// - or he abandoned booking flow in between
const formFillerId = formFillerIdRef.current;
const decidedActionWithFormResponseRef = useRef<{
action: NonRouterRoute["action"];
const chosenRouteWithFormResponseRef = useRef<{
route: NonRouterRoute;
response: FormResponse;
}>();
const router = useRouter();
const onSubmit = (response: FormResponse) => {
const decidedAction = processRoute({ form, response });
const chosenRoute = findMatchingRoute({ form, response });
if (!decidedAction) {
// FIXME: Make sure that when a form is created, there is always a fallback route and then remove this.
alert("Define atleast 1 route");
return;
if (!chosenRoute) {
// This error should never happen as we ensure that fallback route is always there that matches always
throw new Error("No matching route found");
}
responseMutation.mutate({
formId: form.id,
formFillerId,
response: response,
chosenRouteId: chosenRoute.id,
});
decidedActionWithFormResponseRef.current = {
action: decidedAction,
chosenRouteWithFormResponseRef.current = {
route: chosenRoute,
response,
};
};
@@ -92,9 +93,10 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
}, [customPageMessage]);
const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({
onSuccess: async () => {
const decidedActionWithFormResponse = decidedActionWithFormResponseRef.current;
if (!decidedActionWithFormResponse) {
onSuccess: async (data) => {
const {teamMembersMatchingAttributeLogic, formResponse} = data;
const chosenRouteWithFormResponse = chosenRouteWithFormResponseRef.current;
if (!chosenRouteWithFormResponse) {
return;
}
const fields = form.fields;
@@ -102,11 +104,14 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
throw new Error("Routing Form fields must exist here");
}
const allURLSearchParams = getUrlSearchParamsToForward({
formResponse: decidedActionWithFormResponse.response,
formResponse: chosenRouteWithFormResponse.response,
formResponseId: formResponse.id,
fields,
searchParams: new URLSearchParams(window.location.search),
teamMembersMatchingAttributeLogic,
});
const decidedAction = decidedActionWithFormResponse.action;
const chosenRoute = chosenRouteWithFormResponse.route;
const decidedAction = chosenRoute.action;
sdkActionManager?.fire("routed", {
actionType: decidedAction.type,
actionValue: decidedAction.value,
@@ -57,12 +57,15 @@ describe("getUrlSearchParamsToForward", () => {
f2: ["Option 1", "Option 2"],
query1: "value1",
query2: "value2",
"cal.routingFormResponseId": "1",
};
const result = getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
teamMembersMatchingAttributeLogic: null,
formResponseId: 1,
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
@@ -87,12 +90,15 @@ describe("getUrlSearchParamsToForward", () => {
"Field 2": "value2",
query1: "value1",
query2: "value2",
"cal.routingFormResponseId": "1",
};
const result = getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
teamMembersMatchingAttributeLogic: null,
formResponseId: 1,
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
@@ -134,12 +140,15 @@ describe("getUrlSearchParamsToForward", () => {
"Field 2": ["Option 1", "Option 2"],
query1: "value1",
query2: "value2",
"cal.routingFormResponseId": "1",
};
const result = getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
teamMembersMatchingAttributeLogic: null,
formResponseId: 1,
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
@@ -181,12 +190,15 @@ describe("getUrlSearchParamsToForward", () => {
"Field 2": ["Option 1", "Option 2"],
query1: "value1",
query2: "value2",
"cal.routingFormResponseId": "1",
};
const result = getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
teamMembersMatchingAttributeLogic: null,
formResponseId: 1,
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
@@ -204,12 +216,15 @@ describe("getUrlSearchParamsToForward", () => {
"Field 1": "123",
query1: "value1",
query2: "value2",
"cal.routingFormResponseId": "1",
};
const result = getUrlSearchParamsToForward({
formResponse: response,
fields,
searchParams,
teamMembersMatchingAttributeLogic: null,
formResponseId: 1
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
@@ -8,6 +8,8 @@ export function getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
teamMembersMatchingAttributeLogic,
formResponseId,
}: {
formResponse: Record<
string,
@@ -20,6 +22,8 @@ export function getUrlSearchParamsToForward({
"id" | "type" | "options" | "identifier" | "label"
>[];
searchParams: URLSearchParams;
formResponseId: number;
teamMembersMatchingAttributeLogic: number[] | null;
}) {
type Params = Record<string, string | string[]>;
const paramsFromResponse: Params = {};
@@ -71,6 +75,10 @@ export function getUrlSearchParamsToForward({
...paramsFromCurrentUrl,
// 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(",") }
: null),
['cal.routingFormResponseId']: String(formResponseId),
};
const allQueryURLSearchParams = new URLSearchParams();
@@ -0,0 +1,335 @@
import { BaseWidget, JsonTree } from "react-awesome-query-builder";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AttributeType } from "@calcom/prisma/enums";
import { RoutingFormFieldType } from "../../lib/FieldTypes";
import { RaqbLogicResult } from "../../lib/evaluateRaqbLogic";
// import { EmailField } from "@calcom/ui";
import * as getAttributesModule from "../../lib/getAttributes";
import type { AttributesQueryValue, FormFieldsQueryValue } from "../../types/types";
import { findTeamMembersMatchingAttributeLogicOfRoute } from "../utils";
vi.mock("../../lib/getAttributes");
vi.mock("../../components/react-awesome-query-builder/widgets", () => ({
default: {},
}));
vi.mock("@calcom/ui");
function mockAttributesScenario({
attributes,
teamMembersWithAttributeOptionValuePerAttribute,
}: {
attributes: Awaited<ReturnType<typeof getAttributesModule.getAttributesForTeam>>;
teamMembersWithAttributeOptionValuePerAttribute: Awaited<
ReturnType<typeof getAttributesModule.getTeamMembersWithAttributeOptionValuePerAttribute>
>;
}) {
vi.mocked(getAttributesModule.getAttributesForTeam).mockResolvedValue(attributes);
vi.mocked(getAttributesModule.getTeamMembersWithAttributeOptionValuePerAttribute).mockResolvedValue(
teamMembersWithAttributeOptionValuePerAttribute
);
}
function buildQueryValue({
rules,
}: {
rules: {
raqbFieldId: string;
value: string | number | string[];
operator: string;
valueSrc: NonNullable<BaseWidget["valueSrc"]>[];
valueType: string[];
}[];
}) {
const queryValue = {
id: "query-id-1",
type: "group",
children1: rules.reduce((acc, rule, index) => {
acc[`rule-${index + 1}`] = {
type: "rule",
properties: {
field: rule.raqbFieldId,
value: rule.value,
operator: rule.operator,
valueSrc: rule.valueSrc,
valueType: rule.valueType,
},
};
return acc;
}, {} as any),
};
return queryValue;
}
function buildSelectTypeFieldQueryValue({
rules,
}: {
rules: {
raqbFieldId: string;
value: string | number | string[];
operator: string;
}[];
}) {
return buildQueryValue({
rules: rules.map((rule) => ({
raqbFieldId: rule.raqbFieldId,
value: rule.value,
operator: rule.operator,
valueSrc: ["value"],
valueType: ["select"],
})),
}) as AttributesQueryValue;
}
function buildRoute({
id,
action,
queryValue,
attributesQueryValue,
}: {
id: string;
action: {
type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl";
value: string;
};
queryValue: FormFieldsQueryValue;
attributesQueryValue?: AttributesQueryValue;
}) {
return {
id,
action,
queryValue,
attributesQueryValue,
};
}
function buildDefaultCustomPageRoute({
id,
attributesQueryValue,
}: {
id: string;
attributesQueryValue?: AttributesQueryValue;
}) {
return buildRoute({
id,
action: { type: "customPageMessage", value: "test" },
queryValue: { type: "group" } as unknown as FormFieldsQueryValue,
attributesQueryValue,
});
}
describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("should return null if route is not found", async () => {
const result = await findTeamMembersMatchingAttributeLogicOfRoute({
form: { routes: [], fields: [] },
response: {},
routeId: "non-existent-route",
teamId: 1,
});
expect(result).toBeNull();
});
it("should return null if the route does not have an attributesQueryValue set", async () => {
const result = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
{
id: "test-route",
queryValue: { type: "group" } as unknown as FormFieldsQueryValue,
action: { type: "customPageMessage", value: "test" },
},
],
fields: [],
},
response: {},
routeId: "test-route",
teamId: 1,
});
expect(result).toBeNull();
});
it("should return matching team members with a SINGLE_SELECT attribute when a static option is selected", async () => {
const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" };
const Attribute1 = {
id: "attr1",
name: "Attribute 1",
type: "SINGLE_SELECT" as const,
slug: "attribute-1",
options: [Option1OfAttribute1],
};
mockAttributesScenario({
attributes: [Attribute1],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [Option1OfAttribute1.id],
operator: "select_equals",
},
],
}) as AttributesQueryValue;
const result = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
{
id: "test-route",
action: { type: "customPageMessage", value: "test" },
queryValue: {
type: "group",
} as unknown as FormFieldsQueryValue,
attributesQueryValue: attributesQueryValue,
},
],
fields: [],
},
response: {},
routeId: "test-route",
teamId: 1,
});
expect(result).toEqual([
{
userId: 1,
result: RaqbLogicResult.MATCH,
},
]);
});
it("should return matching team members with a SINGLE_SELECT attribute when 'Value of Field' option is selected", async () => {
const Option1OfAttribute1HumanReadableValue = "Option 1";
const Option1OfField1HumanReadableValue = Option1OfAttribute1HumanReadableValue;
const Field1Id = "field-1";
const Option1OfAttribute1 = {
id: "attr-1-opt-1",
value: Option1OfAttribute1HumanReadableValue,
slug: "option-1",
};
const Option1OfField1 = {
id: "field-1-opt-1",
label: Option1OfField1HumanReadableValue,
};
const Attribute1 = {
id: "attr1",
name: "Attribute 1",
type: "SINGLE_SELECT" as const,
slug: "attribute-1",
options: [Option1OfAttribute1],
};
mockAttributesScenario({
attributes: [Attribute1],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [`{field:${Field1Id}}`],
operator: "select_equals",
},
],
}) as AttributesQueryValue;
const result = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
],
fields: [
{
id: Field1Id,
type: RoutingFormFieldType.SINGLE_SELECT,
label: "Field 1",
options: [Option1OfField1],
},
],
},
response: {
[Field1Id]: {
value: Option1OfAttribute1HumanReadableValue,
label: Option1OfAttribute1HumanReadableValue,
},
},
routeId: "test-route",
teamId: 1,
});
expect(result).toEqual([
{
userId: 1,
result: RaqbLogicResult.MATCH,
},
]);
});
describe("Error handling", () => {
it("should throw an error if the attribute type is not supported", async () => {
const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" };
const Attribute1 = {
id: "attr1",
name: "Attribute 1",
type: "UNSUPPORTED_ATTRIBUTE_TYPE" as unknown as AttributeType,
slug: "attribute-1",
options: [Option1OfAttribute1],
};
mockAttributesScenario({
attributes: [Attribute1],
teamMembersWithAttributeOptionValuePerAttribute: [
{
userId: 1,
attributes: { [Attribute1.id]: Option1OfAttribute1.value },
},
],
});
await expect(
findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [Option1OfAttribute1.id],
operator: "select_equals",
},
],
}) as AttributesQueryValue,
}),
],
fields: [],
},
response: {},
routeId: "test-route",
teamId: 1,
})
).rejects.toThrow("Unsupported attribute type");
});
});
});
@@ -8,6 +8,8 @@ import { ZFormQueryInputSchema } from "./formQuery.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> = {};
@@ -68,6 +70,26 @@ const appRoutingForms = router({
const handler = await getHandler("report", () => import("./report.handler"));
return handler({ ctx, input });
}),
getAttributesForTeam: authedProcedure
.input(ZGetAttributesForTeamInputSchema)
.query(async ({ ctx, input }) => {
const handler = await getHandler(
"getAttributesForTeam",
() => import("./getAttributesForTeam.handler")
);
return handler({ ctx, input });
}),
findTeamMembersMatchingAttributeLogic: authedProcedure
.input(ZFindTeamMembersMatchingAttributeLogicInputSchema)
.mutation(async ({ ctx, input }) => {
const handler = await getHandler(
"findTeamMembersMatchingAttributeLogic",
() => import("./findTeamMembersMatchingAttributeLogic.handler")
);
return handler({ ctx, input });
}),
});
export default appRoutingForms;
@@ -0,0 +1,68 @@
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema";
import { findTeamMembersMatchingAttributeLogicOfRoute } from "./utils";
interface FindTeamMembersMatchingAttributeLogicHandlerOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TFindTeamMembersMatchingAttributeLogicInputSchema;
}
export const findTeamMembersMatchingAttributeLogicHandler = async ({
ctx,
input,
}: FindTeamMembersMatchingAttributeLogicHandlerOptions) => {
const { prisma, user } = ctx;
const { formId, response, routeId } = input;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
...entityPrismaWhereClause({ userId: user.id }),
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
if (!form.teamId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This form is not associated with a team",
});
}
const serializableForm = await getSerializableForm({ form });
const matchingTeamMembersWithResult = await findTeamMembersMatchingAttributeLogicOfRoute({
response,
routeId,
form: serializableForm,
teamId: form.teamId,
});
if (!matchingTeamMembersWithResult) {
return null;
}
const matchingTeamMembersIds = matchingTeamMembersWithResult.map((member) => member.userId);
const matchingTeamMembers = await UserRepository.findByIds({ ids: matchingTeamMembersIds });
return matchingTeamMembers.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
}));
};
export default findTeamMembersMatchingAttributeLogicHandler;
@@ -0,0 +1,11 @@
import { z } from "zod";
export const ZFindTeamMembersMatchingAttributeLogicInputSchema = z.object({
formId: z.string(),
response: z.record(z.string(), z.any()),
routeId: z.string()
});
export type TFindTeamMembersMatchingAttributeLogicInputSchema = z.infer<
typeof ZFindTeamMembersMatchingAttributeLogicInputSchema
>;
@@ -0,0 +1,31 @@
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
import { getAttributesForTeam } from "../lib/getAttributes";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
type GetAttributesForTeamHandlerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetAttributesForTeamInputSchema;
};
export default async function getAttributesForTeamHandler({
ctx,
input,
}: GetAttributesForTeamHandlerOptions) {
const { teamId } = input;
const { user } = ctx;
const isMemberOfTeam = await MembershipRepository.findFirstByUserIdAndTeamId({ userId: user.id, teamId });
if (!isMemberOfTeam) {
throw new TRPCError({
code: "NOT_FOUND",
message: "You are not a member of this team",
});
}
return getAttributesForTeam({ teamId });
}
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetAttributesForTeamInputSchema = z.object({
teamId: z.number(),
});
export type TGetAttributesForTeamInputSchema = z.infer<typeof ZGetAttributesForTeamInputSchema>;
@@ -0,0 +1,308 @@
import type { App_RoutingForms_Form, User } from "@prisma/client";
import { 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 { LocalRoute } from "../types/types";
import type { FormResponse, SerializableForm } from "../types/types";
import type { SerializableField } from "../types/types";
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/raqbUtils"] });
type GetFieldResponse = ({
field,
fieldResponseValue,
}: {
fieldResponseValue: FormResponse[keyof FormResponse]["value"];
field: Pick<SerializableField, "type" | "options">;
}) => { value: string | number | string[] };
function ensureArray(value: string | string[]) {
return typeof value === "string" ? [value] : value;
}
// We connect Form Field value and Attribute value using the labels lowercased
function compatibleForAttributeAndFormFieldMatch<T extends string | string[]>(
stringOrStringArray: T
): T extends string[] ? string[] : string {
return (
stringOrStringArray instanceof Array
? stringOrStringArray.map((string) => string.toLowerCase())
: stringOrStringArray.toLowerCase()
) as T extends string[] ? string[] : string;
}
const raqbQueryValueUtils = {
isQueryValueARuleGroup: function isQueryValueARuleGroup(queryValue: JsonTree): queryValue is JsonGroup {
return queryValue.type === "group";
},
isARule: function isARule(rule: JsonItem): rule is JsonRule {
return rule.type === "rule";
},
getValueTypeFromAttributesQueryValueForRaqbField:
function getValueTypeFromAttributesQueryValueForRaqbField({
attributesQueryValue,
raqbFieldId,
}: {
attributesQueryValue: JsonTree;
raqbFieldId: string;
}) {
if (!raqbQueryValueUtils.isQueryValueARuleGroup(attributesQueryValue)) {
return null;
}
if (!attributesQueryValue.children1) {
return null;
}
let raqbFieldValueType = null;
Object.values(attributesQueryValue.children1).reduce((acc, rule) => {
if (!raqbQueryValueUtils.isARule(rule)) {
return acc;
}
const ruleProperties = rule?.properties;
if (!ruleProperties) {
return acc;
}
if (ruleProperties.field === raqbFieldId) {
raqbFieldValueType = ruleProperties.valueType?.[0];
}
return acc;
}, null);
return raqbFieldValueType;
},
};
/**
* Replace attribute option Ids with the attribute option label(compatible to be matched with form field value)
*/
const replaceAttributeOptionIdsWithOptionLabel = ({
queryValueString,
attributes,
}: {
queryValueString: string;
attributes: Attribute[];
}) => {
const allAttributesOptions = attributes.map((attribute) => attribute.options).flat();
// Because all attribute option Ids are unique, we can reliably identify them along any number of attribute options of different attributes
allAttributesOptions.forEach((attributeOption) => {
const attributeOptionId = attributeOption.id;
queryValueString = queryValueString.replace(
new RegExp(`${attributeOptionId}`, "g"),
compatibleForAttributeAndFormFieldMatch(attributeOption.value)
);
});
return queryValueString;
};
/**
* Replace {field:<fieldId>} with the field label(compatible to be matched with attribute value)
*/
const replaceFieldTemplateVariableWithOptionLabel = ({
queryValueString,
fields,
response,
getFieldResponse,
}: {
queryValueString: string;
fields: SerializableField[] | undefined;
response: FormResponse;
getFieldResponse: GetFieldResponse;
}) => {
return queryValueString.replace(/{field:([\w-]+)}/g, (match, fieldId: string) => {
const field = fields?.find((f) => f.id === fieldId);
if (!field) {
moduleLogger.debug("field not found", safeStringify({ fieldId }));
return match;
}
const fieldResponseValue = response[fieldId]?.value;
if (!fieldResponseValue) {
return match;
}
const { value: fieldValue } = getFieldResponse({ field, fieldResponseValue });
moduleLogger.debug("matchingOptionLabel", safeStringify({ fieldValue, response, fieldId }));
if (fieldValue instanceof Array && fieldValue.length > 1) {
throw new Error("Array value not supported with 'Value of field'");
}
return fieldValue ? compatibleForAttributeAndFormFieldMatch(fieldValue.toString()) : match;
});
};
/**
* Utilities to handle compatiblity when attribute's type changes
*/
const attributeChangeCompatibility = {
getRaqbFieldTypeCompatibleWithQueryValue: function getRaqbFieldTypeCompatibleWithQueryValue({
attributesQueryValue,
raqbField,
raqbFieldId,
}: {
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
raqbField: AttributesQueryBuilderConfigWithRaqbFields["fields"][string];
raqbFieldId: string;
}) {
const fieldTypeFromAttributesQueryValue =
raqbQueryValueUtils.getValueTypeFromAttributesQueryValueForRaqbField({
attributesQueryValue,
raqbFieldId,
});
if (raqbField.type === "select" && fieldTypeFromAttributesQueryValue === "multiselect") {
// One could argue why not use fieldTypeFromAttributesQueryValue directly. This could potentially cause issues, so we do this only when we think it makes sense.
moduleLogger.warn(
`Switching field type from ${raqbField.type} to multiselect as the queryValue expects it to be so`
);
return "multiselect";
}
return raqbField.type;
},
/**
* Ensure the attribute value if of type same as the valueType in queryValue
*/
ensureAttributeValueToBeOfRaqbFieldValueType: function ensureAttributeValueToBeOfRaqbFieldValueType({
attributeValue,
attributesQueryValue,
attributeId,
}: {
attributeValue: string | string[];
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
attributeId: string;
}) {
const fieldTypeFromAttributesQueryValue = raqbQueryValueUtils.getValueTypeFromAttributesQueryValueForRaqbField({
attributesQueryValue,
raqbFieldId: attributeId,
});
if (fieldTypeFromAttributesQueryValue === "multiselect") {
return ensureArray(attributeValue);
}
return attributeValue;
},
};
function getAttributesData({
attributesData,
attributesQueryValue,
}: {
attributesData: Record<string, string | string[]>;
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
}) {
return Object.entries(attributesData).reduce((acc, [attributeId, value]) => {
let 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({
attributeValue: compatibleValueForAttributeAndFormFieldMatching,
attributesQueryValue,
attributeId,
});
return acc;
}, {} as Record<string, string | string[]>);
}
function getAttributesQueryValue({
attributesQueryValue,
attributes,
response,
fields,
getFieldResponse,
}: {
attributesQueryValue: LocalRoute["attributesQueryValue"];
attributes: Attribute[];
response: FormResponse;
fields: SerializableField[] | undefined;
getFieldResponse: GetFieldResponse;
}) {
if (!attributesQueryValue) {
return null;
}
type AttributesQueryValue = NonNullable<LocalRoute["attributesQueryValue"]>;
const attributesMap = attributes.reduce((acc, attribute) => {
acc[attribute.id] = attribute;
return acc;
}, {} as Record<string, Attribute>);
console.log({ attributesMap });
const attributesQueryValueCompatibleForMatchingWithFormField: AttributesQueryValue = JSON.parse(
replaceFieldTemplateVariableWithOptionLabel({
queryValueString: replaceAttributeOptionIdsWithOptionLabel({
queryValueString: JSON.stringify(attributesQueryValue),
attributes,
}),
fields,
response,
getFieldResponse,
})
);
return attributesQueryValueCompatibleForMatchingWithFormField;
}
function getAttributesQueryBuilderConfig({
form,
attributes,
attributesQueryValue,
}: {
form: Pick<SerializableForm<App_RoutingForms_Form>, "fields">;
attributes: Attribute[];
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
}) {
const attributesQueryBuilderConfig = getQueryBuilderConfigForAttributes({
attributes,
form,
});
const attributesQueryBuilderConfigFieldsWithCompatibleListValues = Object.fromEntries(
Object.entries(attributesQueryBuilderConfig.fields).map(([raqbFieldId, raqbField]) => {
let raqbFieldType = attributeChangeCompatibility.getRaqbFieldTypeCompatibleWithQueryValue({
attributesQueryValue,
raqbField,
raqbFieldId,
});
return [
raqbFieldId,
{
...raqbField,
type: raqbFieldType,
fieldSettings: {
...raqbField.fieldSettings,
listValues: raqbField.fieldSettings.listValues?.map((option) => {
return {
...option,
// Use the title(which is the attributeOption.value) as the value of the raqb field so that it can be compatible for matching with the form field value
value: compatibleForAttributeAndFormFieldMatch(option.title),
};
}),
},
},
];
})
);
const attributesQueryBuilderConfigWithCompatibleListValues = {
...attributesQueryBuilderConfig,
fields: attributesQueryBuilderConfigFieldsWithCompatibleListValues,
};
return attributesQueryBuilderConfigWithCompatibleListValues;
}
/**
* Utilities to establish compatibility between formFieldQueryValue and attributeQueryValue
*/
export const acrossQueryValueCompatiblity = {
getAttributesQueryBuilderConfig,
getAttributesQueryValue,
getAttributesData,
};
@@ -1,6 +1,8 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { PrismaClient } from "@calcom/prisma";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@calcom/trpc/server";
@@ -8,7 +10,9 @@ import { TRPCError } from "@calcom/trpc/server";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { FormResponse } from "../types/types";
import type { TResponseInputSchema } from "./response.schema";
import { onFormSubmission } from "./utils";
import { onFormSubmission, findTeamMembersMatchingAttributeLogicOfRoute } from "./utils";
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/response.handler"] });
interface ResponseHandlerOptions {
ctx: {
@@ -19,7 +23,7 @@ interface ResponseHandlerOptions {
export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => {
const { prisma } = ctx;
try {
const { response, formId } = input;
const { response, formId, chosenRouteId } = input;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
@@ -91,7 +95,10 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
}
const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
data: input,
data: {
formId,
response: response,
},
});
const settings = RoutingFormSettings.parse(form.settings);
@@ -115,11 +122,30 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
userWithEmails = userEmails.map((userEmail) => userEmail.user.email);
}
const teamMembersMatchingAttributeLogicWithResult =
form.teamId && chosenRouteId
? await findTeamMembersMatchingAttributeLogicOfRoute({
response,
routeId: chosenRouteId,
form: serializableForm,
teamId: form.teamId,
})
: null;
moduleLogger.debug(
"teamMembersMatchingAttributeLogic",
safeStringify({ teamMembersMatchingAttributeLogicWithResult })
);
const teamMemberIdsMatchingAttributeLogic = teamMembersMatchingAttributeLogicWithResult
? teamMembersMatchingAttributeLogicWithResult?.map((member) => member.userId)
: null;
await onFormSubmission(
{ ...serializableFormWithFields, userWithEmails },
dbFormResponse.response as FormResponse
);
return dbFormResponse;
return { formResponse: dbFormResponse, teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic };
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
@@ -9,6 +9,8 @@ export const ZResponseInputSchema = z.object({
value: z.union([z.string(), z.number(), z.array(z.string())]),
})
),
// TODO: There could be existing forms loaded that will not send chosenRouteId. Make it required later.
chosenRouteId: z.string().optional(),
});
export type TResponseInputSchema = z.infer<typeof ZResponseInputSchema>;
+102 -7
View File
@@ -4,15 +4,26 @@ import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { WebhookTriggerEvents } from "@calcom/prisma/client";
import type { Ensure } from "@calcom/types/utils";
import type { OrderedResponses } from "../types/types";
import { evaluateRaqbLogic, RaqbLogicResult } from "../lib/evaluateRaqbLogic";
import {
getTeamMembersWithAttributeOptionValuePerAttribute,
getAttributesForTeam,
} from "../lib/getAttributes";
import isRouter from "../lib/isRouter";
import type { SerializableField, OrderedResponses } from "../types/types";
import type { FormResponse, SerializableForm } from "../types/types";
import { acrossQueryValueCompatiblity } from "./raqbUtils";
type Field = NonNullable<SerializableForm<App_RoutingForms_Form>["fields"]>[number];
const { getAttributesData: getAttributes, getAttributesQueryBuilderConfig, getAttributesQueryValue } =
acrossQueryValueCompatiblity;
function isOptionsField(field: Pick<Field, "type" | "options">) {
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/utils"] });
function isOptionsField(field: Pick<SerializableField, "type" | "options">) {
return (field.type === "select" || field.type === "multiselect") && field.options;
}
@@ -21,7 +32,7 @@ function getFieldResponse({
fieldResponseValue,
}: {
fieldResponseValue: FormResponse[keyof FormResponse]["value"];
field: Pick<Field, "type" | "options">;
field: Pick<SerializableField, "type" | "options">;
}) {
if (!isOptionsField(field)) {
return {
@@ -38,6 +49,7 @@ function getFieldResponse({
}
const valueArray = fieldResponseValue instanceof Array ? fieldResponseValue : [fieldResponseValue];
const chosenOptions = valueArray.map((idOrLabel) => {
const foundOptionById = field.options?.find((option) => {
return option.id === idOrLabel;
@@ -77,6 +89,89 @@ type FORM_SUBMITTED_WEBHOOK_RESPONSES = Record<
}
>;
export async function findTeamMembersMatchingAttributeLogicOfRoute({
form,
response,
routeId,
teamId,
}: {
form: Pick<SerializableForm<App_RoutingForms_Form>, "routes" | "fields">;
response: FormResponse;
routeId: string;
teamId: number;
}) {
const route = form.routes?.find((route) => route.id === routeId);
if (!route) {
return null;
}
let teamMembersMatchingAttributeLogic: {
userId: number;
result: RaqbLogicResult;
}[] = [];
if (!isRouter(route)) {
const attributesForTeam = await getAttributesForTeam({ teamId: teamId });
const attributesQueryValue = getAttributesQueryValue({
attributesQueryValue: route.attributesQueryValue,
attributes: attributesForTeam,
response,
fields: form.fields,
getFieldResponse,
});
if (!attributesQueryValue) {
return null;
}
const attributesQueryBuilderConfig = getAttributesQueryBuilderConfig({
form,
attributes: attributesForTeam,
attributesQueryValue,
});
moduleLogger.debug(
"Finding team members matching attribute logic",
safeStringify({
form,
response,
routeId,
teamId,
attributesQueryBuilderConfigFields: attributesQueryBuilderConfig.fields,
})
);
const teamMembersWithAttributeOptionValuePerAttribute =
await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: teamId });
teamMembersWithAttributeOptionValuePerAttribute.forEach((member, index) => {
const attributesData = getAttributes({
attributesData: member.attributes,
attributesQueryValue,
});
moduleLogger.debug(
`Checking team member ${member.userId} with attributes logic`,
safeStringify({ attributes: attributesData, attributesQueryValue })
);
const result = evaluateRaqbLogic({
queryValue: attributesQueryValue,
queryBuilderConfig: attributesQueryBuilderConfig,
data: attributesData,
beStrictWithEmptyLogic: true,
});
if (result === RaqbLogicResult.MATCH || result === RaqbLogicResult.LOGIC_NOT_FOUND_SO_MATCHED) {
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`
);
}
});
}
return teamMembersMatchingAttributeLogic;
}
export async function onFormSubmission(
form: Ensure<
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
@@ -145,12 +240,12 @@ export async function onFormSubmission(
}, [] as OrderedResponses);
if (form.settings?.emailOwnerOnSubmission) {
logger.debug(
moduleLogger.debug(
`Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}`
);
await sendResponseEmail(form, orderedResponses, [form.user.email]);
} else if (form.userWithEmails?.length) {
logger.debug(
moduleLogger.debug(
`Preparing to send Form Response email for Form:${form.id} to users: ${form.userWithEmails.join(",")}`
);
await sendResponseEmail(form, orderedResponses, form.userWithEmails);
@@ -169,7 +264,7 @@ export const sendResponseEmail = async (
await email.sendEmail();
}
} catch (e) {
logger.error("Error sending response email", e);
moduleLogger.error("Error sending response email", e);
}
};
+18 -1
View File
@@ -1,5 +1,6 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import type z from "zod";
import { AttributeType } from "@calcom/prisma/client";
import type { RoutingFormSettings } from "@calcom/prisma/zod-utils";
@@ -22,7 +23,7 @@ export type FormResponse = Record<
export type Fields = z.infer<typeof zodFieldsView>;
export type Field = NonNullable<Fields>[number];
export type Routes = z.infer<typeof zodRoutesView>;
export type Route = Routes[0];
export type Route = NonNullable<Routes>[0];
export type NonRouterRoute = z.infer<typeof zodNonRouterRoute>;
export type SerializableFormTeamMembers = {
@@ -56,3 +57,19 @@ export type SerializableRoute =
| GlobalRoute;
export type OrderedResponses = FormResponse[string][];
export type Attribute = {
name: string;
slug: string;
type: AttributeType;
id: string;
options: {
id: string;
value: string;
slug: string;
}[];
};
export type AttributesQueryValue = NonNullable<LocalRoute["attributesQueryValue"]>;
export type FormFieldsQueryValue = LocalRoute["queryValue"];
export type SerializableField = NonNullable<SerializableForm<App_RoutingForms_Form>["fields"]>[number];
+15 -6
View File
@@ -48,15 +48,24 @@ export const zodRouterFieldView = zodRouterField.extend({
export const zodFieldView = z.union([zodNonRouterFieldView, zodRouterFieldView]);
export const zodFieldsView = z.array(zodFieldView).optional();
const queryValueSchema = z.object({
id: z.string().optional(),
type: z.union([z.literal("group"), z.literal("switch_group")]),
children1: z.any(),
properties: z.any(),
});
export const zodNonRouterRoute = z.object({
id: z.string(),
queryValue: z.object({
id: z.string().optional(),
type: z.union([z.literal("group"), z.literal("switch_group")]),
children1: z.any(),
properties: z.any(),
}),
// TODO: It should be renamed to formFieldsQueryValue but it would take some effort
/**
* RAQB query value for form fields
*/
queryValue: queryValueSchema.brand<"formFieldsQueryValue">(),
/**
* RAQB query value for attributes. It is only applicable for Team Events as it is used to find matching team members
*/
attributesQueryValue: queryValueSchema.brand<"attributesQueryValue">().optional(),
isFallback: z.boolean().optional(),
action: z.object({
// TODO: Make it a union type of "customPageMessage" and ..
@@ -1,6 +1,7 @@
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams";
import { parseRecurringDates } from "@calcom/lib/parse-dates";
import type { BookerEvent, BookingCreateBody, RecurringBookingCreateBody } from "../../types";
@@ -41,6 +42,10 @@ export const mapBookingToMutationInput = ({
teamMemberEmail,
orgSlug,
}: BookingOptions): BookingCreateBody => {
const searchParams = new URLSearchParams(window.location.search);
const routedTeamMemberIds = getRoutedTeamMemberIdsFromSearchParams(searchParams);
const routingFormResponseIdParam = searchParams.get("cal.routingFormResponseId");
const routingFormResponseId = routingFormResponseIdParam ? Number(routingFormResponseIdParam) : undefined;
return {
...values,
user: username,
@@ -62,6 +67,8 @@ export const mapBookingToMutationInput = ({
hashedLink,
teamMemberEmail,
orgSlug,
routedTeamMemberIds,
routingFormResponseId
};
};
@@ -202,6 +202,7 @@ async function handler(
smsReminderNumber,
rescheduleReason,
luckyUsers,
routedTeamMemberIds,
...reqBody
} = bookingData;
@@ -276,6 +277,7 @@ async function handler(
eventTypeId,
dynamicUserList,
logger: loggerWithEventDetails,
routedTeamMemberIds: routedTeamMemberIds ?? null,
});
let { locationBodyString, organizerOrFirstDynamicGroupMemberDefaultLocationUrl } = getLocationValuesForDb(
@@ -901,6 +903,7 @@ async function handler(
try {
booking = await createBooking({
uid,
routedFromRoutingFormResponseId: reqBody.routingFormResponseId,
reqBody: {
user: reqBody.user,
metadata: reqBody.metadata,
@@ -21,6 +21,7 @@ type ReqBodyWithEnd = TgetBookingDataSchema & { end: string };
type CreateBookingParams = {
uid: short.SUUID;
routedFromRoutingFormResponseId: number | undefined;
reqBody: {
user: ReqBodyWithEnd["user"];
metadata: ReqBodyWithEnd["metadata"];
@@ -61,6 +62,15 @@ function updateEventDetails(
}
}
async function getAssociatedBookingForFormResponse(formResponseId: number) {
const formResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
where: {
id: formResponseId,
},
});
return formResponse?.routedToBookingUid ?? null;
}
export async function createBooking({
uid,
reqBody,
@@ -68,11 +78,21 @@ export async function createBooking({
input,
evt,
originalRescheduledBooking,
routedFromRoutingFormResponseId,
}: CreateBookingParams) {
updateEventDetails(evt, originalRescheduledBooking, input.changedOrganizer);
const associatedBookingForFormResponse = routedFromRoutingFormResponseId
? await getAssociatedBookingForFormResponse(routedFromRoutingFormResponseId)
: null;
const newBookingData = buildNewBookingData({
uid,
// We allow only the first booking to be connected to the form response
// Other bookings could happen due to user doing a booking by using browser back button to reach the same booking form with same query params and changing the time
// Such case isn't what the Routing Form redirected the user to, so we avoid this at the moment.
routedFromRoutingFormResponseId: associatedBookingForFormResponse
? undefined
: routedFromRoutingFormResponseId,
reqBody,
eventType,
input,
@@ -145,7 +165,8 @@ function getAttendeesData(evt: Pick<CalendarEvent, "attendees" | "team">) {
}
function buildNewBookingData(params: CreateBookingParams): Prisma.BookingCreateInput {
const { uid, evt, reqBody, eventType, input, originalRescheduledBooking } = params;
const { uid, evt, reqBody, eventType, input, originalRescheduledBooking, routedFromRoutingFormResponseId } =
params;
const attendeesData = getAttendeesData(evt);
const eventTypeRel = getEventTypeRel(eventType.id);
@@ -184,6 +205,10 @@ function buildNewBookingData(params: CreateBookingParams): Prisma.BookingCreateI
connect: { id: evt.destinationCalendar[0].id },
}
: undefined,
routedFromRoutingFormReponse: routedFromRoutingFormResponseId
? { connect: { id: routedFromRoutingFormResponseId } }
: undefined,
};
if (reqBody.recurringEventId) {
@@ -26,6 +26,7 @@ type InputProps = {
eventTypeId: number;
dynamicUserList: string[];
logger: Logger<unknown>;
routedTeamMemberIds: number[] | null;
};
export async function loadAndValidateUsers({
@@ -34,8 +35,9 @@ export async function loadAndValidateUsers({
eventTypeId,
dynamicUserList,
logger,
routedTeamMemberIds,
}: InputProps): Promise<Users> {
let users: Users = await loadUsers(eventType, dynamicUserList, req);
let users: Users = await loadUsers({ eventType, dynamicUserList, req, routedTeamMemberIds });
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
if (!isDynamicAllowed && !eventTypeId) {
logger.warn({
@@ -2,8 +2,10 @@ 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 { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma, { userSelect } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -14,14 +16,25 @@ const log = logger.getSubLogger({ prefix: ["[loadUsers]:handleNewBooking "] });
type EventType = Pick<NewBookingEventType, "hosts" | "users" | "id">;
export const loadUsers = async (eventType: EventType, dynamicUserList: string[], req: IncomingMessage) => {
export const loadUsers = async ({
eventType,
dynamicUserList,
req,
routedTeamMemberIds,
}: {
eventType: EventType;
dynamicUserList: string[];
req: IncomingMessage;
routedTeamMemberIds: number[] | null;
}) => {
try {
const { currentOrgDomain } = orgDomainConfig(req);
return eventType.id
const users = eventType.id
? await loadUsersByEventType(eventType)
: await loadDynamicUsers(dynamicUserList, currentOrgDomain);
return getRoutedUsers({ users, routedTeamMemberIds });
} catch (error) {
log.error("Unable to load users", safeStringify(error));
if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) {
throw new HttpError({ statusCode: 400, message: error.message });
}
@@ -1,4 +1,7 @@
import { useSearchParams } from "next/navigation";
import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule";
import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { trpc } from "@calcom/trpc/react";
@@ -42,7 +45,9 @@ export const useSchedule = ({
prefetchNextMonth,
selectedDate,
});
const searchParams = useSearchParams();
const routedTeamMemberIds = searchParams ? getRoutedTeamMemberIdsFromSearchParams(searchParams) : null;
return trpc.viewer.public.slots.getSchedule.useQuery(
{
isTeamEvent,
@@ -61,6 +66,7 @@ export const useSchedule = ({
rescheduleUid,
orgSlug,
teamMemberEmail,
routedTeamMemberIds,
},
{
trpc: {
@@ -0,0 +1,11 @@
export const getRoutedTeamMemberIdsFromSearchParams = (searchParams: URLSearchParams) => {
const routedTeamMemberIdsParam = searchParams.get("cal.routedTeamMemberIds");
const routedTeamMemberIds =
typeof routedTeamMemberIdsParam === "string"
? routedTeamMemberIdsParam
.split(",")
.filter(Boolean)
.map((id) => parseInt(id, 10))
: null;
return routedTeamMemberIds;
};
+38
View File
@@ -0,0 +1,38 @@
import { safeStringify } from "@calcom/lib/safeStringify";
import logger from "@calcom/lib/logger";
const log = logger.getSubLogger({ prefix: ["[getRoutedUsers]"] });
export const getRoutedHosts = <T extends { user: { id: number }; isFixed?: boolean }>({
routedTeamMemberIds,
hosts,
}: {
routedTeamMemberIds: number[] | null;
hosts: T[];
}) => {
// 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 hosts;
}
log.debug("filtering hosts as per routedTeamMemberIds", safeStringify({ routedTeamMemberIds }));
return hosts.filter((host) => routedTeamMemberIds.includes(host.user.id) || host.isFixed);
};
export const getRoutedUsers = <T extends { id: number; isFixed?: boolean }>({
routedTeamMemberIds,
users,
}: {
routedTeamMemberIds: number[] | null;
users: T[];
}) => {
// 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);
};
+11
View File
@@ -256,6 +256,17 @@ export class UserRepository {
};
}
static async findByIds({ ids }: { ids: number[] }) {
return prisma.user.findMany({
where: {
id: {
in: ids,
},
},
select: userSelect,
});
}
static async findByIdOrThrow({ id }: { id: number }) {
const user = await UserRepository.findById({ id });
if (!user) {
@@ -0,0 +1,14 @@
/*
Warnings:
- A unique constraint covering the columns `[routedToBookingUid]` on the table `App_RoutingForms_FormResponse` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "App_RoutingForms_FormResponse" ADD COLUMN "routedToBookingUid" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "App_RoutingForms_FormResponse_routedToBookingUid_key" ON "App_RoutingForms_FormResponse"("routedToBookingUid");
-- AddForeignKey
ALTER TABLE "App_RoutingForms_FormResponse" ADD CONSTRAINT "App_RoutingForms_FormResponse_routedToBookingUid_fkey" FOREIGN KEY ("routedToBookingUid") REFERENCES "Booking"("uid") ON DELETE SET NULL ON UPDATE CASCADE;
+4 -1
View File
@@ -624,6 +624,7 @@ model Booking {
cancelledBy String?
/// @zod.email()
rescheduledBy String?
routedFromRoutingFormReponse App_RoutingForms_FormResponse?
@@index([eventTypeId])
@@index([userId])
@@ -940,7 +941,9 @@ model App_RoutingForms_FormResponse {
formId String
response Json
createdAt DateTime @default(now())
routedToBookingUid String? @unique
// We should not cascade delete the booking, because we want to keep the form response even if the routedToBooking is deleted
routedToBooking Booking? @relation(fields: [routedToBookingUid], references: [uid])
@@unique([formFillerId, formId])
@@index([formFillerId])
@@index([formId])
+2
View File
@@ -248,6 +248,8 @@ export const bookingCreateBodySchema = z.object({
seatReferenceUid: z.string().optional(),
orgSlug: z.string().optional(),
teamMemberEmail: z.string().nullish(),
routedTeamMemberIds: z.array(z.number()).nullish(),
routingFormResponseId: z.number().optional(),
});
export const requiredCustomInputSchema = z.union([
@@ -25,6 +25,7 @@ export const getScheduleSchema = z
isTeamEvent: z.boolean().optional().default(false),
orgSlug: z.string().nullish(),
teamMemberEmail: z.string().nullish(),
routedTeamMemberIds: z.array(z.number()).nullish(),
})
.transform((val) => {
// Need this so we can pass a single username in the query string form public API
@@ -31,7 +31,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";
@@ -402,7 +402,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
}
let currentSeats: CurrentSeats | undefined;
let hosts =
let eventHosts =
eventType.hosts?.length && eventType.schedulingType
? eventType.hosts
: eventType.users.map((user) => {
@@ -412,6 +412,8 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
};
});
let hosts = getRoutedHosts({ hosts: eventHosts, routedTeamMemberIds: input.routedTeamMemberIds ?? null });
if (
input.rescheduleUid &&
eventType.rescheduleWithSameRoundRobinHost &&