From 654a2048789eb4bea2853e81dfced009b2747817 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 8 Oct 2024 21:12:32 +0530 Subject: [PATCH] feat: Attribute based routing from Team Routing Form to Team Event(with assigned users matching the attribute logic) (#16823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --------- Co-authored-by: Omar López Co-authored-by: Benny Joo --- apps/web/public/static/locales/en/common.json | 2 + packages/app-store/routing-forms/README.md | 27 ++ packages/app-store/routing-forms/TODO.md | 49 ++ .../components/FormInputFields.tsx | 6 +- .../routing-forms/components/SingleForm.tsx | 213 ++++---- .../config/BasicConfig.ts | 453 ++++++++++++++++++ .../config/config.tsx | 319 +++++++----- .../react-awesome-query-builder/widgets.tsx | 5 +- .../app-store/routing-forms/lib/FieldTypes.ts | 24 +- .../routing-forms/lib/InitialConfig.tsx | 5 +- .../__tests__/getQueryBuilderConfig.test.ts | 20 +- .../routing-forms/lib/createFallbackRoute.ts | 4 +- .../lib/evaluateRaqbLogic.test.ts | 55 +++ .../routing-forms/lib/evaluateRaqbLogic.ts | 40 ++ .../routing-forms/lib/getAttributes.ts | 138 ++++++ .../lib/getQueryBuilderConfig.ts | 138 +++++- .../routing-forms/lib/getSerializableForm.ts | 5 +- .../routing-forms/lib/jsonLogic.test.ts | 115 +++++ .../{jsonLogicOverrides.ts => jsonLogic.ts} | 0 .../routing-forms/lib/processRoute.tsx | 60 +-- .../routing-forms/lib/substituteVariables.ts | 4 +- .../pages/reporting/[...appPages].tsx | 19 +- .../pages/route-builder/[...appPages].tsx | 286 ++++++++--- .../pages/router/getServerSideProps.ts | 26 +- .../pages/routing-link/[...appPages].tsx | 37 +- .../getUrlSearchParamsToForward.test.ts | 15 + .../getUrlSearchParamsToForward.ts | 8 + .../trpc/__tests__/utils.test.ts | 335 +++++++++++++ .../app-store/routing-forms/trpc/_router.ts | 22 + ...amMembersMatchingAttributeLogic.handler.ts | 68 +++ ...eamMembersMatchingAttributeLogic.schema.ts | 11 + .../trpc/getAttributesForTeam.handler.ts | 31 ++ .../trpc/getAttributesForTeam.schema.ts | 7 + .../app-store/routing-forms/trpc/raqbUtils.ts | 308 ++++++++++++ .../routing-forms/trpc/response.handler.ts | 34 +- .../routing-forms/trpc/response.schema.ts | 2 + .../app-store/routing-forms/trpc/utils.ts | 109 ++++- .../app-store/routing-forms/types/types.d.ts | 19 +- packages/app-store/routing-forms/zod.ts | 21 +- .../booking-to-mutation-input-mapper.tsx | 7 + .../features/bookings/lib/handleNewBooking.ts | 3 + .../lib/handleNewBooking/createBooking.ts | 27 +- .../handleNewBooking/loadAndValidateUsers.ts | 4 +- .../lib/handleNewBooking/loadUsers.ts | 19 +- .../schedules/lib/use-schedule/useSchedule.ts | 6 + .../getRoutedTeamMemberIdsFromSearchParams.ts | 11 + packages/lib/bookings/getRoutedUsers.ts | 38 ++ packages/lib/server/repository/user.ts | 11 + .../migration.sql | 14 + packages/prisma/schema.prisma | 5 +- packages/prisma/zod-utils.ts | 2 + .../trpc/server/routers/viewer/slots/types.ts | 1 + .../trpc/server/routers/viewer/slots/util.ts | 6 +- 53 files changed, 2784 insertions(+), 410 deletions(-) create mode 100644 packages/app-store/routing-forms/TODO.md create mode 100644 packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts create mode 100644 packages/app-store/routing-forms/lib/evaluateRaqbLogic.test.ts create mode 100644 packages/app-store/routing-forms/lib/evaluateRaqbLogic.ts create mode 100644 packages/app-store/routing-forms/lib/getAttributes.ts create mode 100644 packages/app-store/routing-forms/lib/jsonLogic.test.ts rename packages/app-store/routing-forms/lib/{jsonLogicOverrides.ts => jsonLogic.ts} (100%) create mode 100644 packages/app-store/routing-forms/trpc/__tests__/utils.test.ts create mode 100644 packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts create mode 100644 packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts create mode 100644 packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts create mode 100644 packages/app-store/routing-forms/trpc/getAttributesForTeam.schema.ts create mode 100644 packages/app-store/routing-forms/trpc/raqbUtils.ts create mode 100644 packages/lib/bookings/getRoutedTeamMemberIdsFromSearchParams.ts create mode 100644 packages/lib/bookings/getRoutedUsers.ts create mode 100644 packages/prisma/migrations/20241007134700_add_connection_bw_booking_and_routing_form_response/migration.sql diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index cd3c471a79..2fbd357631 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/app-store/routing-forms/README.md b/packages/app-store/routing-forms/README.md index 0620055ec6..53fafd2b64 100644 --- a/packages/app-store/routing-forms/README.md +++ b/packages/app-store/routing-forms/README.md @@ -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. diff --git a/packages/app-store/routing-forms/TODO.md b/packages/app-store/routing-forms/TODO.md new file mode 100644 index 0000000000..1b25c29786 --- /dev/null +++ b/packages/app-store/routing-forms/TODO.md @@ -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 \ No newline at end of file diff --git a/packages/app-store/routing-forms/components/FormInputFields.tsx b/packages/app-store/routing-forms/components/FormInputFields.tsx index 552a52438e..07baf4bf52 100644 --- a/packages/app-store/routing-forms/components/FormInputFields.tsx +++ b/packages/app-store/routing-forms/components/FormInputFields.tsx @@ -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; } diff --git a/packages/app-store/routing-forms/components/SingleForm.tsx b/packages/app-store/routing-forms/components/SingleForm.tsx index 8dca9b44a1..0ecf14ec43 100644 --- a/packages/app-store/routing-forms/components/SingleForm.tsx +++ b/packages/app-store/routing-forms/components/SingleForm.tsx @@ -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({}); - const [decidedAction, setDecidedAction] = useState(null); + const [chosenRoute, setChosenRoute] = useState(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(); @@ -306,6 +330,100 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF }); const connectedForms = form.connectedForms; + const testFormDialog = (() => { + const testResult = chosenRoute ? ( +
+
{t("route_to")}:
+
+ {RoutingPages.map((page) => { + if (page.value !== chosenRoute.action.type) return null; + return ( + + {page.label} + + ); + })} + :{" "} + {chosenRoute.action.type === "customPageMessage" ? ( + + {chosenRoute.action.value} + + ) : chosenRoute.action.type === "externalRedirectUrl" ? ( + + + {chosenRoute.action.value} + + + ) : ( +
+ + + {chosenRoute.action.value} + + + {isTeamForm ? ( +
+ {t("matching_members")}:{" "} + {!findTeamMembersMatchingAttributeLogicMutation.isPending ? ( +
+ {teamMembersMatchingAttributeLogic?.map((member) => member.email).join(", ") || + t("no_matching_members")} +
+ ) : ( +
Loading...
+ )} +
+ ) : null} +
+ )} +
+
+ ) : null; + + return ( + + + +
+
{ + e.preventDefault(); + testRouting(); + }}> +
+ {form && } +
+
{testResult}
+ + { + setIsTestPreviewOpen(false); + setChosenRoute(null); + setResponse({}); + }}> + {t("close")} + + + +
+
+
+
+ ); + })(); + return ( <>
- - - -
-
{ - e.preventDefault(); - testRouting(); - }}> -
- {form && } -
-
- {decidedAction && ( -
-
{t("route_to")}:
-
- {RoutingPages.map((page) => { - if (page.value !== decidedAction.type) return null; - return ( - - {page.label} - - ); - })} - :{" "} - {decidedAction.type === "customPageMessage" ? ( - - {decidedAction.value} - - ) : decidedAction.type === "externalRedirectUrl" ? ( - - - {decidedAction.value} - - - ) : ( - - - {decidedAction.value} - - - )} -
-
- )} -
- - { - setIsTestPreviewOpen(false); - setDecidedAction(null); - setResponse({}); - }}> - {t("close")} - - - -
-
-
-
+ {testFormDialog} ); } diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts new file mode 100644 index 0000000000..9a6c4455e9 --- /dev/null +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts @@ -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; +export type Conjunctions = Record; +export type Operator = RAQBOperator & { + _jsonLogicIsExclamationOp?: boolean; +} +export type Operators = Record; +export type WidgetWithoutFactory = Omit & { + type: string; + jsType: string; + toJS: (val: any) => any; +}; +export type WidgetsWithoutFactory = Record; +export type Type = RAQBType; +export type Types = Record; +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, +}; \ No newline at end of file diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.tsx b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.tsx index 538b3d0020..c4ab84cfc4 100644 --- a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.tsx +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.tsx @@ -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 (props: T1 | undefined, Component: React.FC) { @@ -29,128 +32,206 @@ const renderComponent = function (props: T1 | undefined, Component: React.FC return ; }; -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
; - } - return ; +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
; - } + 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
; + } + return ; + }, + valuePlaceholder: "Enter Phone Number", + }, + email: { + ...BasicConfig.widgets.text, + factory: (props) => { + if (!props) { + return
; + } - return ( - ) => { - 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 ( + ) => { + 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), +}; \ No newline at end of file diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx b/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx index d0f641edf4..a8d7e65e14 100644 --- a/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx @@ -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 (
{summary} @@ -371,6 +371,7 @@ const widgets = { ButtonGroup, Conjs, Provider, + EmailWidget: EmailField, }; export default widgets; diff --git a/packages/app-store/routing-forms/lib/FieldTypes.ts b/packages/app-store/routing-forms/lib/FieldTypes.ts index ee5bf48b34..55d050822e 100644 --- a/packages/app-store/routing-forms/lib/FieldTypes.ts +++ b/packages/app-store/routing-forms/lib/FieldTypes.ts @@ -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; diff --git a/packages/app-store/routing-forms/lib/InitialConfig.tsx b/packages/app-store/routing-forms/lib/InitialConfig.tsx index 3ef1b62f14..9481cdeafb 100644 --- a/packages/app-store/routing-forms/lib/InitialConfig.tsx +++ b/packages/app-store/routing-forms/lib/InitialConfig.tsx @@ -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; diff --git a/packages/app-store/routing-forms/lib/__tests__/getQueryBuilderConfig.test.ts b/packages/app-store/routing-forms/lib/__tests__/getQueryBuilderConfig.test.ts index ef07178f60..c14c6e81f8 100644 --- a/packages/app-store/routing-forms/lib/__tests__/getQueryBuilderConfig.test.ts +++ b/packages/app-store/routing-forms/lib/__tests__/getQueryBuilderConfig.test.ts @@ -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; 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), }) ); diff --git a/packages/app-store/routing-forms/lib/createFallbackRoute.ts b/packages/app-store/routing-forms/lib/createFallbackRoute.ts index b030f0092e..c762d3b686 100644 --- a/packages/app-store/routing-forms/lib/createFallbackRoute.ts +++ b/packages/app-store/routing-forms/lib/createFallbackRoute.ts @@ -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 => { const uuid = QbUtils.uuid(); @@ -11,6 +11,6 @@ export const createFallbackRoute = (): Exclude = 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"], }; }; diff --git a/packages/app-store/routing-forms/lib/evaluateRaqbLogic.test.ts b/packages/app-store/routing-forms/lib/evaluateRaqbLogic.test.ts new file mode 100644 index 0000000000..23f0512edc --- /dev/null +++ b/packages/app-store/routing-forms/lib/evaluateRaqbLogic.test.ts @@ -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); + }); +}); diff --git a/packages/app-store/routing-forms/lib/evaluateRaqbLogic.ts b/packages/app-store/routing-forms/lib/evaluateRaqbLogic.ts new file mode 100644 index 0000000000..acaa40f2ee --- /dev/null +++ b/packages/app-store/routing-forms/lib/evaluateRaqbLogic.ts @@ -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; + 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; +}; diff --git a/packages/app-store/routing-forms/lib/getAttributes.ts b/packages/app-store/routing-forms/lib/getAttributes.ts new file mode 100644 index 0000000000..3fd94e660d --- /dev/null +++ b/packages/app-store/routing-forms/lib/getAttributes.ts @@ -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 }>); + + return Object.values(teamMembers); +} diff --git a/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts b/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts index fb4ab31bb7..8f2ef33886 100644 --- a/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts +++ b/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts @@ -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, 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([ + [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, 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, 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, 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, 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; +}) { + 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, + }; +} diff --git a/packages/app-store/routing-forms/lib/getSerializableForm.ts b/packages/app-store/routing-forms/lib/getSerializableForm.ts index 7679dd8678..8940ad1fbb 100644 --- a/packages/app-store/routing-forms/lib/getSerializableForm.ts +++ b/packages/app-store/routing-forms/lib/getSerializableForm.ts @@ -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({ 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"); } diff --git a/packages/app-store/routing-forms/lib/jsonLogic.test.ts b/packages/app-store/routing-forms/lib/jsonLogic.test.ts new file mode 100644 index 0000000000..ed269991b9 --- /dev/null +++ b/packages/app-store/routing-forms/lib/jsonLogic.test.ts @@ -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); + }); + }); +}); diff --git a/packages/app-store/routing-forms/lib/jsonLogicOverrides.ts b/packages/app-store/routing-forms/lib/jsonLogic.ts similarity index 100% rename from packages/app-store/routing-forms/lib/jsonLogicOverrides.ts rename to packages/app-store/routing-forms/lib/jsonLogic.ts diff --git a/packages/app-store/routing-forms/lib/processRoute.tsx b/packages/app-store/routing-forms/lib/processRoute.tsx index 4ea8fb4e85..1d9222e5d9 100644 --- a/packages/app-store/routing-forms/lib/processRoute.tsx +++ b/packages/app-store/routing-forms/lib/processRoute.tsx @@ -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; response: Record>; }) { - 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[]; - 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 = {}; - 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 = 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; } diff --git a/packages/app-store/routing-forms/lib/substituteVariables.ts b/packages/app-store/routing-forms/lib/substituteVariables.ts index d4ff242590..6c5901b657 100644 --- a/packages/app-store/routing-forms/lib/substituteVariables.ts +++ b/packages/app-store/routing-forms/lib/substituteVariables.ts @@ -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[] ) => { diff --git a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx index d9a516ea31..45fd0ee5de 100644 --- a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx @@ -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) => { +const getInitialQuery = (config: ReturnType) => { 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) => { }; const Reporter = ({ form }: { form: inferSSRProps["form"] }) => { - const config = getQueryBuilderConfig(form, true); + const config = getQueryBuilderConfigForFormFields(form, true); const [query, setQuery] = useState(getInitialQuery(config)); const [jsonLogicQuery, setJsonLogicQuery] = useState(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["fo return (
{ - onChange(immutableTree, config as QueryBuilderUpdatedConfig); + onChange(immutableTree, config as unknown as FormFieldsQueryBuilderConfigWithRaqbFields); }} renderBuilder={renderBuilder} /> diff --git a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx index a6d4a25f9a..37b8c1a25e 100644 --- a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx @@ -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; +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 => { 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) => void; - config: QueryBuilderUpdatedConfig; + formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields; + attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null; setRoutes: React.Dispatch>; 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 ? ( +
+ + For responses matching the following criteria(matches all by default) + + { + onChangeFormFieldsQuery( + route, + immutableTree, + formFieldsQueryBuilderConfig as unknown as FormFieldsQueryBuilderConfigWithRaqbFields + ); + }} + renderBuilder={renderBuilder} + /> + +
+ ) : null; + + const attributesQueryBuilder = + route.action?.type === "eventTypeRedirectUrl" ? ( +
+ {isTeamForm && ( + + and use only the Team Members that match the following criteria(matches all by default) + + )} +
+ {route.attributesQueryBuilderState && attributesQueryBuilderConfig && ( + { + onChangeTeamMembersQuery( + route, + immutableTree, + attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields + ); + }} + renderBuilder={renderBuilder} + /> + )} +
+
+ ) : null; + return (
+ {formFieldsQueryBuilder}
@@ -343,20 +425,7 @@ const Route = ({ ) ) : null}
- - {((route.isFallback && hasRules(route)) || !route.isFallback) && ( - <> - - { - onChange(route, immutableTree, config as QueryBuilderUpdatedConfig); - }} - renderBuilder={renderBuilder} - /> - - )} + {attributesQueryBuilder}
@@ -364,16 +433,48 @@ const Route = ({ ); }; -const deserializeRoute = ( - route: Exclude, - 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; + 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["form"]; hookForm: UseFormReturn; 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 }) => { + 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 = ({
{ + 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
Loading...
; + } + if (!attributes) { + return
{t("something_went_wrong")}
; + } + } + return (
- +
); }} @@ -659,3 +819,5 @@ RouteBuilder.getLayout = (page: React.ReactElement) => { ); }; + +export { getServerSideProps }; diff --git a/packages/app-store/routing-forms/pages/router/getServerSideProps.ts b/packages/app-store/routing-forms/pages/router/getServerSideProps.ts index f8288c9bef..de119be8fc 100644 --- a/packages/app-store/routing-forms/pages/router/getServerSideProps.ts +++ b/packages/app-store/routing-forms/pages/router/getServerSideProps.ts @@ -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, }, diff --git a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx index a607639014..a6a10d15a7 100644 --- a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx @@ -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, diff --git a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts index 789cf959f5..d571ff1bfa 100644 --- a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts +++ b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.test.ts @@ -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); }); diff --git a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts index 281a637fcc..aa093873a6 100644 --- a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts +++ b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts @@ -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; 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(); diff --git a/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts b/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts new file mode 100644 index 0000000000..9066feb0d4 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts @@ -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>; + teamMembersWithAttributeOptionValuePerAttribute: Awaited< + ReturnType + >; +}) { + 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[]; + 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"); + }); + }); +}); diff --git a/packages/app-store/routing-forms/trpc/_router.ts b/packages/app-store/routing-forms/trpc/_router.ts index 6f514c568b..20b3a11177 100644 --- a/packages/app-store/routing-forms/trpc/_router.ts +++ b/packages/app-store/routing-forms/trpc/_router.ts @@ -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 = {}; @@ -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; diff --git a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts b/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts new file mode 100644 index 0000000000..6008214409 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts @@ -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; + }; + 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; diff --git a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts b/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts new file mode 100644 index 0000000000..60ce96c90c --- /dev/null +++ b/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts @@ -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 +>; diff --git a/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts new file mode 100644 index 0000000000..45b811abe8 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts @@ -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; + }; + 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 }); +} \ No newline at end of file diff --git a/packages/app-store/routing-forms/trpc/getAttributesForTeam.schema.ts b/packages/app-store/routing-forms/trpc/getAttributesForTeam.schema.ts new file mode 100644 index 0000000000..102d396515 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/getAttributesForTeam.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetAttributesForTeamInputSchema = z.object({ + teamId: z.number(), +}); + +export type TGetAttributesForTeamInputSchema = z.infer; diff --git a/packages/app-store/routing-forms/trpc/raqbUtils.ts b/packages/app-store/routing-forms/trpc/raqbUtils.ts new file mode 100644 index 0000000000..51992f02f7 --- /dev/null +++ b/packages/app-store/routing-forms/trpc/raqbUtils.ts @@ -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; +}) => { 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( + 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:} 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; + 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; + attributeId: string; + }) { + const fieldTypeFromAttributesQueryValue = raqbQueryValueUtils.getValueTypeFromAttributesQueryValueForRaqbField({ + attributesQueryValue, + raqbFieldId: attributeId, + }); + + if (fieldTypeFromAttributesQueryValue === "multiselect") { + return ensureArray(attributeValue); + } + return attributeValue; + }, +}; + +function getAttributesData({ + attributesData, + attributesQueryValue, +}: { + attributesData: Record; + attributesQueryValue: NonNullable; +}) { + 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); +} + +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; + + const attributesMap = attributes.reduce((acc, attribute) => { + acc[attribute.id] = attribute; + return acc; + }, {} as Record); + + 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, "fields">; + attributes: Attribute[]; + attributesQueryValue: NonNullable; +}) { + 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, +}; diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts index d030126500..8933b84471 100644 --- a/packages/app-store/routing-forms/trpc/response.handler.ts +++ b/packages/app-store/routing-forms/trpc/response.handler.ts @@ -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") { diff --git a/packages/app-store/routing-forms/trpc/response.schema.ts b/packages/app-store/routing-forms/trpc/response.schema.ts index b0f4a5a8d0..d232506438 100644 --- a/packages/app-store/routing-forms/trpc/response.schema.ts +++ b/packages/app-store/routing-forms/trpc/response.schema.ts @@ -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; diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts index a1b2df8a8f..f7574964b1 100644 --- a/packages/app-store/routing-forms/trpc/utils.ts +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -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["fields"]>[number]; +const { getAttributesData: getAttributes, getAttributesQueryBuilderConfig, getAttributesQueryValue } = + acrossQueryValueCompatiblity; -function isOptionsField(field: Pick) { +const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/utils"] }); + +function isOptionsField(field: Pick) { return (field.type === "select" || field.type === "multiselect") && field.options; } @@ -21,7 +32,7 @@ function getFieldResponse({ fieldResponseValue, }: { fieldResponseValue: FormResponse[keyof FormResponse]["value"]; - field: Pick; + field: Pick; }) { 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, "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 & { user: Pick; 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); } }; diff --git a/packages/app-store/routing-forms/types/types.d.ts b/packages/app-store/routing-forms/types/types.d.ts index 5fc63e9e4c..2e2077ec52 100644 --- a/packages/app-store/routing-forms/types/types.d.ts +++ b/packages/app-store/routing-forms/types/types.d.ts @@ -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; export type Field = NonNullable[number]; export type Routes = z.infer; -export type Route = Routes[0]; +export type Route = NonNullable[0]; export type NonRouterRoute = z.infer; 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; +export type FormFieldsQueryValue = LocalRoute["queryValue"]; +export type SerializableField = NonNullable["fields"]>[number]; \ No newline at end of file diff --git a/packages/app-store/routing-forms/zod.ts b/packages/app-store/routing-forms/zod.ts index 738412940c..536b291acf 100644 --- a/packages/app-store/routing-forms/zod.ts +++ b/packages/app-store/routing-forms/zod.ts @@ -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 .. diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index aedb4f46be..0fbe2c1072 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -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 }; }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 029bc8c547..45760e1485 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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, diff --git a/packages/features/bookings/lib/handleNewBooking/createBooking.ts b/packages/features/bookings/lib/handleNewBooking/createBooking.ts index 089cefbd28..ab215e6903 100644 --- a/packages/features/bookings/lib/handleNewBooking/createBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/createBooking.ts @@ -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) { } 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) { diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts index 94b2643366..7af0cef324 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts @@ -26,6 +26,7 @@ type InputProps = { eventTypeId: number; dynamicUserList: string[]; logger: Logger; + routedTeamMemberIds: number[] | null; }; export async function loadAndValidateUsers({ @@ -34,8 +35,9 @@ export async function loadAndValidateUsers({ eventTypeId, dynamicUserList, logger, + routedTeamMemberIds, }: InputProps): Promise { - 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({ diff --git a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts index 02eaa51e39..121f769f58 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts @@ -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; -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 }); } diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index cbe6dc245e..2323f7df86 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -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: { diff --git a/packages/lib/bookings/getRoutedTeamMemberIdsFromSearchParams.ts b/packages/lib/bookings/getRoutedTeamMemberIdsFromSearchParams.ts new file mode 100644 index 0000000000..99015c36b8 --- /dev/null +++ b/packages/lib/bookings/getRoutedTeamMemberIdsFromSearchParams.ts @@ -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; +}; diff --git a/packages/lib/bookings/getRoutedUsers.ts b/packages/lib/bookings/getRoutedUsers.ts new file mode 100644 index 0000000000..7873a68565 --- /dev/null +++ b/packages/lib/bookings/getRoutedUsers.ts @@ -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 = ({ + 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 = ({ + 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); +}; diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index 3ecea08512..ad95767535 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -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) { diff --git a/packages/prisma/migrations/20241007134700_add_connection_bw_booking_and_routing_form_response/migration.sql b/packages/prisma/migrations/20241007134700_add_connection_bw_booking_and_routing_form_response/migration.sql new file mode 100644 index 0000000000..dbed89a5ad --- /dev/null +++ b/packages/prisma/migrations/20241007134700_add_connection_bw_booking_and_routing_form_response/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f5df907c0f..5ad99c650e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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]) diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 32f1ac88ef..5b7cee43fc 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -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([ diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 3e124615f9..acc9e426b2 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -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 diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index f930ce309c..3354d99eb6 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -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 &&