From 82e52a1948d4bad5157fe8d980560e75744b407d Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Wed, 20 Nov 2024 10:22:53 +0530 Subject: [PATCH] feat: Attribute filter/Segment for Round Robin hosts (#17361) Co-authored-by: CarinaWolli --- apps/web/components/Segment.tsx | 173 +++++++ .../web/components/__tests__/Segment.test.tsx | 180 +++++++ apps/web/components/dialog/RerouteDialog.tsx | 2 +- .../dialog/__tests__/RerouteDialog.test.tsx | 2 +- .../getTeamMemberEmailFromCrm.test.ts | 2 - apps/web/public/static/locales/en/common.json | 4 + apps/web/styles/globals.css | 3 + .../styles.css => apps/web/styles/raqb.css | 0 .../test/lib/handleChildrenEventTypes.test.ts | 13 + .../utils/bookingScenario/bookingScenario.ts | 6 + packages/app-store/routing-forms/TODO.md | 27 -- .../__tests__/TestFormDialog.test.tsx | 2 +- .../components/FormInputFields.tsx | 6 +- .../routing-forms/components/SingleForm.tsx | 3 +- .../config/BasicConfig.ts | 19 +- .../config/config.ts | 100 ++++ .../config/config.tsx | 198 -------- .../config/types.ts | 9 + .../config/uiConfig.tsx | 145 ++++++ .../lib/getQueryBuilderConfig.ts | 31 +- .../routing-forms/lib/getSerializableForm.ts | 4 +- .../routing-forms/lib/processRoute.tsx | 6 +- .../pages/reporting/[...appPages].tsx | 1 - .../pages/route-builder/[...appPages].tsx | 67 +-- .../playwright/tests/basic.e2e.ts | 3 +- .../trpc/formMutation.handler.ts | 63 +-- .../trpc/getAttributesForTeam.handler.ts | 2 +- .../routing-forms/trpc/response.handler.ts | 64 +-- packages/app-store/routing-forms/zod.ts | 79 +--- .../Booker/components/hooks/useBookings.ts | 10 + .../booking-to-mutation-input-mapper.tsx | 3 + .../features/bookings/lib/handleNewBooking.ts | 443 +++++++++++++----- .../handleNewBooking/getEventTypesFromDB.ts | 9 +- .../handleNewBooking/loadAndValidateUsers.ts | 11 +- .../lib/handleNewBooking/loadUsers.ts | 35 +- .../lib/handleChildrenEventTypes.ts | 6 + .../components/AddMembersWithSwitch.tsx | 252 ++++++++-- .../components/AssignAllTeamMembers.tsx | 1 + .../__tests__/AddMembersWithSwitch.test.tsx | 203 ++++++++ .../assignment/EventTeamAssignmentTab.tsx | 47 +- packages/features/eventtypes/lib/types.ts | 4 +- .../lib/bookings/findQualifiedHosts.test.ts | 1 + packages/lib/bookings/findQualifiedHosts.ts | 39 +- packages/lib/bookings/getRoutedUsers.ts | 121 +++++ packages/lib/defaultEvents.ts | 2 + packages/lib/isEqual.ts | 26 + .../raqb}/evaluateRaqbLogic.test.ts | 3 +- .../lib => lib/raqb}/evaluateRaqbLogic.ts | 0 ...TeamMembersMatchingAttributeLogic.test.ts} | 250 +++++----- .../findTeamMembersMatchingAttributeLogic.ts} | 200 ++++---- .../lib => lib/raqb}/getAttributes.ts | 2 +- .../lib => lib/raqb}/jsonLogic.test.ts | 23 + .../lib => lib/raqb}/jsonLogic.ts | 0 .../lib => lib/raqb}/raqbUtils.ts | 214 ++++----- packages/lib/raqb/types.d.ts | 47 ++ .../__tests__ => lib/raqb}/zod.test.ts | 89 ++-- packages/lib/raqb/zod.ts | 85 ++++ packages/lib/server/eventTypeSelect.ts | 2 + packages/lib/server/getLuckyUser.ts | 25 +- packages/lib/server/queries/teams/index.ts | 3 + packages/lib/server/repository/eventType.ts | 2 + packages/lib/test/builder.ts | 2 + .../event-types/hooks/useEventTypeForm.ts | 2 + .../wrappers/EventTypeWebWrapper.tsx | 9 +- .../migration.sql | 3 + packages/prisma/schema.prisma | 4 + packages/prisma/zod-utils.ts | 7 + .../routers/viewer/attributes/_router.ts | 17 + ...amMembersMatchingAttributeLogic.handler.ts | 65 +++ ...eamMembersMatchingAttributeLogic.schema.ts | 15 + .../viewer/eventTypes/duplicate.handler.ts | 3 + .../server/routers/viewer/eventTypes/types.ts | 9 +- .../viewer/eventTypes/update.handler.ts | 4 + .../routers/viewer/routing-forms/_router.ts | 10 +- ...sMatchingAttributeLogicOfRoute.handler.ts} | 25 +- ...rsMatchingAttributeLogicOfRoute.schema.ts} | 6 +- .../trpc/server/routers/viewer/slots/util.ts | 19 +- .../components/form/switch/SettingsToggle.tsx | 4 +- 78 files changed, 2454 insertions(+), 1122 deletions(-) create mode 100644 apps/web/components/Segment.tsx create mode 100644 apps/web/components/__tests__/Segment.test.tsx rename packages/app-store/routing-forms/components/react-awesome-query-builder/styles.css => apps/web/styles/raqb.css (100%) create mode 100644 packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.ts delete mode 100644 packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.tsx create mode 100644 packages/app-store/routing-forms/components/react-awesome-query-builder/config/types.ts create mode 100644 packages/app-store/routing-forms/components/react-awesome-query-builder/config/uiConfig.tsx create mode 100644 packages/features/eventtypes/components/__tests__/AddMembersWithSwitch.test.tsx create mode 100644 packages/lib/isEqual.ts rename packages/{app-store/routing-forms/lib => lib/raqb}/evaluateRaqbLogic.test.ts (93%) rename packages/{app-store/routing-forms/lib => lib/raqb}/evaluateRaqbLogic.ts (100%) rename packages/{app-store/routing-forms/lib/__tests__/findTeamMembersMatchingAttributeLogicOfRoute.test.ts => lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts} (83%) rename packages/{app-store/routing-forms/lib/findTeamMembersMatchingAttributeLogicOfRoute.ts => lib/raqb/findTeamMembersMatchingAttributeLogic.ts} (74%) rename packages/{app-store/routing-forms/lib => lib/raqb}/getAttributes.ts (98%) rename packages/{app-store/routing-forms/lib => lib/raqb}/jsonLogic.test.ts (90%) rename packages/{app-store/routing-forms/lib => lib/raqb}/jsonLogic.ts (100%) rename packages/{app-store/routing-forms/lib => lib/raqb}/raqbUtils.ts (55%) create mode 100644 packages/lib/raqb/types.d.ts rename packages/{app-store/routing-forms/__tests__ => lib/raqb}/zod.test.ts (73%) create mode 100644 packages/lib/raqb/zod.ts create mode 100644 packages/prisma/migrations/20241028103610_add_segment_related_assignment_columns_event_type/migration.sql create mode 100644 packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.schema.ts rename packages/trpc/server/routers/viewer/routing-forms/{findTeamMembersMatchingAttributeLogic.handler.ts => findTeamMembersMatchingAttributeLogicOfRoute.handler.ts} (91%) rename packages/trpc/server/routers/viewer/routing-forms/{findTeamMembersMatchingAttributeLogic.schema.ts => findTeamMembersMatchingAttributeLogicOfRoute.schema.ts} (63%) diff --git a/apps/web/components/Segment.tsx b/apps/web/components/Segment.tsx new file mode 100644 index 0000000000..6cc90d2758 --- /dev/null +++ b/apps/web/components/Segment.tsx @@ -0,0 +1,173 @@ +import { useCallback, useState } from "react"; +import { Query, Builder, Utils as QbUtils } from "react-awesome-query-builder"; +import type { ImmutableTree, BuilderProps } from "react-awesome-query-builder"; +import type { JsonTree } from "react-awesome-query-builder"; + +import { + withRaqbSettingsAndWidgets, + ConfigFor, +} from "@calcom/app-store/routing-forms/components/react-awesome-query-builder/config/uiConfig"; +import { getQueryBuilderConfigForAttributes } from "@calcom/app-store/routing-forms/lib/getQueryBuilderConfig"; +import { classNames as cn } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { isEqual } from "@calcom/lib/isEqual"; +import { buildStateFromQueryValue } from "@calcom/lib/raqb/raqbUtils"; +import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; +import { trpc, type RouterOutputs } from "@calcom/trpc"; + +export type Attributes = RouterOutputs["viewer"]["appRoutingForms"]["getAttributesForTeam"]; +export function useAttributes(teamId: number) { + const { data: attributes, isPending } = trpc.viewer.appRoutingForms.getAttributesForTeam.useQuery({ + teamId, + }); + return { + attributes, + isPending, + }; +} + +function SegmentWithAttributes({ + attributes, + teamId, + queryValue: initialQueryValue, + onQueryValueChange, + className, +}: { + attributes: Attributes; + teamId: number; + queryValue: AttributesQueryValue | null; + onQueryValueChange: ({ queryValue }: { queryValue: AttributesQueryValue }) => void; + className?: string; +}) { + const attributesQueryBuilderConfig = getQueryBuilderConfigForAttributes({ + attributes, + }); + + const [queryValue, setQueryValue] = useState(initialQueryValue); + const attributesQueryBuilderConfigWithRaqbSettingsAndWidgets = withRaqbSettingsAndWidgets({ + config: attributesQueryBuilderConfig, + configFor: ConfigFor.Attributes, + }); + + const queryBuilderData = buildStateFromQueryValue({ + queryValue: queryValue as JsonTree, + config: attributesQueryBuilderConfigWithRaqbSettingsAndWidgets, + }); + + const renderBuilder = useCallback( + (props: BuilderProps) => ( +
+
+ +
+
+ ), + [] + ); + + function onChange(immutableTree: ImmutableTree) { + const jsonTree = QbUtils.getTree(immutableTree) as AttributesQueryValue; + + // IMPORTANT: RAQB calls onChange even without explicit user action. It just identifies if the props have changed or not. isEqual ensures that we don't end up having infinite re-renders. + if (!isEqual(jsonTree, queryValue)) { + setQueryValue(jsonTree); + onQueryValueChange({ + queryValue: jsonTree, + }); + } + } + + return ( + // cal-query-builder class has special styling through global CSS, allowing us to customize RAQB +
+
+ +
+
+ +
+
+ ); +} + +function MatchingTeamMembers({ + teamId, + queryValue, +}: { + teamId: number; + queryValue: AttributesQueryValue | null; +}) { + const { t } = useLocale(); + const { data: matchingTeamMembersWithResult, isPending } = + trpc.viewer.attributes.findTeamMembersMatchingAttributeLogic.useQuery({ + teamId, + attributesQueryValue: queryValue, + _enablePerf: true, + }); + + if (isPending) return {t("loading")}; + if (!matchingTeamMembersWithResult) return {t("something_went_wrong")}; + const { result: matchingTeamMembers } = matchingTeamMembersWithResult; + if (!matchingTeamMembers || !queryValue) { + return ( +
+
+ {t("no_filter_set")} +
+
+ ); + } + + return ( +
+
+ {t("x_matching_members", { x: matchingTeamMembers.length })} +
+
    + {matchingTeamMembers.map((member) => ( +
  • +
    + {member.name} + ({member.email}) +
    +
  • + ))} +
+
+ ); +} + +export function Segment({ + teamId, + queryValue, + onQueryValueChange, + className, +}: { + teamId: number; + queryValue: AttributesQueryValue | null; + onQueryValueChange: ({ queryValue }: { queryValue: AttributesQueryValue }) => void; + className?: string; +}) { + const { attributes, isPending } = useAttributes(teamId); + const { t } = useLocale(); + if (isPending) return Loading...; + if (!attributes) { + console.log("Error fetching attributes"); + return {t("something_went_wrong")}; + } + + return ( + + ); +} diff --git a/apps/web/components/__tests__/Segment.test.tsx b/apps/web/components/__tests__/Segment.test.tsx new file mode 100644 index 0000000000..02abf78dd2 --- /dev/null +++ b/apps/web/components/__tests__/Segment.test.tsx @@ -0,0 +1,180 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { Mock } from "vitest"; + +import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; +import { AttributeType } from "@calcom/prisma/enums"; +import { trpc, type RouterOutputs } from "@calcom/trpc"; + +import { Segment } from "../Segment"; + +type Attributes = RouterOutputs["viewer"]["appRoutingForms"]["getAttributesForTeam"]; +type MatchingTeamMembersData = RouterOutputs["viewer"]["attributes"]["findTeamMembersMatchingAttributeLogic"]; +const mockGetAttributesForTeam = ( + arg: { data: Attributes; isPending: false } | { data: undefined; isPending: true } +) => { + ( + trpc.viewer.appRoutingForms.getAttributesForTeam.useQuery as Mock< + typeof trpc.viewer.appRoutingForms.getAttributesForTeam.useQuery + > + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockReturnValue(arg as any); +}; + +const mockGetMatchingTeamMembers = ( + arg: + | { + data: MatchingTeamMembersData; + isPending: false; + } + | { + data: undefined; + isPending: true; + } +) => { + ( + trpc.viewer.attributes.findTeamMembersMatchingAttributeLogic.useQuery as Mock< + typeof trpc.viewer.attributes.findTeamMembersMatchingAttributeLogic.useQuery + > + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockReturnValue(arg as any); +}; + +const mockAttributesWithSingleSelect = () => { + return mockGetAttributesForTeam({ + data: [ + { + id: "a", + slug: "department", + name: "Department", + type: AttributeType.SINGLE_SELECT, + options: [ + { id: "1", value: "Sales", slug: "sales" }, + { id: "2", value: "Engineering", slug: "engineering" }, + ], + }, + ], + isPending: false, + }); +}; + +// Mock the TRPC hooks +vi.mock("@calcom/trpc", () => ({ + trpc: { + viewer: { + appRoutingForms: { + getAttributesForTeam: { + useQuery: vi.fn(), + }, + }, + attributes: { + findTeamMembersMatchingAttributeLogic: { + useQuery: vi.fn(), + }, + }, + }, + }, +})); + +// Mock useLocale hook +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string) => key, + }), +})); + +describe("Segment", () => { + const defaultProps = { + teamId: 1, + queryValue: null as AttributesQueryValue | null, + onQueryValueChange: vi.fn(), + className: "test-class", + }; + beforeEach(() => { + mockAttributesWithSingleSelect(); + }); + + it("renders query builder when attributes are loaded", async () => { + mockGetMatchingTeamMembers({ + data: { + mainWarnings: null, + fallbackWarnings: null, + troubleshooter: undefined, + result: [ + { + id: 1, + name: "John Doe", + email: "john@example.com", + }, + ], + }, + isPending: false, + }); + render(); + + await waitFor(() => { + // Query builder container should be present + expect(screen.getByTestId("query-builder-container")).toBeInTheDocument(); + expect(screen.queryByText("loading")).not.toBeInTheDocument(); + }); + }); + + it("renders in loading state when matching team members are not loaded", async () => { + mockGetMatchingTeamMembers({ + data: undefined, + isPending: true, + }); + render(); + + await waitFor(() => { + expect(screen.getByText("loading")).toBeInTheDocument(); + }); + }); + + it("shows matching team members when query value is provided", async () => { + mockGetMatchingTeamMembers({ + isPending: false, + data: { + mainWarnings: null, + fallbackWarnings: null, + troubleshooter: undefined, + result: [ + { + id: 1, + name: "John Doe", + email: "john@example.com", + }, + ], + }, + }); + + const queryValue = { + id: "root", + type: "group", + } as AttributesQueryValue; + + render(); + await waitFor(() => { + expect(screen.getByText("John Doe")).toBeInTheDocument(); + expect(screen.getByText("(john@example.com)")).toBeInTheDocument(); + }); + }); + + it("shows no filter message when no query is set", async () => { + mockGetMatchingTeamMembers({ + data: { + mainWarnings: null, + fallbackWarnings: null, + troubleshooter: undefined, + result: [] as MatchingTeamMembersData["result"], + }, + isPending: false, + }); + render(); + await waitFor(() => { + expect(screen.getByText("no_filter_set")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/components/dialog/RerouteDialog.tsx b/apps/web/components/dialog/RerouteDialog.tsx index a5612a8f43..ca72e40dc8 100644 --- a/apps/web/components/dialog/RerouteDialog.tsx +++ b/apps/web/components/dialog/RerouteDialog.tsx @@ -775,7 +775,7 @@ const RerouteDialogContentAndFooterWithFormResponse = ({ >([]); const findTeamMembersMatchingAttributeLogicMutation = - trpc.viewer.routingForms.findTeamMembersMatchingAttributeLogic.useMutation({ + trpc.viewer.routingForms.findTeamMembersMatchingAttributeLogicOfRoute.useMutation({ onSuccess(data) { setTeamMembersMatchingAttributeLogic(data.result ? data.result.users : data.result); }, diff --git a/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx index 721417aa96..e5c68d786e 100644 --- a/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx +++ b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx @@ -183,7 +183,7 @@ vi.mock("@calcom/trpc/react", () => ({ }, }, routingForms: { - findTeamMembersMatchingAttributeLogic: { + findTeamMembersMatchingAttributeLogicOfRoute: { useMutation: vi.fn(({ onSuccess }) => { return { mutate: vi.fn(() => { diff --git a/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts b/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts index 7124dc4413..2c06159ca7 100644 --- a/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts +++ b/apps/web/lib/__tests__/getTeamMemberEmailFromCrm.test.ts @@ -87,8 +87,6 @@ async function createRoutingFormWithResponse({ queryValue: { id: v4(), type: "group", - children1: [], - properties: {}, }, id: v4(), ...route, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 400a6b2df0..d6458a4c6d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2682,6 +2682,7 @@ "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", + "x_matching_members": "{{x}} matching members", "matching_members_queue_using_attribute_weights": "Matching members queue (using attribute weights)", "matching_members_queue_using_event_assignee_weights": "Matching members queue (using event assignee weights)", "no_matching_members": "No matching members. It will fallback to using the team members assigned to the event type.", @@ -2751,6 +2752,7 @@ "reassign_unavailable_team_member_description": "Are you sure you want to reassign this booking to an unavailable host?", "reassign_available_team_member_description": "Are you sure you want to reassign this booking?", "yes_reassign": "Yes, reassign", + "filter_by_attributes": "Filter by attributes", "reassign_reason": "Reason for reassigning", "reassigned_by": "Reassigned by", "row": "row", @@ -2763,6 +2765,8 @@ "event_end_time_in_attendee_timezone_info": "Event End Time In Attendee Timezone", "salesforce_create_event_on_contact": "Create event on contact, if it exists. Else fallback to lead", "salesforce_owner_name_to_change": "Owner name to change", + "no_filter_set": "No filter set", + "booking_dry_run_successful": "Booking dry run successful", "dont_have_access_this_page": "You don't have access to this page", "you_need_admin_or_owner_privileges_to_access": "In order to view or interact with this page, you must have admin or owner privileges", "field_type": "Field type", diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index 4c25942b20..7a9021fe68 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -483,3 +483,6 @@ select:focus { .intercom-lightweight-app { @apply z-40 !important; } + + +@import "./raqb.css"; diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/styles.css b/apps/web/styles/raqb.css similarity index 100% rename from packages/app-store/routing-forms/components/react-awesome-query-builder/styles.css rename to apps/web/styles/raqb.css diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index a028816818..210dc5508f 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -142,6 +142,8 @@ describe("handleChildrenEventTypes", () => { recurringEvent: undefined, eventTypeColor: undefined, userId: 4, + rrSegmentQueryValue: undefined, + assignRRMembersUsingSegment: false, }, }); expect(result.newUserIds).toEqual([4]); @@ -187,6 +189,8 @@ describe("handleChildrenEventTypes", () => { expect(prismaMock.eventType.update).toHaveBeenCalledWith({ data: { ...rest, + assignRRMembersUsingSegment: undefined, + rrSegmentQueryValue: undefined, locations: [], scheduleId: null, lockTimeZoneToggleOnBookingPage: false, @@ -299,6 +303,8 @@ describe("handleChildrenEventTypes", () => { userId: 4, workflows: undefined, hashedLink: undefined, + rrSegmentQueryValue: undefined, + assignRRMembersUsingSegment: false, }, }); expect(result.newUserIds).toEqual([4]); @@ -343,6 +349,8 @@ describe("handleChildrenEventTypes", () => { expect(prismaMock.eventType.update).toHaveBeenCalledWith({ data: { ...rest, + assignRRMembersUsingSegment: undefined, + rrSegmentQueryValue: undefined, locations: [], hashedLink: { deleteMany: {}, @@ -405,6 +413,7 @@ describe("handleChildrenEventTypes", () => { profileId: null, updatedValues: {}, }); + expect(prismaMock.eventType.create).toHaveBeenCalledWith({ data: { ...evType, @@ -429,6 +438,8 @@ describe("handleChildrenEventTypes", () => { create: [{ workflowId: 11 }], }, hashedLink: undefined, + rrSegmentQueryValue: undefined, + assignRRMembersUsingSegment: false, }, }); const { profileId, ...rest } = evType; @@ -437,6 +448,8 @@ describe("handleChildrenEventTypes", () => { data: { ...rest, locations: [], + assignRRMembersUsingSegment: undefined, + rrSegmentQueryValue: undefined, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, hashedLink: { diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 5eabc04760..fc3f53515e 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -7,6 +7,7 @@ import type { Prisma } from "@prisma/client"; import type { WebhookTriggerEvents } from "@prisma/client"; import type Stripe from "stripe"; import { v4 as uuidv4 } from "uuid"; +import { vi } from "vitest"; import "vitest-fetch-mock"; import type { z } from "zod"; @@ -35,6 +36,11 @@ import type { EventBusyDate, IntervalLimit } from "@calcom/types/Calendar"; import { getMockPaymentService } from "./MockPaymentService"; import type { getMockRequestDataForBooking } from "./getMockRequestDataForBooking"; +// We don't need to test it. Also, it causes Formbricks error when imported +vi.mock("@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic", () => ({ + default: {}, +})); + type Fields = z.infer; logger.settings.minLevel = 1; diff --git a/packages/app-store/routing-forms/TODO.md b/packages/app-store/routing-forms/TODO.md index d0540e8b46..cf08213fc5 100644 --- a/packages/app-store/routing-forms/TODO.md +++ b/packages/app-store/routing-forms/TODO.md @@ -1,18 +1,4 @@ -## Ready for Review Requirements -- Authorization check for fetch form response and form - - - ## 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 -- [x] We might want to disable attributes query builder for non-orgs. Same for Re-routing. - [ ] Switching from Select to MultiSelect in attribute - [ ] It immediately makes the Routing logic to not work. Fix it. - [ ] It removes the selected option from Routing. Fix it. @@ -23,10 +9,6 @@ - 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. - [ ] When logic can't be built(Short text maps to an attribute with given set of options but the short text value doesn't match any of the options), the team member matching route throws error but Test Preview still shows the previous value - It show should the error in there. - [ ] When routing to a team that doesn't have the team members assigned that are chosen, we are unable to select those users. FIX it by ignoring the assigned members(except the fixed hosts) and just using the attributes matched team members. -- [x] Start storing eventTypeId in routing Action. -- [x] Make sure that without making the FORM dirty, user can't reroute -- [x] When 'Verify Route' is clicked, it doesn't seem to run the form field logic as there is a warning in console. Fix it. -- [x] Give a proper icon to Reroute button - [ ] Fetch bookings on window focus but not when rerouting dialog is open. As soon as it is closed, we need to refetch again. - [ ] Seated Events - [ ] Disable routing for seated events. Disable only during creation. @@ -45,22 +27,13 @@ - [ ] 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 -- [x] getRoutedUsers tests - [ ] getAvailableSlots - [ ] should use routedTeamMemberIds in availability query -- [ ] RerouteDialog - - [x] BASIC - - [ ] New Tab rescheduling ### Documentation / Tooltip diff --git a/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx b/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx index bd28ff0f83..26f35885ec 100644 --- a/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx +++ b/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx @@ -98,7 +98,7 @@ vi.mock("@calcom/trpc/react", () => ({ trpc: { viewer: { routingForms: { - findTeamMembersMatchingAttributeLogic: { + findTeamMembersMatchingAttributeLogicOfRoute: { useMutation: vi.fn(({ onSuccess }) => { return { mutate: vi.fn(() => { diff --git a/packages/app-store/routing-forms/components/FormInputFields.tsx b/packages/app-store/routing-forms/components/FormInputFields.tsx index 76f6c1e202..00cb27ee56 100644 --- a/packages/app-store/routing-forms/components/FormInputFields.tsx +++ b/packages/app-store/routing-forms/components/FormInputFields.tsx @@ -9,6 +9,7 @@ import isRouterLinkedField from "../lib/isRouterLinkedField"; import { getUIOptionsForSelect } from "../lib/selectOptions"; import { getFieldResponseForJsonLogic } from "../lib/transformResponse"; import type { SerializableForm, FormResponse } from "../types/types"; +import { ConfigFor, withRaqbSettingsAndWidgets } from "./react-awesome-query-builder/config/uiConfig"; export type FormInputFieldsProps = { form: Pick, "fields">; @@ -26,7 +27,10 @@ export type FormInputFieldsProps = { export default function FormInputFields(props: FormInputFieldsProps) { const { form, response, setResponse, disabledFields = [] } = props; - const formFieldsQueryBuilderConfig = getQueryBuilderConfigForFormFields(form); + const formFieldsQueryBuilderConfig = withRaqbSettingsAndWidgets({ + config: getQueryBuilderConfigForFormFields(form), + configFor: ConfigFor.FormFields, + }); return ( <> diff --git a/packages/app-store/routing-forms/components/SingleForm.tsx b/packages/app-store/routing-forms/components/SingleForm.tsx index 51e1931a58..562b7bf607 100644 --- a/packages/app-store/routing-forms/components/SingleForm.tsx +++ b/packages/app-store/routing-forms/components/SingleForm.tsx @@ -431,7 +431,7 @@ export const TestFormDialog = ({ setMembersMatchResult(null); }; const findTeamMembersMatchingAttributeLogicMutation = - trpc.viewer.routingForms.findTeamMembersMatchingAttributeLogic.useMutation({ + trpc.viewer.routingForms.findTeamMembersMatchingAttributeLogicOfRoute.useMutation({ onSuccess(data) { setMembersMatchResult({ isUsingAttributeWeights: data.isUsingAttributeWeights, @@ -708,6 +708,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF {t("routing_forms_send_email_to")} ({ value: member.id.toString(), label: member.name || member.email, diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts index cbd8884192..86fd692be2 100644 --- a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts @@ -7,10 +7,7 @@ import type { Operator as RAQBOperator, } from "react-awesome-query-builder"; -export type Conjunction = Omit< - RAQBConjunction, - "formatConj" | "sqlFormatConj" | "spelFormatConj" | "mongoConj" ->; +export type Conjunction = RAQBConjunction; export type Conjunctions = Record; export type Operator = RAQBOperator & { _jsonLogicIsExclamationOp?: boolean; @@ -30,11 +27,19 @@ const conjunctions: Conjunctions = { label: "And", jsonLogicConj: "and", reversedConj: "OR", + formatConj: () => "", + sqlFormatConj: () => "", + spelFormatConj: () => "", + mongoConj: "", }, OR: { label: "Or", jsonLogicConj: "or", reversedConj: "AND", + formatConj: () => "", + sqlFormatConj: () => "", + spelFormatConj: () => "", + mongoConj: "", }, }; @@ -175,7 +180,7 @@ const operators: Operators = { }, select_not_any_in: { isNotOp: true, - label: "Not in", + label: "Not any in", labelForFormat: "NOT IN", reversedOp: "select_any_in", }, @@ -438,10 +443,12 @@ const settings: Settings = { setOpOnChangeField: ["keep" as const, "default" as const], // 'default' (default if present), 'keep' (keep prev from last field), 'first', 'none' }; -export default { +const basicConfig = { conjunctions, operators, widgets, types, settings, }; + +export default basicConfig; diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.ts b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.ts new file mode 100644 index 0000000000..a39a10ca48 --- /dev/null +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.ts @@ -0,0 +1,100 @@ +// Figure out why routing-forms/env.d.ts doesn't work +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +//@ts-ignore +import type { Operators, Types } from "./BasicConfig"; +import BasicConfig from "./BasicConfig"; +import { ConfigFor } from "./types"; +import type { WidgetsWithoutFactory } from "./types"; + +function getWidgetsWithoutFactory(_configFor: ConfigFor) { + const widgetsWithoutFactory: WidgetsWithoutFactory = { + ...BasicConfig.widgets, + phone: { + ...BasicConfig.widgets.text, + }, + email: { + ...BasicConfig.widgets.text, + }, + }; + return widgetsWithoutFactory; +} + +function getTypes(configFor: ConfigFor) { + const multiSelectOperators = BasicConfig.types.multiselect.widgets.multiselect.operators || []; + + if (configFor === ConfigFor.Attributes) { + // Attributes don't need reporting at the moment. So, we can support multiselect_some_in and multiselect_not_some_in operators for attributes. + // We could probably use them in FormFields later once they are supported through Prisma query as well + multiSelectOperators.push("multiselect_some_in", "multiselect_not_some_in"); + } + + 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: [...multiSelectOperators], + }, + }, + }, + }; + return types; +} + +function getOperators(_configFor: ConfigFor) { + // Clone to avoid mutating the original object + const operators: Operators = { + ...BasicConfig.operators, + }; + + return operators; +} + +function getConjunctions(_configFor: ConfigFor) { + return { + ...BasicConfig.conjunctions, + }; +} + +function getSettingsWithoutRenderFns() { + return { + ...BasicConfig.settings, + groupActionsPosition: "bottomCenter" as const, + // TODO: Test it and then enable it. It might allow us to show better error messages. + // But it doesn't detect every kind of error like an operator gone missing e.g. what happened in https://github.com/calcom/cal.com/pull/17102 + showErrorMessage: true, + // Disable groups + maxNesting: 1, + }; +} + +export const FormFieldsBaseConfig = { + conjunctions: getConjunctions(ConfigFor.FormFields), + operators: getOperators(ConfigFor.FormFields), + types: getTypes(ConfigFor.FormFields), + widgets: getWidgetsWithoutFactory(ConfigFor.FormFields), + settings: getSettingsWithoutRenderFns(), +}; + +export const AttributesBaseConfig = { + conjunctions: getConjunctions(ConfigFor.Attributes), + operators: getOperators(ConfigFor.Attributes), + types: getTypes(ConfigFor.Attributes), + widgets: getWidgetsWithoutFactory(ConfigFor.Attributes), + settings: getSettingsWithoutRenderFns(), +}; 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 deleted file mode 100644 index 33029af784..0000000000 --- a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import type { ChangeEvent } from "react"; -import type { - Settings, - Widgets, - SelectWidgetProps, - SelectWidget as SelectWidgetType, -} from "react-awesome-query-builder"; - -import { EmailField as EmailWidget } from "@calcom/ui"; - -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 type { Operators, Types } from "./BasicConfig"; -import BasicConfig from "./BasicConfig"; - -const enum ConfigFor { - FormFields = "FormFields", - Attributes = "Attributes", -} -const { - TextWidget, - TextAreaWidget, - MultiSelectWidget, - SelectWidget, - NumberWidget, - FieldSelect, - Conjs, - Button, - ButtonGroup, - Provider, -} = widgetsComponents; - -const renderComponent = function (props: T1 | undefined, Component: React.FC) { - if (!props) { - return
; - } - return ; -}; - -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), - - groupActionsPosition: "bottomCenter", - // TODO: Test it and then enable it. It might allow us to show better error messages. - // But it doesn't detect every kind of error like an operator gone missing e.g. what happened in https://github.com/calcom/cal.com/pull/17102 - showErrorMessage: true, - // 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. -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), - }, - 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 SelectWidgetType, - select: { - ...BasicConfig.widgets.select, - factory: ( - props: SelectWidgetProps & { - listValues: { title: string; value: string }[]; - } - ) => renderComponent(props, SelectWidget), - } as SelectWidgetType, - 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 widgets; -} - -function getTypes(configFor: ConfigFor) { - const multiSelectOperators = BasicConfig.types.multiselect.widgets.multiselect.operators || []; - - if (configFor === ConfigFor.Attributes) { - // Attributes don't need reporting at the moment. So, we can support multiselect_some_in and multiselect_not_some_in operators for attributes. - // We could probably use them in FormFields later once they are supported through Prisma query as well - multiSelectOperators.push("multiselect_some_in", "multiselect_not_some_in"); - } - - 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: [...multiSelectOperators], - }, - }, - }, - }; - return types; -} - -function getOperators(configFor: ConfigFor) { - // Clone to avoid mutating the original object - const operators: Operators = { - ...BasicConfig.operators, - }; - - 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), -}; - -export const AttributesBaseConfig = { - conjunctions: getConjunctions(ConfigFor.Attributes), - operators: getOperators(ConfigFor.Attributes), - types: getTypes(ConfigFor.Attributes), - widgets: getWidgets(ConfigFor.Attributes), - settings: getSettings(ConfigFor.Attributes), -}; diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/types.ts b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/types.ts new file mode 100644 index 0000000000..2afbd64ea7 --- /dev/null +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/types.ts @@ -0,0 +1,9 @@ +import type { Widgets as RaqbWidgets } from "react-awesome-query-builder"; + +export const enum ConfigFor { + FormFields = "FormFields", + Attributes = "Attributes", +} + +export type Widgets = RaqbWidgets & { [key in keyof RaqbWidgets]: RaqbWidgets[key] & { type: string } }; +export type WidgetsWithoutFactory = { [key in keyof Widgets]: Omit }; diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/uiConfig.tsx b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/uiConfig.tsx new file mode 100644 index 0000000000..9fa1acf02c --- /dev/null +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/uiConfig.tsx @@ -0,0 +1,145 @@ +import type { ChangeEvent } from "react"; +import type { + Settings, + SelectWidgetProps, + SelectWidget as SelectWidgetType, + WidgetProps, +} from "react-awesome-query-builder"; + +import { EmailField as EmailWidget } from "@calcom/ui"; + +import widgetsComponents from "../widgets"; +import type { Widgets, WidgetsWithoutFactory } from "./types"; +import type { ConfigFor } from "./types"; + +export { ConfigFor } from "./types"; + +const renderComponent = function (props: T1 | undefined, Component: React.FC) { + if (!props) { + return
; + } + return ; +}; + +const { + TextWidget, + TextAreaWidget, + MultiSelectWidget, + SelectWidget, + NumberWidget, + FieldSelect, + Conjs, + Button, + ButtonGroup, + Provider, +} = widgetsComponents; + +const TextFactory = (props: WidgetProps | undefined) => renderComponent(props, TextWidget); +const TextAreaFactory = (props: WidgetProps | undefined) => renderComponent(props, TextAreaWidget); +const NumberFactory = (props: WidgetProps | undefined) => renderComponent(props, NumberWidget); +const MultiSelectFactory = ( + props: + | (SelectWidgetProps & { + listValues: { title: string; value: string }[]; + }) + | undefined +) => renderComponent(props, MultiSelectWidget); +const SelectFactory = ( + props: + | (SelectWidgetProps & { + listValues: { title: string; value: string }[]; + }) + | undefined +) => renderComponent(props, SelectWidget); + +const PhoneFactory = (props: WidgetProps | undefined) => { + if (!props) { + return
; + } + return ; +}; + +const EmailFactory = (props: WidgetProps | undefined) => { + 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} + /> + ); +}; + +// 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. +function withFactoryWidgets(widgets: WidgetsWithoutFactory) { + const widgetsWithFactory: Widgets = { + ...widgets, + text: { + ...widgets.text, + factory: TextFactory, + }, + textarea: { + ...widgets.textarea, + factory: TextAreaFactory, + }, + number: { + ...widgets.number, + factory: NumberFactory, + }, + multiselect: { + ...widgets.multiselect, + factory: MultiSelectFactory, + } as SelectWidgetType, + select: { + ...widgets.select, + factory: SelectFactory, + } as SelectWidgetType, + phone: { + ...widgets.text, + factory: PhoneFactory, + valuePlaceholder: "Enter Phone Number", + }, + email: { + ...widgets.text, + factory: EmailFactory, + }, + }; + return widgetsWithFactory; +} + +function withRenderFnsSettings(settings: Settings) { + const settingsWithRenderFns: Settings = { + ...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), + }; + return settingsWithRenderFns; +} + +export function withRaqbSettingsAndWidgets({ + config, +}: { + config: T; + configFor: ConfigFor; +}) { + const { settings, widgets, ...rest } = config; + return { + ...rest, + settings: withRenderFnsSettings(settings), + widgets: withFactoryWidgets(widgets), + }; +} diff --git a/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts b/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts index c776d077ec..a6384d617c 100644 --- a/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts +++ b/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts @@ -111,10 +111,18 @@ function transformAttributesToCompatibleFormat(attributes: Attribute[]) { export function getQueryBuilderConfigForAttributes({ attributes, - form, + /** + * It is the fields that makes up additional options to be matched with for the single select/multiselect attributes. + * They are shown as 'Value of field ' in the dropdown. + */ + dynamicOperandFields = [], }: { attributes: Attribute[]; - form: Pick; + + dynamicOperandFields?: { + label: string; + id: string; + }[]; }) { const transformedAttributes = transformAttributesToCompatibleFormat(attributes); const fields: RaqbConfigFields = {}; @@ -124,16 +132,15 @@ export function getQueryBuilderConfigForAttributes({ // 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; - })() - ); + const valueOfFieldOptions = (() => { + const formFieldsOptions = dynamicOperandFields.map((field) => ({ + title: `Value of field '${field.label}'`, + value: `{field:${field.id}}`, + })); + return formFieldsOptions; + })(); + + const attributeOptions = [...valueOfFieldOptions, ...attribute.options]; // These are RAQB fields fields[attribute.id] = { diff --git a/packages/app-store/routing-forms/lib/getSerializableForm.ts b/packages/app-store/routing-forms/lib/getSerializableForm.ts index e6513ade4d..df6d669154 100644 --- a/packages/app-store/routing-forms/lib/getSerializableForm.ts +++ b/packages/app-store/routing-forms/lib/getSerializableForm.ts @@ -28,7 +28,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)); + log.error("Error parsing routes", safeStringify({ error: routesParsed.error, routes: form.routes })); throw new Error("Error parsing routes"); } @@ -51,7 +51,7 @@ export async function getSerializableForm({ const fields = parsedFields as NonNullable>; const fieldsExistInForm: Record = {}; - parsedFields?.forEach((f) => { + parsedFields.forEach((f) => { fieldsExistInForm[f.id] = true; }); diff --git a/packages/app-store/routing-forms/lib/processRoute.tsx b/packages/app-store/routing-forms/lib/processRoute.tsx index ec5b38ddc1..de21d15dfb 100644 --- a/packages/app-store/routing-forms/lib/processRoute.tsx +++ b/packages/app-store/routing-forms/lib/processRoute.tsx @@ -1,11 +1,13 @@ "use client"; import type { App_RoutingForms_Form } from "@prisma/client"; +import type { JsonTree } from "react-awesome-query-builder"; import type { z } from "zod"; +import { evaluateRaqbLogic, RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic"; + import type { FormResponse, Route, SerializableForm } from "../types/types"; import type { zodNonRouterRoute } from "../zod"; -import { evaluateRaqbLogic, RaqbLogicResult } from "./evaluateRaqbLogic"; import { getQueryBuilderConfigForFormFields } from "./getQueryBuilderConfig"; import { isFallbackRoute } from "./isFallbackRoute"; import isRouter from "./isRouter"; @@ -50,7 +52,7 @@ export function findMatchingRoute({ ); const result = evaluateRaqbLogic({ - queryValue: route.queryValue, + queryValue: route.queryValue as JsonTree, queryBuilderConfig, data: responseValues, }); diff --git a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx index 0872ad7c2d..69647f649c 100644 --- a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx @@ -22,7 +22,6 @@ import { Button, showToast } from "@calcom/ui"; import SingleForm, { getServerSidePropsForSingleFormView as getServerSideProps, } from "../../components/SingleForm"; -import "../../components/react-awesome-query-builder/styles.css"; import type { JsonLogicQuery } from "../../jsonLogicToPrisma"; import { getQueryBuilderConfigForFormFields, 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 0ff64ed93f..5ad1bb5705 100644 --- a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx @@ -5,13 +5,15 @@ import Link from "next/link"; import React, { useCallback, useState, useEffect } from "react"; import { Query, Builder, Utils as QbUtils } from "react-awesome-query-builder"; import type { ImmutableTree, BuilderProps, Config } from "react-awesome-query-builder"; +import type { JsonTree } 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 { buildEmptyQueryValue } from "@calcom/lib/raqb/raqbUtils"; import type { App_RoutingForms_Form } from "@calcom/prisma/client"; -import { SchedulingType } from "@calcom/prisma/client"; +import type { SchedulingType } from "@calcom/prisma/client"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; @@ -31,7 +33,10 @@ import type { RoutingFormWithResponseCount } from "../../components/SingleForm"; import SingleForm, { getServerSidePropsForSingleFormView as getServerSideProps, } from "../../components/SingleForm"; -import "../../components/react-awesome-query-builder/styles.css"; +import { + withRaqbSettingsAndWidgets, + ConfigFor, +} from "../../components/react-awesome-query-builder/config/uiConfig"; import { RoutingPages } from "../../lib/RoutingPages"; import { createFallbackRoute } from "../../lib/createFallbackRoute"; import getEventTypeAppMetadata from "../../lib/getEventTypeAppMetadata"; @@ -104,7 +109,7 @@ const hasRules = (route: EditFormRoute) => { }; function getEmptyQueryValue() { - return { id: QbUtils.uuid(), type: "group" }; + return buildEmptyQueryValue(); } const getEmptyRoute = (): Exclude => { @@ -341,20 +346,16 @@ const Route = ({ } : undefined; - const chosenEventTypeForRedirect = eventTypeRedirectUrlSelectedOption?.eventTypeId - ? eventTypesMap.get(eventTypeRedirectUrlSelectedOption.eventTypeId) - : null; - - const isRoundRobinEventSelectedForRedirect = - chosenEventTypeForRedirect?.schedulingType === SchedulingType.ROUND_ROBIN; - const formFieldsQueryBuilder = shouldShowFormFieldsQueryBuilder ? (
For responses matching the following criteria (matches all by default) { onChangeFormFieldsQuery( @@ -370,6 +371,13 @@ const Route = ({
) : null; + const attributesQueryBuilderConfigWithRaqbSettingsAndWidgets = attributesQueryBuilderConfig + ? withRaqbSettingsAndWidgets({ + config: attributesQueryBuilderConfig, + configFor: ConfigFor.Attributes, + }) + : null; + const attributesQueryBuilder = route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
@@ -391,9 +399,9 @@ const Route = ({ ) : null}
- {route.attributesQueryBuilderState && attributesQueryBuilderConfig && ( + {route.attributesQueryBuilderState && attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && ( { onChangeTeamMembersQuery( @@ -416,20 +424,21 @@ const Route = ({ {t("fallback_attribute_logic_description")}
- {route.fallbackAttributesQueryBuilderState && attributesQueryBuilderConfig && ( - { - onChangeFallbackTeamMembersQuery( - route, - immutableTree, - attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields - ); - }} - renderBuilder={renderBuilder} - /> - )} + {route.fallbackAttributesQueryBuilderState && + attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && ( + { + onChangeFallbackTeamMembersQuery( + route, + immutableTree, + attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields + ); + }} + renderBuilder={renderBuilder} + /> + )}
) : null; @@ -600,7 +609,7 @@ const buildState = < queryValue, config, }: T) => ({ - tree: QbUtils.checkTree(QbUtils.loadTree(queryValue), config as unknown as Config), + tree: QbUtils.checkTree(QbUtils.loadTree(queryValue as JsonTree), config as unknown as Config), config, }); @@ -736,7 +745,7 @@ const Routes = ({ const attributesQueryBuilderConfig = attributes ? getQueryBuilderConfigForAttributes({ attributes: attributes, - form: hookForm.getValues(), + dynamicOperandFields: hookForm.getValues().fields, }) : null; diff --git a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts index d95fc18a14..0e92529ea6 100644 --- a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts +++ b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts @@ -61,11 +61,10 @@ async function addAttributeRoutingRule(page: Page) { }); const attributeValueSelector = attributeQueryBuilder.locator(".rule--value").nth(0); - const numOfOptionsInAttribute = 3; await selectFirstValueForAttributeValue({ fromLocator: attributeValueSelector, // Select 'Value of Field Short Text' option - option: numOfOptionsInAttribute + 1, + option: 1, }); } diff --git a/packages/app-store/routing-forms/trpc/formMutation.handler.ts b/packages/app-store/routing-forms/trpc/formMutation.handler.ts index 08299432b7..8054b14514 100644 --- a/packages/app-store/routing-forms/trpc/formMutation.handler.ts +++ b/packages/app-store/routing-forms/trpc/formMutation.handler.ts @@ -13,7 +13,7 @@ import { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed"; import isRouter from "../lib/isRouter"; import isRouterLinkedField from "../lib/isRouterLinkedField"; import type { SerializableForm } from "../types/types"; -import { zodFields, zodRouterRoute, zodRoutes, queryValueSaveValidationSchema } from "../zod"; +import { zodFields, zodRouterRoute, zodRoutes } from "../zod"; import type { TFormMutationInputSchema } from "./formMutation.schema"; interface FormMutationHandlerOptions { @@ -24,58 +24,6 @@ interface FormMutationHandlerOptions { input: TFormMutationInputSchema; } -function throwIfInvalidQueryValueToBeSaved({ - routes, -}: { - routes: FormMutationHandlerOptions["input"]["routes"]; -}) { - if (!routes) { - return; - } - routes.forEach((route, routeIndex) => { - if (isRouter(route)) { - return; - } - // We use separate schema for queryValye here which is much more strict - // It allows that we are still lenient with schema while reading the queryValue but while saving it we are strict - const parsedFormFieldsQueryValue = queryValueSaveValidationSchema.safeParse(route.queryValue); - if (!parsedFormFieldsQueryValue.success) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Route ${routeIndex + 1} form fields: ${getErrorMessageFromZodError( - parsedFormFieldsQueryValue.error - )}`, - }); - } - - const parsedAttributesQueryValue = queryValueSaveValidationSchema.safeParse(route.attributesQueryValue); - if (!parsedAttributesQueryValue.success) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Route ${routeIndex + 1} attributes: ${getErrorMessageFromZodError( - parsedAttributesQueryValue.error - )}`, - }); - } - - const parsedFallbackAttributesQueryValue = queryValueSaveValidationSchema.safeParse( - route.fallbackAttributesQueryValue - ); - if (!parsedFallbackAttributesQueryValue.success) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Route ${routeIndex + 1} fallback attributes: ${getErrorMessageFromZodError( - parsedFallbackAttributesQueryValue.error - )}`, - }); - } - }); - - function getErrorMessageFromZodError(zodError: Zod.ZodError) { - return zodError.errors.map((err) => err.message).join(", "); - } -} - export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOptions) => { const { user, prisma } = ctx; const { name, id, description, disabled, addFallback, duplicateFrom, shouldConnect } = input; @@ -86,16 +34,9 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt code: "FORBIDDEN", }); } - let { routes: inputRoutes } = input; - // Ensures that wrong queryValue is not saved - // This is super useful when we make some wrong change in RAQB config accidentally and end up - // - Removing an operator support from a rule - // - Populating things wrong for any reason in RAQB - // It would just ensure that the wrong queryValue is not saved. Because it is impossible to fix it once saved. User would have to manually fix it then. - throwIfInvalidQueryValueToBeSaved({ routes: inputRoutes }); + let { routes: inputRoutes, fields: inputFields } = input; - let { fields: inputFields } = input; inputFields = inputFields || []; inputRoutes = inputRoutes || []; type InputFields = typeof inputFields; diff --git a/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts index 5ebe7aa17e..9ebbcaeedf 100644 --- a/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts +++ b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts @@ -3,7 +3,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; -import { getAttributesForTeam } from "../lib/getAttributes"; +import { getAttributesForTeam } from "@calcom/lib/raqb/getAttributes"; import type { TGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema"; type GetAttributesForTeamHandlerOptions = { diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts index fd63c3ac41..4a87665d76 100644 --- a/packages/app-store/routing-forms/trpc/response.handler.ts +++ b/packages/app-store/routing-forms/trpc/response.handler.ts @@ -3,14 +3,14 @@ import { z } from "zod"; import { emailSchema } from "@calcom/lib/emailSchema"; import logger from "@calcom/lib/logger"; +import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic"; 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"; -// import { RoutingFormFieldType } from "../lib/FieldTypes"; -import { findTeamMembersMatchingAttributeLogicOfRoute } from "../lib/findTeamMembersMatchingAttributeLogicOfRoute"; import { getSerializableForm } from "../lib/getSerializableForm"; +import isRouter from "../lib/isRouter"; import type { FormResponse } from "../types/types"; import type { TResponseInputSchema } from "./response.schema"; import { onFormSubmission } from "./utils"; @@ -119,34 +119,41 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => } const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId); - if (!chosenRoute) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Chosen route not found", - }); - } + let teamMemberIdsMatchingAttributeLogic: number[] | null = null; - const teamMembersMatchingAttributeLogicWithResult = - form.teamId && chosenRouteId - ? await findTeamMembersMatchingAttributeLogicOfRoute({ - response, - route: chosenRoute, - form: serializableForm, + if (chosenRoute) { + if (isRouter(chosenRoute)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Chosen route is a router", + }); + } + const teamMembersMatchingAttributeLogicWithResult = form.teamId + ? await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { + response, + fields: serializableForm.fields || [], + }, + attributesQueryValue: chosenRoute.attributesQueryValue ?? null, + fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue, teamId: form.teamId, }) : null; - moduleLogger.debug( - "teamMembersMatchingAttributeLogic", - safeStringify({ teamMembersMatchingAttributeLogicWithResult }) - ); + moduleLogger.debug( + "teamMembersMatchingAttributeLogic", + safeStringify({ teamMembersMatchingAttributeLogicWithResult }) + ); - const teamMemberIdsMatchingAttributeLogic = - teamMembersMatchingAttributeLogicWithResult?.teamMembersMatchingAttributeLogic - ? teamMembersMatchingAttributeLogicWithResult.teamMembersMatchingAttributeLogic.map( - (member) => member.userId - ) - : null; + teamMemberIdsMatchingAttributeLogic = + teamMembersMatchingAttributeLogicWithResult?.teamMembersMatchingAttributeLogic + ? teamMembersMatchingAttributeLogicWithResult.teamMembersMatchingAttributeLogic.map( + (member) => member.userId + ) + : null; + } else { + // It currently happens for a Router route. Such a route id isn't present in the form.routes + } // const chosenRouteName = `Route ${chosenRouteIndex + 1}`; @@ -218,15 +225,18 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => { ...serializableFormWithFields, userWithEmails }, dbFormResponse.response as FormResponse, dbFormResponse.id, - "action" in chosenRoute ? chosenRoute.action : undefined + chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : undefined) : undefined ); return { isPreview: false, formResponse: dbFormResponse, teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic, - attributeRoutingConfig: - "attributeRoutingConfig" in chosenRoute ? chosenRoute.attributeRoutingConfig ?? null : null, + attributeRoutingConfig: chosenRoute + ? "attributeRoutingConfig" in chosenRoute + ? chosenRoute.attributeRoutingConfig + : null + : null, }; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/packages/app-store/routing-forms/zod.ts b/packages/app-store/routing-forms/zod.ts index 04a0012b13..5402e6ba68 100644 --- a/packages/app-store/routing-forms/zod.ts +++ b/packages/app-store/routing-forms/zod.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod"; + import { routingFormAppDataSchemas } from "./appDataSchemas"; export const zodNonRouterField = z.object({ @@ -50,12 +52,6 @@ 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 enum RouteActionType { CustomPageMessage = "customPageMessage", @@ -65,71 +61,6 @@ export enum RouteActionType { export const routeActionTypeSchema = z.nativeEnum(RouteActionType); -export const children1Schema = z.object({ - children1: z - .record( - z.object({ - type: z.string().optional(), - properties: z - .object({ - field: z.any().optional(), - operator: z.any().optional(), - value: z.any().optional(), - valueSrc: z.any().optional(), - }) - .optional(), - }) - ) - .optional() - // Be very careful and lenient here. Just ensure that the rule isn't invalid without breaking anything - .superRefine((children1, ctx) => { - if (!children1) return; - const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null; - Object.entries(children1).forEach(([, _rule]) => { - const rule = _rule as unknown; - if (!isObject(rule) || rule.type !== "rule") return; - if (!isObject(rule.properties)) return; - - const value = rule.properties.value || []; - const valueSrc = rule.properties.valueSrc; - if (!(value instanceof Array) || !(valueSrc instanceof Array)) { - return; - } - - if (!valueSrc.length) { - // If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty - return; - } - - // MultiSelect array can be 2D array - const flattenedValues = value.flat(); - - const validValues = flattenedValues.filter((value: unknown) => { - // Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field - // Also, it is possible that RAQB has some requirements to support null or empty string values. - if (value === undefined) return false; - return true; - }); - - if (!validValues.length) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Looks like you are trying to create a rule with no value", - }); - } - }); - }), -}); -/** - * Stricter schema for validating before saving to DB - * It doesn't decide what will be saved, it is just to validate the data before saving - */ -export const queryValueSaveValidationSchema = queryValueSchema - .omit({ children1: true }) - .merge(children1Schema) - .nullish(); - export const attributeRoutingConfigSchema = z .object({ skipContactOwner: z.boolean().optional(), @@ -147,17 +78,17 @@ export const zodNonRouterRoute = z.object({ * RAQB query value for form fields * BRANDED to ensure we don't give it Attributes */ - queryValue: queryValueSchema.brand<"formFieldsQueryValue">(), + queryValue: raqbQueryValueSchema.brand<"formFieldsQueryValue">(), /** * RAQB query value for attributes. It is only applicable for Team Events as it is used to find matching team members * BRANDED to ensure we don't give it Form Fields */ - attributesQueryValue: queryValueSchema.brand<"attributesQueryValue">().optional(), + attributesQueryValue: raqbQueryValueSchema.optional(), /** * RAQB query value for fallback of `attributesQueryValue` * BRANDED to ensure we don't give it Form Fields, It needs Attributes */ - fallbackAttributesQueryValue: queryValueSchema.brand<"attributesQueryValue">().optional(), + fallbackAttributesQueryValue: raqbQueryValueSchema.optional(), /** * Whether the route is a fallback if no other routes match */ diff --git a/packages/features/bookings/Booker/components/hooks/useBookings.ts b/packages/features/bookings/Booker/components/hooks/useBookings.ts index 9480f3fb1d..208ab06b85 100644 --- a/packages/features/bookings/Booker/components/hooks/useBookings.ts +++ b/packages/features/bookings/Booker/components/hooks/useBookings.ts @@ -180,6 +180,10 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb const createBookingMutation = useMutation({ mutationFn: createBooking, onSuccess: (booking) => { + if (booking.isDryRun) { + showToast(t("booking_dry_run_successful"), "success"); + return; + } const { uid, paymentUid } = booking; const fullName = getFullName(bookingForm.getValues("responses.name")); @@ -305,6 +309,12 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb mutationFn: createRecurringBooking, onSuccess: async (bookings) => { const booking = bookings[0] || {}; + + if (booking.isDryRun) { + showToast(t("booking_dry_run_successful"), "success"); + return; + } + const { uid } = booking; if (!uid) { 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 c98e490bfa..0a362eccd0 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 @@ -48,6 +48,8 @@ export const mapBookingToMutationInput = ({ const routingFormResponseId = routingFormResponseIdParam ? Number(routingFormResponseIdParam) : undefined; const skipContactOwner = searchParams.get("cal.skipContactOwner") === "true"; const reroutingFormResponses = searchParams.get("cal.reroutingFormResponses"); + const isBookingDryRun = searchParams.get("cal.isBookingDryRun") === "true"; + return { ...values, user: username, @@ -74,6 +76,7 @@ export const mapBookingToMutationInput = ({ skipContactOwner, // In case of rerouting, the form responses are actually the responses that we need to update. reroutingFormResponses: reroutingFormResponses ? JSON.parse(reroutingFormResponses) : undefined, + _isDryRun: isBookingDryRun, }; }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index f7955d5556..6912b023b7 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -188,6 +188,155 @@ function buildLuckyUsersWithJustContactOwner({ return luckyUsers; } +type CreatedBooking = Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number }; + +const buildDryRunBooking = ({ + eventTypeId, + organizerUser, + eventName, + startTime, + endTime, + contactOwnerFromReq, + contactOwnerEmail, + allHostUsers, + isManagedEventType, +}: { + eventTypeId: number; + organizerUser: { + id: number; + name: string | null; + username: string | null; + email: string; + timeZone: string; + }; + eventName: string; + startTime: string; + endTime: string; + contactOwnerFromReq: string | null; + contactOwnerEmail: string | null; + allHostUsers: { id: number }[]; + isManagedEventType: boolean; +}) => { + const booking = { + id: -101, + uid: "DRY_RUN_UID", + iCalUID: "DRY_RUN_ICAL_UID", + status: BookingStatus.ACCEPTED, + eventTypeId: eventTypeId, + user: organizerUser, + userId: organizerUser.id, + title: eventName, + startTime: new Date(startTime), + endTime: new Date(endTime), + createdAt: new Date(), + updatedAt: new Date(), + attendees: [], + references: [], + payment: [], + oneTimePassword: null, + smsReminderNumber: null, + metadata: {}, + idempotencyKey: null, + userPrimaryEmail: null, + description: null, + customInputs: null, + responses: null, + location: null, + paid: false, + destinationCalendar: null, + cancellationReason: null, + rejectionReason: null, + dynamicEventSlugRef: null, + dynamicGroupSlugRef: null, + rescheduledFrom: null, + fromReschedule: null, + recurringEventId: null, + seatsReferences: [], + workflowReminders: [], + scheduledJobs: [], + rescheduledTo: null, + rescheduledBy: null, + destinationCalendarId: null, + reassignReason: null, + reassignById: null, + rescheduled: false, + confirmed: false, + isRecurringEvent: false, + isRecorded: false, + iCalSequence: 0, + rating: null, + ratingFeedback: null, + noShowHost: null, + cancelledBy: null, + } as CreatedBooking; + + /** + * Troubleshooting data + */ + const troubleshooterData = { + organizerUserId: organizerUser.id, + eventTypeId, + askedContactOwnerEmail: contactOwnerFromReq, + usedContactOwnerEmail: contactOwnerEmail, + allHostUsers: allHostUsers.map((user) => user.id), + isManagedEventType: isManagedEventType, + }; + + return { + booking, + troubleshooterData, + }; +}; + +const buildDryRunEventManager = () => { + return { + create: async () => ({ results: [], referencesToCreate: [] }), + reschedule: async () => ({ results: [], referencesToCreate: [] }), + }; +}; + +function buildTroubleshooterData({ + eventType, +}: { + eventType: { + id: number; + slug: string; + }; +}) { + const troubleshooterData: { + organizerUser: { + id: number; + } | null; + eventType: { + id: number; + slug: string; + }; + allHostUsers: number[]; + luckyUsers: number[]; + luckyUserPool: number[]; + fixedUsers: number[]; + luckyUsersFromFirstBooking: number[]; + usedContactOwnerEmail: string | null; + askedContactOwnerEmail: string | null; + isManagedEventType: boolean; + } = { + organizerUser: null, + eventType: { + id: eventType.id, + slug: eventType.slug, + }, + luckyUsers: [], + luckyUserPool: [], + fixedUsers: [], + luckyUsersFromFirstBooking: [], + usedContactOwnerEmail: null, + allHostUsers: [], + askedContactOwnerEmail: null, + isManagedEventType: false, + }; + return troubleshooterData; +} + async function handler( req: NextApiRequest & { userId?: number | undefined; @@ -243,9 +392,14 @@ async function handler( routedTeamMemberIds, reroutingFormResponses, routingFormResponseId, + _isDryRun: isDryRun = false, ...reqBody } = bookingData; + let troubleshooterData = buildTroubleshooterData({ + eventType, + }); + const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug); await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail }); @@ -315,7 +469,7 @@ async function handler( const skipContactOwner = reqBody.skipContactOwner ?? false; const contactOwnerEmail = skipContactOwner ? null : contactOwnerFromReq; - let users = await loadAndValidateUsers({ + const allHostUsers = await loadAndValidateUsers({ req, eventType, eventTypeId, @@ -325,6 +479,9 @@ async function handler( contactOwnerEmail, }); + // We filter out users but ensure allHostUsers remain same. + let users = allHostUsers; + let { locationBodyString, organizerOrFirstDynamicGroupMemberDefaultLocationUrl } = getLocationValuesForDb( dynamicUserList, users, @@ -545,6 +702,12 @@ async function handler( // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer. users = [...fixedUserPool, ...luckyUsers]; luckyUserResponse = { luckyUsers: luckyUsers.map((u) => u.id) }; + troubleshooterData = { + ...troubleshooterData, + luckyUsers: luckyUsers.map((u) => u.id), + fixedUsers: fixedUserPool.map((u) => u.id), + luckyUserPool: luckyUserPool.map((u) => u.id), + }; } else if (req.body.allRecurringDates && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { // all recurring slots except the first one const luckyUsersFromFirstBooking = luckyUsers @@ -552,6 +715,11 @@ async function handler( : []; const fixedHosts = eventTypeWithUsers.users.filter((user: IsFixedAwareUser) => user.isFixed); users = [...fixedHosts, ...luckyUsersFromFirstBooking]; + troubleshooterData = { + ...troubleshooterData, + luckyUsersFromFirstBooking: luckyUsersFromFirstBooking.map((u) => u.id), + fixedUsers: fixedHosts.map((u) => u.id), + }; } } @@ -755,10 +923,12 @@ async function handler( value: platformBookingLocation ?? bookingLocation, optionValue: "", }; + + const eventName = getEventName(eventNameObject); let evt: CalendarEvent = { bookerUrl, type: eventType.slug, - title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately + title: eventName, //this needs to be either forced in english, or fetched for each attendee and organizer separately description: eventType.description, additionalNotes, customInputs, @@ -913,6 +1083,8 @@ async function handler( email: null, }, paymentRequired: false, + isDryRun: isDryRun, + ...(isDryRun ? { troubleshooterData } : {}), }; return { ...bookingResponse, @@ -943,8 +1115,7 @@ async function handler( let results: EventResult[] = []; let referencesToCreate: PartialReference[] = []; - let booking: (Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number }) | null = - null; + let booking: CreatedBooking | null = null; loggerWithEventDetails.debug( "Going to create booking in DB now", @@ -958,73 +1129,91 @@ async function handler( ); try { - booking = await createBooking({ - uid, - rescheduledBy: reqBody.rescheduledBy, - routingFormResponseId: routingFormResponseId, - reroutingFormResponses: reroutingFormResponses ?? null, - reqBody: { - user: reqBody.user, - metadata: reqBody.metadata, - recurringEventId: reqBody.recurringEventId, - }, - eventType: { - eventTypeData: eventType, - id: eventTypeId, - slug: eventTypeSlug, - organizerUser, - isConfirmedByDefault, - paymentAppData, - }, - input: { - bookerEmail, - rescheduleReason, - changedOrganizer, - smsReminderNumber, - responses, - }, - evt, - originalRescheduledBooking, - }); - - if (booking?.userId) { - const usersRepository = new UsersRepository(); - await usersRepository.updateLastActiveAt(booking.userId); - } - - evt.uid = booking?.uid ?? null; - evt.oneTimePassword = booking?.oneTimePassword ?? null; - - if (booking && booking.id && eventType.seatsPerTimeSlot) { - const currentAttendee = booking.attendees.find( - (attendee) => - attendee.email === req.body.responses.email || - (req.body.responses.attendeePhoneNumber && - attendee.phoneNumber === req.body.responses.attendeePhoneNumber) - ); - - // Save description to bookingSeat - const uniqueAttendeeId = uuid(); - await prisma.bookingSeat.create({ - data: { - referenceUid: uniqueAttendeeId, - data: { - description: additionalNotes, - responses, - }, - booking: { - connect: { - id: booking.id, - }, - }, - attendee: { - connect: { - id: currentAttendee?.id, - }, - }, + if (!isDryRun) { + booking = await createBooking({ + uid, + rescheduledBy: reqBody.rescheduledBy, + routingFormResponseId: routingFormResponseId, + reroutingFormResponses: reroutingFormResponses ?? null, + reqBody: { + user: reqBody.user, + metadata: reqBody.metadata, + recurringEventId: reqBody.recurringEventId, }, + eventType: { + eventTypeData: eventType, + id: eventTypeId, + slug: eventTypeSlug, + organizerUser, + isConfirmedByDefault, + paymentAppData, + }, + input: { + bookerEmail, + rescheduleReason, + changedOrganizer, + smsReminderNumber, + responses, + }, + evt, + originalRescheduledBooking, }); - evt.attendeeSeatId = uniqueAttendeeId; + + if (booking?.userId) { + const usersRepository = new UsersRepository(); + await usersRepository.updateLastActiveAt(booking.userId); + } + + evt.uid = booking.uid ?? null; + evt.oneTimePassword = booking.oneTimePassword ?? null; + if (booking && booking.id && eventType.seatsPerTimeSlot) { + const currentAttendee = booking.attendees.find( + (attendee) => + attendee.email === req.body.responses.email || + (req.body.responses.attendeePhoneNumber && + attendee.phoneNumber === req.body.responses.attendeePhoneNumber) + ); + + // Save description to bookingSeat + const uniqueAttendeeId = uuid(); + await prisma.bookingSeat.create({ + data: { + referenceUid: uniqueAttendeeId, + data: { + description: additionalNotes, + responses, + }, + booking: { + connect: { + id: booking.id, + }, + }, + attendee: { + connect: { + id: currentAttendee?.id, + }, + }, + }, + }); + evt.attendeeSeatId = uniqueAttendeeId; + } + } else { + const { booking: dryRunBooking, troubleshooterData: _troubleshooterData } = buildDryRunBooking({ + eventTypeId, + organizerUser, + eventName, + startTime: reqBody.start, + endTime: reqBody.end, + contactOwnerFromReq, + contactOwnerEmail, + allHostUsers, + isManagedEventType, + }); + booking = dryRunBooking; + troubleshooterData = { + ...troubleshooterData, + ..._troubleshooterData, + }; } } catch (_err) { const err = getErrorFromUnknown(_err); @@ -1041,7 +1230,9 @@ async function handler( // After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again. const credentials = await refreshCredentials(allCredentials); - const eventManager = new EventManager({ ...organizerUser, credentials }, eventType?.metadata?.apps); + const eventManager = !isDryRun + ? new EventManager({ ...organizerUser, credentials }, eventType?.metadata?.apps) + : buildDryRunEventManager(); let videoCallUrl; @@ -1258,28 +1449,32 @@ async function handler( ) ); - sendRoundRobinRescheduledEmailsAndSMS( - copyEventAdditionalInfo, - rescheduledMembers, - eventType.metadata - ); - sendRoundRobinScheduledEmailsAndSMS({ - calEvent: copyEventAdditionalInfo, - members: newBookedMembers, - eventTypeMetadata: eventType.metadata, - }); - sendRoundRobinCancelledEmailsAndSMS(copyEventAdditionalInfo, cancelledMembers, eventType.metadata); + if (!isDryRun) { + sendRoundRobinRescheduledEmailsAndSMS( + copyEventAdditionalInfo, + rescheduledMembers, + eventType.metadata + ); + sendRoundRobinScheduledEmailsAndSMS({ + calEvent: copyEventAdditionalInfo, + members: newBookedMembers, + eventTypeMetadata: eventType.metadata, + }); + sendRoundRobinCancelledEmailsAndSMS(copyEventAdditionalInfo, cancelledMembers, eventType.metadata); + } } else { - // send normal rescheduled emails (non round robin event, where organizers stay the same) - await sendRescheduledEmailsAndSMS( - { - ...copyEvent, - additionalInformation: metadata, - additionalNotes, // Resets back to the additionalNote input and not the override value - cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email - }, - eventType?.metadata - ); + if (!isDryRun) { + // send normal rescheduled emails (non round robin event, where organizers stay the same) + await sendRescheduledEmailsAndSMS( + { + ...copyEvent, + additionalInformation: metadata, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }, + eventType?.metadata + ); + } } } // If it's not a reschedule, doesn't require confirmation and there's no price, @@ -1373,7 +1568,7 @@ async function handler( organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl; - if (evt.iCalUID !== booking.iCalUID) { + if (!isDryRun && evt.iCalUID !== booking.iCalUID) { // The eventManager could change the iCalUID. At this point we can update the DB record await prisma.booking.update({ where: { @@ -1409,18 +1604,20 @@ async function handler( }) ); - await sendScheduledEmailsAndSMS( - { - ...evt, - additionalInformation, - additionalNotes, - customInputs, - }, - eventNameObject, - isHostConfirmationEmailsDisabled, - isAttendeeConfirmationEmailDisabled, - eventType.metadata - ); + if (!isDryRun) { + await sendScheduledEmailsAndSMS( + { + ...evt, + additionalInformation, + additionalNotes, + customInputs, + }, + eventNameObject, + isHostConfirmationEmailsDisabled, + isAttendeeConfirmationEmailDisabled, + eventType.metadata + ); + } } } } else { @@ -1448,8 +1645,10 @@ async function handler( calEvent: getPiiFreeCalendarEvent(evt), }) ); - await sendOrganizerRequestEmail({ ...evt, additionalNotes }, eventType.metadata); - await sendAttendeeRequestEmailAndSMS({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata); + if (!isDryRun) { + await sendOrganizerRequestEmail({ ...evt, additionalNotes }, eventType.metadata); + await sendAttendeeRequestEmailAndSMS({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata); + } } if (booking.location?.startsWith("http")) { @@ -1558,6 +1757,8 @@ async function handler( paymentRequired: true, paymentUid: payment?.uid, paymentId: payment?.id, + isDryRun, + ...(isDryRun ? { troubleshooterData } : {}), }; } @@ -1620,7 +1821,7 @@ async function handler( } try { - if (hasHashedBookingLink && reqBody.hashedLink) { + if (hasHashedBookingLink && reqBody.hashedLink && !isDryRun) { await prisma.hashedLink.delete({ where: { link: reqBody.hashedLink as string, @@ -1634,20 +1835,22 @@ async function handler( if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" }); try { - await prisma.booking.update({ - where: { - uid: booking.uid, - }, - data: { - location: evt.location, - metadata: { ...(typeof booking.metadata === "object" && booking.metadata), ...metadata }, - references: { - createMany: { - data: referencesToCreate, + if (!isDryRun) { + await prisma.booking.update({ + where: { + uid: booking.uid, + }, + data: { + location: evt.location, + metadata: { ...(typeof booking.metadata === "object" && booking.metadata), ...metadata }, + references: { + createMany: { + data: referencesToCreate, + }, }, }, - }, - }); + }); + } } catch (error) { loggerWithEventDetails.error("Error while creating booking references", JSON.stringify({ error })); } @@ -1716,6 +1919,8 @@ async function handler( return { ...bookingResponse, ...luckyUserResponse, + isDryRun, + ...(isDryRun ? { troubleshooterData } : {}), references: referencesToCreate, seatReferenceUid: evt.attendeeSeatId, }; diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index f508894ae7..a13f3085e7 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -4,7 +4,11 @@ import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/ import { parseRecurringEvent } from "@calcom/lib"; import prisma, { userSelect } from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; -import { EventTypeMetaDataSchema, customInputSchema } from "@calcom/prisma/zod-utils"; +import { + EventTypeMetaDataSchema, + customInputSchema, + rrSegmentQueryValueSchema, +} from "@calcom/prisma/zod-utils"; export const getEventTypesFromDB = async (eventTypeId: number) => { const eventType = await prisma.eventType.findUniqueOrThrow({ @@ -153,6 +157,8 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { email: true, }, }, + assignRRMembersUsingSegment: true, + rrSegmentQueryValue: true, }, }); @@ -166,6 +172,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { customInputs: customInputSchema.array().parse(eventType?.customInputs || []), locations: (eventType?.locations ?? []) as LocationObject[], bookingFields: getBookingFieldsWithSystemFields({ ...restEventType, isOrgTeamEvent } || {}), + rrSegmentQueryValue: rrSegmentQueryValueSchema.parse(eventType.rrSegmentQueryValue) ?? null, isDynamic: false, }; }; diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts index d51bb7e683..2a2e6e1a70 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts @@ -22,7 +22,16 @@ type Users = (Awaited>[number] & { type EventType = Pick< NewBookingEventType, - "hosts" | "users" | "id" | "userId" | "schedulingType" | "maxLeadThreshold" + | "hosts" + | "users" + | "id" + | "userId" + | "schedulingType" + | "maxLeadThreshold" + | "team" + | "assignAllTeamMembers" + | "assignRRMembersUsingSegment" + | "rrSegmentQueryValue" >; type InputProps = { diff --git a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts index fe8c890663..3327602179 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts @@ -2,7 +2,11 @@ import { Prisma } from "@prisma/client"; import type { IncomingMessage } from "http"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { getRoutedUsersWithContactOwnerAndFixedUsers } from "@calcom/lib/bookings/getRoutedUsers"; +import { + getRoutedUsersWithContactOwnerAndFixedUsers, + findMatchingHostsWithEventSegment, + getNormalizedHosts, +} from "@calcom/lib/bookings/getRoutedUsers"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -14,7 +18,17 @@ import type { NewBookingEventType } from "./types"; const log = logger.getSubLogger({ prefix: ["[loadUsers]:handleNewBooking "] }); -type EventType = Pick; +type EventType = Pick< + NewBookingEventType, + | "hosts" + | "users" + | "id" + | "schedulingType" + | "team" + | "assignAllTeamMembers" + | "assignRRMembersUsingSegment" + | "rrSegmentQueryValue" +>; export const loadUsers = async ({ eventType, @@ -34,6 +48,7 @@ export const loadUsers = async ({ const users = eventType.id ? await loadUsersByEventType(eventType) : await loadDynamicUsers(dynamicUserList, currentOrgDomain); + return getRoutedUsersWithContactOwnerAndFixedUsers({ users, routedTeamMemberIds, contactOwnerEmail }); } catch (error) { log.error("Unable to load users", safeStringify(error)); @@ -45,20 +60,20 @@ export const loadUsers = async ({ }; const loadUsersByEventType = async (eventType: EventType): Promise => { - const hosts = eventType.hosts || []; - const users = hosts.map(({ user, isFixed, priority, weight, createdAt }) => ({ + const { hosts, fallbackHosts } = getNormalizedHosts({ + eventType: { ...eventType, hosts: eventType.hosts.filter(Boolean) }, + }); + const matchingHosts = await findMatchingHostsWithEventSegment({ + eventType, + normalizedHosts: hosts ?? fallbackHosts, + }); + return matchingHosts.map(({ user, isFixed, priority, weight, createdAt }) => ({ ...user, isFixed, priority, weight, createdAt, })); - return users.length - ? users - : eventType.users.map((user) => ({ - ...user, - createdAt: null, - })); }; const loadDynamicUsers = async (dynamicUserList: string[], currentOrgDomain: string | null) => { diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts index ca2408a0e8..56f5bd9776 100644 --- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts +++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts @@ -180,6 +180,12 @@ export default async function handleChildrenEventTypes({ workflows: currentWorkflowIds && { create: currentWorkflowIds.map((wfId) => ({ workflowId: wfId })), }, + /** + * RR Segment isn't applicable for managed event types. + */ + rrSegmentQueryValue: undefined, + assignRRMembersUsingSegment: false, + // Reserved for future releases /* webhooks: eventType.webhooks && { diff --git a/packages/features/eventtypes/components/AddMembersWithSwitch.tsx b/packages/features/eventtypes/components/AddMembersWithSwitch.tsx index 1fcae7f35a..0272263726 100644 --- a/packages/features/eventtypes/components/AddMembersWithSwitch.tsx +++ b/packages/features/eventtypes/components/AddMembersWithSwitch.tsx @@ -1,11 +1,15 @@ import type { ComponentProps, Dispatch, SetStateAction } from "react"; import { useFormContext } from "react-hook-form"; +import { Controller } from "react-hook-form"; import type { Options } from "react-select"; import type { FormValues, Host, TeamMember } from "@calcom/features/eventtypes/lib/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Label } from "@calcom/ui"; +import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; +import { trpc } from "@calcom/trpc"; +import { Label, SettingsToggle } from "@calcom/ui"; +import { Segment } from "../../../../apps/web/components/Segment"; import AssignAllTeamMembers from "./AssignAllTeamMembers"; import CheckedTeamSelect from "./CheckedTeamSelect"; import type { CheckedSelectOption } from "./CheckedTeamSelect"; @@ -99,7 +103,117 @@ const CheckedHostField = ({ ); }; -const AddMembersWithSwitch = ({ +function MembersSegmentWithToggle({ + teamId, + assignRRMembersUsingSegment, + setAssignRRMembersUsingSegment, + rrSegmentQueryValue, + setRrSegmentQueryValue, + className, +}: { + teamId: number; + assignRRMembersUsingSegment: boolean; + setAssignRRMembersUsingSegment: (value: boolean) => void; + rrSegmentQueryValue: AttributesQueryValue | null; + setRrSegmentQueryValue: (value: AttributesQueryValue) => void; + className?: string; +}) { + const { t } = useLocale(); + const onQueryValueChange = ({ queryValue }: { queryValue: AttributesQueryValue }) => { + setRrSegmentQueryValue(queryValue); + }; + + return ( + + name="assignRRMembersUsingSegment" + render={() => ( + { + setAssignRRMembersUsingSegment(active); + }}> + + + )} + /> + ); +} + +type AddMembersWithSwitchProps = { + teamMembers: TeamMember[]; + value: Host[]; + onChange: (hosts: Host[]) => void; + assignAllTeamMembers: boolean; + setAssignAllTeamMembers: Dispatch>; + automaticAddAllEnabled: boolean; + onActive: () => void; + isFixed: boolean; + placeholder?: string; + isRRWeightsEnabled?: boolean; + teamId: number; + isSegmentApplicable?: boolean; + "data-testid"?: string; +}; + +const enum AssignmentState { + TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE = "TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE", + TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE = "TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE", + ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE = "ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE", + ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE = "ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE", + TEAM_MEMBERS_IN_SEGMENT_ENABLED = "TEAM_MEMBERS_IN_SEGMENT_ENABLED", +} + +function getAssignmentState({ + assignAllTeamMembers, + assignRRMembersUsingSegment, + isAssigningAllTeamMembersApplicable, + isSegmentApplicable, +}: { + assignAllTeamMembers: boolean; + assignRRMembersUsingSegment: boolean; + isAssigningAllTeamMembersApplicable: boolean; + isSegmentApplicable?: boolean; +}) { + if (assignAllTeamMembers) { + return isSegmentApplicable + ? AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE + : AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE; + } + if (assignRRMembersUsingSegment && isSegmentApplicable) + return AssignmentState.TEAM_MEMBERS_IN_SEGMENT_ENABLED; + if (isAssigningAllTeamMembersApplicable) return AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE; + return AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE; +} + +function useSegmentState() { + const { getValues, setValue, watch } = useFormContext(); + const assignRRMembersUsingSegment = watch("assignRRMembersUsingSegment"); + + const setAssignRRMembersUsingSegment = (value: boolean) => + setValue("assignRRMembersUsingSegment", value, { shouldDirty: true }); + + const rrSegmentQueryValue = getValues("rrSegmentQueryValue"); + const setRrSegmentQueryValue = (value: AttributesQueryValue) => + setValue("rrSegmentQueryValue", value, { shouldDirty: true }); + + return { + assignRRMembersUsingSegment, + setAssignRRMembersUsingSegment, + rrSegmentQueryValue, + setRrSegmentQueryValue, + }; +} + +function AddMembersWithSwitch({ teamMembers, value, onChange, @@ -109,57 +223,109 @@ const AddMembersWithSwitch = ({ onActive, isFixed, placeholder = "", - containerClassName = "", isRRWeightsEnabled, + teamId, + isSegmentApplicable, ...rest -}: { - value: Host[]; - onChange: (hosts: Host[]) => void; - teamMembers: TeamMember[]; - assignAllTeamMembers: boolean; - setAssignAllTeamMembers: Dispatch>; - automaticAddAllEnabled: boolean; - onActive: () => void; - isFixed: boolean; - placeholder?: string; - containerClassName?: string; - isRRWeightsEnabled?: boolean; - "data-testid"?: string; -}) => { +}: AddMembersWithSwitchProps) { const { t } = useLocale(); const { setValue } = useFormContext(); + const { + assignRRMembersUsingSegment, + setAssignRRMembersUsingSegment, + rrSegmentQueryValue, + setRrSegmentQueryValue, + } = useSegmentState(); + const assignmentState = getAssignmentState({ + assignAllTeamMembers, + assignRRMembersUsingSegment, + isAssigningAllTeamMembersApplicable: automaticAddAllEnabled, + isSegmentApplicable, + }); + const utils = trpc.useUtils(); + + utils.viewer.appRoutingForms.getAttributesForTeam.prefetch({ + teamId, + }); + + const onAssignAllTeamMembersInactive = () => { + setValue("hosts", [], { shouldDirty: true }); + setAssignRRMembersUsingSegment(false); + }; + + switch (assignmentState) { + case AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE: + case AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE: + case AssignmentState.TEAM_MEMBERS_IN_SEGMENT_ENABLED: + return ( + <> + { + onActive(); + }} + onInactive={onAssignAllTeamMembersInactive} + /> + {assignmentState !== AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE && ( +
+ +
+ )} + + ); + + case AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE: + case AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE: + return ( + <> +
+ {assignmentState === AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE && ( + + )} +
+
+ +
+ + ); + } +} + +const AddMembersWithSwitchWrapper = ({ + containerClassName, + ...props +}: AddMembersWithSwitchProps & { + containerClassName?: string; +}) => { return (
- {automaticAddAllEnabled ? ( -
- setValue("hosts", [], { shouldDirty: true })} - /> -
- ) : ( - <> - )} - {!assignAllTeamMembers || !automaticAddAllEnabled ? ( - - ) : ( - <> - )} +
); }; -export default AddMembersWithSwitch; +export default AddMembersWithSwitchWrapper; diff --git a/packages/features/eventtypes/components/AssignAllTeamMembers.tsx b/packages/features/eventtypes/components/AssignAllTeamMembers.tsx index 8ba190b001..cb9c214670 100644 --- a/packages/features/eventtypes/components/AssignAllTeamMembers.tsx +++ b/packages/features/eventtypes/components/AssignAllTeamMembers.tsx @@ -24,6 +24,7 @@ const AssignAllTeamMembers = ({ name="assignAllTeamMembers" render={() => ( ({ + useAutoAnimate: () => [null], +})); + +// Mock Segment component +vi.mock("@calcom/web/components/Segment", () => ({ + Segment: vi.fn().mockImplementation(({ onQueryValueChange }) => ( +
+ +
+ )), +})); + +const mockTeamMembers: TeamMember[] = [ + { + value: "1", + label: "John Doe", + avatar: "avatar1.jpg", + email: "john@example.com", + defaultScheduleId: 1, + }, + { + value: "2", + label: "Jane Smith", + avatar: "avatar2.jpg", + email: "jane@example.com", + defaultScheduleId: 2, + }, +]; + +// Mock trpc +vi.mock("@calcom/trpc", () => ({ + trpc: { + useUtils: () => ({ + viewer: { + appRoutingForms: { + getAttributesForTeam: { + prefetch: vi.fn(), + }, + }, + }, + }), + }, +})); + +const renderComponent = ({ + componentProps, + formDefaultValues = { + assignRRMembersUsingSegment: false, + rrSegmentQueryValue: null, + hosts: [], + }, +}: { + componentProps: Parameters[0]; + formDefaultValues?: Record; +}) => { + const Wrapper = ({ children }: { children: React.ReactNode }) => { + const methods = useForm({ + defaultValues: formDefaultValues, + }); + const [assignAllTeamMembers, setAssignAllTeamMembers] = React.useState(false); + console.log(methods.getValues()); + return ( + + {React.cloneElement(children as React.ReactElement, { + assignAllTeamMembers, + setAssignAllTeamMembers, + })} + + ); + }; + + return render(, { wrapper: Wrapper }); +}; + +describe("AddMembersWithSwitch", () => { + const defaultProps = { + teamMembers: mockTeamMembers, + value: [] as Host[], + onChange: vi.fn(), + onActive: vi.fn(), + isFixed: false, + teamId: 1, + }; + + it("should render in TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE state", () => { + renderComponent({ + componentProps: { + ...defaultProps, + assignAllTeamMembers: false, + isSegmentApplicable: false, + automaticAddAllEnabled: false, + }, + }); + expect(screen.queryByTestId("assign-all-team-members-toggle")).not.toBeInTheDocument(); + expectManualHostListToBeThere(); + }); + + it("should render in TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE state", () => { + renderComponent({ + componentProps: { + ...defaultProps, + assignAllTeamMembers: false, + isSegmentApplicable: false, + automaticAddAllEnabled: true, + }, + }); + + expect(screen.getByTestId("assign-all-team-members-toggle")).toBeInTheDocument(); + expect(screen.getByTestId("assign-all-team-members-toggle").getAttribute("aria-checked")).toBe("false"); + expectManualHostListToBeThere(); + }); + + it("should render in ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE state", () => { + renderComponent({ + componentProps: { + ...defaultProps, + assignAllTeamMembers: true, + isSegmentApplicable: false, + }, + formDefaultValues: { + assignRRMembersUsingSegment: true, + rrSegmentQueryValue: null, + hosts: [], + }, + }); + + expect(screen.queryByTestId("assign-all-team-members-toggle")).not.toBeInTheDocument(); + expect(screen.queryByText("filter_by_attributes")).not.toBeInTheDocument(); + }); + + it("should render segment toggle in enabled state when isSegmentApplicable is true and assignRRMembersUsingSegment is also true(even if assignAllTeamMembers is false)", () => { + renderComponent({ + componentProps: { + ...defaultProps, + assignAllTeamMembers: false, + isSegmentApplicable: true, + }, + formDefaultValues: { + assignRRMembersUsingSegment: true, + rrSegmentQueryValue: null, + hosts: [], + }, + }); + + expect(screen.getByTestId("segment-toggle")).toBeInTheDocument(); + expect(screen.getByTestId("segment-toggle").getAttribute("aria-checked")).toBe("true"); + }); + + it("should call onChange when team members are selected", () => { + renderComponent({ componentProps: defaultProps }); + + const combobox = screen.getByRole("combobox"); + fireEvent.focus(combobox); + fireEvent.keyDown(combobox, { key: "ArrowDown" }); + fireEvent.click(screen.getByText("John Doe")); + + expect(defaultProps.onChange).toHaveBeenCalled(); + }); + + it("should show Segment when 'Automatically add all team members' is toggled on and then segment toggle is switched on", () => { + renderComponent({ + componentProps: { + ...defaultProps, + assignAllTeamMembers: true, + isSegmentApplicable: true, + automaticAddAllEnabled: true, + }, + formDefaultValues: { + assignRRMembersUsingSegment: false, + rrSegmentQueryValue: null, + hosts: [], + }, + }); + + expect(screen.queryByTestId("segment-toggle")).not.toBeInTheDocument(); + + act(() => { + screen.getByTestId("assign-all-team-members-toggle").click(); + }); + + expect(screen.queryByTestId("mock-segment")).not.toBeInTheDocument(); + + act(() => { + screen.getByTestId("segment-toggle").click(); + }); + + expect(screen.getByTestId("mock-segment")).toBeInTheDocument(); + }); +}); + +function expectManualHostListToBeThere() { + expect(screen.getByRole("combobox")).toBeInTheDocument(); +} diff --git a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx index ffb431e951..c7046d46ba 100644 --- a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx +++ b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -21,7 +21,12 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { SchedulingType } from "@calcom/prisma/enums"; import { Label, Select, SettingsToggle, RadioGroup as RadioArea } from "@calcom/ui"; -export type EventTeamAssignmentTabBaseProps = Pick; +export type EventTeamAssignmentTabBaseProps = Pick< + EventTypeSetupProps, + "teamMembers" | "team" | "eventType" +> & { + orgId: number | null; +}; export const mapMemberToChildrenOption = ( member: EventTypeSetupProps["teamMembers"][number], @@ -96,6 +101,7 @@ const FixedHostHelper = ( ); const FixedHosts = ({ + teamId, teamMembers, value, onChange, @@ -103,6 +109,7 @@ const FixedHosts = ({ setAssignAllTeamMembers, isRoundRobinEvent = false, }: { + teamId: number; value: Host[]; onChange: (hosts: Host[]) => void; teamMembers: TeamMember[]; @@ -127,6 +134,7 @@ const FixedHosts = ({
void; teamMembers: TeamMember[]; assignAllTeamMembers: boolean; setAssignAllTeamMembers: Dispatch>; + teamId: number; }) => { const { t } = useLocale(); const { setValue, getValues, control } = useFormContext(); - + const assignRRMembersUsingSegment = getValues("assignRRMembersUsingSegment"); const isRRWeightsEnabled = useWatch({ control, name: "isRRWeightsEnabled", @@ -239,7 +252,7 @@ const RoundRobinHosts = ({

{t("round_robin_helper")}

- {!assignAllTeamMembers && ( + {!assignAllTeamMembers && !assignRRMembersUsingSegment && ( name="isRRWeightsEnabled" render={({ field: { value, onChange } }) => ( @@ -259,11 +272,13 @@ const RoundRobinHosts = ({ /> )} >; @@ -386,6 +405,7 @@ const Hosts = ({ const schedulingTypeRender = { COLLECTIVE: ( { @@ -398,6 +418,7 @@ const Hosts = ({ ROUND_ROBIN: ( <> { @@ -408,6 +429,8 @@ const Hosts = ({ isRoundRobinEvent={true} /> { @@ -427,7 +450,12 @@ const Hosts = ({ ); }; -export const EventTeamAssignmentTab = ({ team, teamMembers, eventType }: EventTeamAssignmentTabBaseProps) => { +export const EventTeamAssignmentTab = ({ + team, + teamMembers, + eventType, + orgId, +}: EventTeamAssignmentTabBaseProps) => { const { t } = useLocale(); const schedulingTypeOptions: { @@ -469,6 +497,12 @@ export const EventTeamAssignmentTab = ({ team, teamMembers, eventType }: EventTe getValues("assignAllTeamMembers") ?? false ); + const resetRROptions = () => { + setValue("assignRRMembersUsingSegment", false, { shouldDirty: true }); + setValue("assignAllTeamMembers", false, { shouldDirty: true }); + setAssignAllTeamMembers(false); + }; + return (
{team && !isManagedEventType && ( @@ -491,8 +525,7 @@ export const EventTeamAssignmentTab = ({ team, teamMembers, eventType }: EventTe className="w-full" onChange={(val) => { onChange(val?.value); - setValue("assignAllTeamMembers", false, { shouldDirty: true }); - setAssignAllTeamMembers(false); + resetRROptions(); }} /> )} @@ -538,6 +571,8 @@ export const EventTeamAssignmentTab = ({ team, teamMembers, eventType }: EventTe
{ user: user, isFixed: true, email: user.email, + createdAt: null, })) ); diff --git a/packages/lib/bookings/findQualifiedHosts.ts b/packages/lib/bookings/findQualifiedHosts.ts index a4da0617a3..300a20db27 100644 --- a/packages/lib/bookings/findQualifiedHosts.ts +++ b/packages/lib/bookings/findQualifiedHosts.ts @@ -1,4 +1,5 @@ -import { SchedulingType } from "@calcom/prisma/enums"; +import { getNormalizedHosts } from "@calcom/lib/bookings/getRoutedUsers"; +import type { SchedulingType } from "@calcom/prisma/enums"; import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold"; @@ -7,36 +8,28 @@ export const findQualifiedHosts = async < >(eventType: { id: number; maxLeadThreshold: number | null; - hosts?: ({ isFixed: boolean; createdAt: Date } & { + hosts?: ({ isFixed: boolean; createdAt: Date; priority?: number | null; weight?: number | null } & { user: T; })[]; - users?: T[]; + users: T[]; schedulingType: SchedulingType | null; }): Promise< { isFixed: boolean; email: string; + createdAt: Date | null; + priority?: number | null; + weight?: number | null; user: T; }[] > => { - const hosts = - eventType.hosts?.length && eventType.schedulingType - ? await filterHostsByLeadThreshold({ - eventTypeId: eventType.id, - hosts: eventType.hosts.map((host) => ({ - isFixed: host.isFixed, - createdAt: host.createdAt, - email: host.user.email, - user: host.user, - })), - maxLeadThreshold: eventType.maxLeadThreshold, - }) - : (eventType.users || []).map((user) => { - return { - isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE, - email: user.email, - user: user, - }; - }); - return hosts; + const { hosts, fallbackHosts } = getNormalizedHosts({ eventType }); + const qualifiedHosts = hosts + ? await filterHostsByLeadThreshold({ + eventTypeId: eventType.id, + hosts, + maxLeadThreshold: eventType.maxLeadThreshold, + }) + : fallbackHosts; + return qualifiedHosts; }; diff --git a/packages/lib/bookings/getRoutedUsers.ts b/packages/lib/bookings/getRoutedUsers.ts index 3352a956de..55ab5d6661 100644 --- a/packages/lib/bookings/getRoutedUsers.ts +++ b/packages/lib/bookings/getRoutedUsers.ts @@ -1,5 +1,8 @@ import logger from "@calcom/lib/logger"; +import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic"; +import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { SchedulingType } from "@calcom/prisma/enums"; const log = logger.getSubLogger({ prefix: ["[getRoutedUsers]"] }); @@ -55,3 +58,121 @@ export const getRoutedUsersWithContactOwnerAndFixedUsers = < (user) => routedTeamMemberIds.includes(user.id) || user.isFixed || user.email === contactOwnerEmail ); }; + +async function findMatchingTeamMembersIdsForEventRRSegment(eventType: EventType) { + if (!eventType) { + return null; + } + + const isSegmentationDisabled = !eventType.assignAllTeamMembers || !eventType.assignRRMembersUsingSegment; + + if (isSegmentationDisabled) { + return null; + } + + if (!eventType.team) { + return null; + } + + const { teamMembersMatchingAttributeLogic } = await findTeamMembersMatchingAttributeLogic({ + attributesQueryValue: eventType.rrSegmentQueryValue ?? null, + teamId: eventType.team.id, + }); + if (!teamMembersMatchingAttributeLogic) { + return teamMembersMatchingAttributeLogic; + } + return teamMembersMatchingAttributeLogic.map((member) => member.userId); +} + +type BaseUser = { + id: number; + email: string; +}; + +type BaseHost = { + isFixed: boolean; + createdAt: Date; + priority?: number | null; + weight?: number | null; + weightAdjustment?: number | null; + user: User; +}; + +type EventType = { + assignAllTeamMembers: boolean; + assignRRMembersUsingSegment: boolean; + rrSegmentQueryValue: AttributesQueryValue | null | undefined; + team: { id: number } | null; +}; + +export function getNormalizedHosts>({ + eventType, +}: { + eventType: { + schedulingType: SchedulingType | null; + hosts?: Host[]; + users: User[]; + }; +}) { + if (eventType.hosts?.length && eventType.schedulingType) { + return { + hosts: eventType.hosts.map((host) => ({ + isFixed: host.isFixed, + email: host.user.email, + user: host.user, + priority: host.priority, + weight: host.weight, + createdAt: host.createdAt, + })), + fallbackHosts: null, + }; + } else { + return { + hosts: null, + fallbackHosts: eventType.users.map((user) => { + return { + isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE, + email: user.email, + user: user, + createdAt: null, + }; + }), + }; + } +} + +export async function findMatchingHostsWithEventSegment({ + eventType, + normalizedHosts, +}: { + eventType: EventType; + normalizedHosts: { + isFixed: boolean; + email: string; + user: User; + priority?: number | null; + weight?: number | null; + createdAt: Date | null; + }[]; +}) { + const matchingRRTeamMembers = await findMatchingTeamMembersIdsForEventRRSegment({ + ...eventType, + rrSegmentQueryValue: eventType.rrSegmentQueryValue ?? null, + }); + + const fixedHosts = normalizedHosts.filter((host) => host.isFixed); + const unsegmentedRoundRobinHosts = normalizedHosts.filter((host) => !host.isFixed); + + const segmentedRoundRobinHosts = unsegmentedRoundRobinHosts.filter((host) => { + if (!matchingRRTeamMembers) return true; + return matchingRRTeamMembers.includes(host.user.id); + }); + + // In case we don't have any matching team members, we return all the RR hosts, as we always want the team event to be bookable. + // TODO: We should notify about it to the organizer somehow. + const roundRobinHosts = segmentedRoundRobinHosts.length + ? segmentedRoundRobinHosts + : unsegmentedRoundRobinHosts; + + return [...fixedHosts, ...roundRobinHosts]; +} diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 14dfaceeea..c0a09cddde 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -105,6 +105,8 @@ const commons = { metadata: EventTypeMetaDataSchema.parse({}), bookingFields: [], assignAllTeamMembers: false, + assignRRMembersUsingSegment: false, + rrSegmentQueryValue: null, isRRWeightsEnabled: false, rescheduleWithSameRoundRobinHost: false, useEventTypeDestinationCalendarEmail: false, diff --git a/packages/lib/isEqual.ts b/packages/lib/isEqual.ts new file mode 100644 index 0000000000..ee26c774b1 --- /dev/null +++ b/packages/lib/isEqual.ts @@ -0,0 +1,26 @@ +export function isEqual(value: unknown, other: unknown): boolean { + // Handle null/undefined + if (value === other) return true; + if (value == null || other == null) return false; + + // Handle arrays + if (Array.isArray(value) && Array.isArray(other)) { + if (value.length !== other.length) return false; + return value.every((val, i) => isEqual(val, other[i])); + } + + // Handle objects + if (typeof value === "object" && typeof other === "object") { + const valueKeys = Object.keys(value); + const otherKeys = Object.keys(other); + + if (valueKeys.length !== otherKeys.length) return false; + + return valueKeys.every((key) => { + if (!Object.prototype.hasOwnProperty.call(other, key)) return false; + return isEqual((value as Record)[key], (other as Record)[key]); + }); + } + + return value === other; +} diff --git a/packages/app-store/routing-forms/lib/evaluateRaqbLogic.test.ts b/packages/lib/raqb/evaluateRaqbLogic.test.ts similarity index 93% rename from packages/app-store/routing-forms/lib/evaluateRaqbLogic.test.ts rename to packages/lib/raqb/evaluateRaqbLogic.test.ts index 540fb30bde..cc5401f7ff 100644 --- a/packages/app-store/routing-forms/lib/evaluateRaqbLogic.test.ts +++ b/packages/lib/raqb/evaluateRaqbLogic.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; -import { FormFieldsBaseConfig } from "../components/react-awesome-query-builder/config/config"; +import { FormFieldsBaseConfig } from "@calcom/routing-forms/components/react-awesome-query-builder/config/config"; + import { evaluateRaqbLogic, RaqbLogicResult } from "./evaluateRaqbLogic"; vi.mock("../components/react-awesome-query-builder/widgets", () => ({ diff --git a/packages/app-store/routing-forms/lib/evaluateRaqbLogic.ts b/packages/lib/raqb/evaluateRaqbLogic.ts similarity index 100% rename from packages/app-store/routing-forms/lib/evaluateRaqbLogic.ts rename to packages/lib/raqb/evaluateRaqbLogic.ts diff --git a/packages/app-store/routing-forms/lib/__tests__/findTeamMembersMatchingAttributeLogicOfRoute.test.ts b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts similarity index 83% rename from packages/app-store/routing-forms/lib/__tests__/findTeamMembersMatchingAttributeLogicOfRoute.test.ts rename to packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts index 092097b153..91d29c1607 100644 --- a/packages/app-store/routing-forms/lib/__tests__/findTeamMembersMatchingAttributeLogicOfRoute.test.ts +++ b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts @@ -2,16 +2,19 @@ import type { BaseWidget } from "react-awesome-query-builder"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { RouteActionType } from "@calcom/app-store/routing-forms/zod"; +import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic"; +import { + findTeamMembersMatchingAttributeLogic, + TroubleshooterCase, +} from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic"; import type { AttributeType } from "@calcom/prisma/enums"; +import { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes"; +import type { AttributesQueryValue, FormFieldsQueryValue } from "@calcom/routing-forms/types/types"; -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 "../findTeamMembersMatchingAttributeLogicOfRoute"; +import * as getAttributesModule from "./getAttributes"; -vi.mock("../../lib/getAttributes"); +vi.mock("./getAttributes"); vi.mock("../../components/react-awesome-query-builder/widgets", () => ({ default: {}, })); @@ -223,26 +226,37 @@ function buildScenarioWhereMainAttributeLogicFails() { return { failingAttributesQueryValue, matchingAttributesQueryValue }; } -describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { +describe("findTeamMembersMatchingAttributeLogic", () => { beforeEach(() => { vi.resetAllMocks(); }); it("should return null if the route does not have an attributesQueryValue set", async () => { - const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({ - form: { - fields: [], - }, - response: {}, - route: { - id: "test-route", - queryValue: { type: "group" } as unknown as FormFieldsQueryValue, - action: { type: RouteActionType.CustomPageMessage, value: "test" }, - }, - teamId: 1, + mockAttributesScenario({ + attributes: [], + teamMembersWithAttributeOptionValuePerAttribute: [], }); - expect(result).toBeNull(); + const { teamMembersMatchingAttributeLogic, troubleshooter } = await findTeamMembersMatchingAttributeLogic( + { + dynamicFieldValueOperands: { + fields: [], + response: {}, + }, + attributesQueryValue: { type: "group" } as unknown as AttributesQueryValue, + teamId: 1, + }, + { + enableTroubleshooter: true, + } + ); + + expect(teamMembersMatchingAttributeLogic).toBeNull(); + expect(troubleshooter).toEqual( + expect.objectContaining({ + type: TroubleshooterCase.MATCHES_ALL_MEMBERS_BECAUSE_OF_EMPTY_QUERY_VALUE, + }) + ); }); it("should return matching team members with a SINGLE_SELECT attribute when a static option is selected and troubleshooter should be null by default", async () => { @@ -273,19 +287,12 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { }) as AttributesQueryValue; const { teamMembersMatchingAttributeLogic: result, troubleshooter } = - await findTeamMembersMatchingAttributeLogicOfRoute({ - form: { + await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [], + response: {}, }, - response: {}, - route: { - id: "test-route", - action: { type: RouteActionType.CustomPageMessage, value: "test" }, - queryValue: { - type: "group", - } as unknown as FormFieldsQueryValue, - attributesQueryValue: attributesQueryValue, - }, + attributesQueryValue, teamId: 1, }); @@ -340,8 +347,8 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { ], }) as AttributesQueryValue; - const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({ - form: { + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [ { id: Field1Id, @@ -350,17 +357,14 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { options: [Option1OfField1], }, ], - }, - response: { - [Field1Id]: { - value: Option1OfAttribute1HumanReadableValue, - label: Option1OfAttribute1HumanReadableValue, + response: { + [Field1Id]: { + value: Option1OfAttribute1HumanReadableValue, + label: Option1OfAttribute1HumanReadableValue, + }, }, }, - route: buildDefaultCustomPageRoute({ - id: "test-route", - attributesQueryValue: attributesQueryValue, - }), + attributesQueryValue: attributesQueryValue, teamId: 1, }); @@ -419,15 +423,12 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { ], }) as AttributesQueryValue; - const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({ - form: { + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [], + response: {}, }, - response: {}, - route: buildDefaultCustomPageRoute({ - id: "test-route", - attributesQueryValue: attributesQueryValue, - }), + attributesQueryValue, teamId: 1, }); @@ -487,15 +488,12 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { ], }) as AttributesQueryValue; - const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({ - form: { + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [], + response: {}, }, - response: {}, - route: buildDefaultCustomPageRoute({ - id: "test-route", - attributesQueryValue: attributesQueryValue, - }), + attributesQueryValue, teamId: 1, }); @@ -558,15 +556,12 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { ], }) as AttributesQueryValue; - const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({ - form: { + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [], + response: {}, }, - response: {}, - route: buildDefaultCustomPageRoute({ - id: "test-route", - attributesQueryValue: attributesQueryValue, - }), + attributesQueryValue, teamId: 1, }); @@ -585,19 +580,13 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { teamMembersMatchingAttributeLogic: result, checkedFallback, troubleshooter, - } = await findTeamMembersMatchingAttributeLogicOfRoute({ - form: { + } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [], + response: {}, }, - response: {}, - route: { - id: "test-route", - action: { type: RouteActionType.CustomPageMessage, value: "test" }, - queryValue: { - type: "group", - } as unknown as FormFieldsQueryValue, - attributesQueryValue: failingAttributesQueryValue, - }, + attributesQueryValue: failingAttributesQueryValue, + fallbackAttributesQueryValue: null, teamId: 1, }); @@ -614,20 +603,13 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { teamMembersMatchingAttributeLogic: result, checkedFallback, troubleshooter, - } = await findTeamMembersMatchingAttributeLogicOfRoute({ - form: { + } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [], + response: {}, }, - response: {}, - route: { - id: "test-route", - action: { type: RouteActionType.CustomPageMessage, value: "test" }, - queryValue: { - type: "group", - } as unknown as FormFieldsQueryValue, - attributesQueryValue: failingAttributesQueryValue, - fallbackAttributesQueryValue: matchingAttributesQueryValue, - }, + attributesQueryValue: failingAttributesQueryValue, + fallbackAttributesQueryValue: matchingAttributesQueryValue, teamId: 1, }); @@ -649,21 +631,16 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { teamMembersMatchingAttributeLogic: result, checkedFallback, troubleshooter, - } = await findTeamMembersMatchingAttributeLogicOfRoute({ - form: { + } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [], + response: {}, }, - response: {}, - route: { - id: "test-route", - action: { type: RouteActionType.CustomPageMessage, value: "test" }, - queryValue: { - type: "group", - } as unknown as FormFieldsQueryValue, - attributesQueryValue: failingAttributesQueryValue, - fallbackAttributesQueryValue: failingAttributesQueryValue, - }, + attributesQueryValue: failingAttributesQueryValue, + fallbackAttributesQueryValue: failingAttributesQueryValue, teamId: 1, + attributesQueryValue: failingAttributesQueryValue, + fallbackAttributesQueryValue: failingAttributesQueryValue, }); expect(checkedFallback).toEqual(true); @@ -693,22 +670,19 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { }); await expect( - findTeamMembersMatchingAttributeLogicOfRoute({ - form: { + findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [], + response: {}, }, - response: {}, - route: buildDefaultCustomPageRoute({ - id: "test-route", - attributesQueryValue: buildSelectTypeFieldQueryValue({ - rules: [ - { - raqbFieldId: Attribute1.id, - value: [Option1OfAttribute1.id], - operator: "select_equals", - }, - ], - }) as AttributesQueryValue, + attributesQueryValue: buildSelectTypeFieldQueryValue({ + rules: [ + { + raqbFieldId: Attribute1.id, + value: [Option1OfAttribute1.id], + operator: "select_equals", + }, + ], }), teamId: 1, }) @@ -763,17 +737,15 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { }) as AttributesQueryValue; async function runInMode({ mode }: { mode: "preview" | "live" }) { - const result = await findTeamMembersMatchingAttributeLogicOfRoute({ - form: { + const result = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [], + response: {}, }, - response: {}, - route: buildDefaultCustomPageRoute({ - id: "test-route", - attributesQueryValue: attributesQueryValue, - }), + attributesQueryValue, teamId: 1, isPreview: mode === "preview" ? true : false, + fallbackAttributesQueryValue: null, }); return result; } @@ -840,19 +812,15 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { expect(attributesQueryValue.children1).toEqual({}); async function runInMode({ mode }: { mode: "preview" | "live" }) { - const { teamMembersMatchingAttributeLogic: result } = - await findTeamMembersMatchingAttributeLogicOfRoute({ - form: { - fields: [], - }, + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { + fields: [], response: {}, - route: buildDefaultCustomPageRoute({ - id: "test-route", - attributesQueryValue: attributesQueryValue, - }), - teamId: 1, - isPreview: mode === "preview" ? true : false, - }); + }, + attributesQueryValue, + teamId: 1, + isPreview: mode === "preview" ? true : false, + }); return result; } @@ -889,16 +857,13 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { }) as AttributesQueryValue; const { teamMembersMatchingAttributeLogic: result, timeTaken } = - await findTeamMembersMatchingAttributeLogicOfRoute( + await findTeamMembersMatchingAttributeLogic( { - form: { + dynamicFieldValueOperands: { fields: [], + response: {}, }, - response: {}, - route: buildDefaultCustomPageRoute({ - id: "test-route", - attributesQueryValue: attributesQueryValue, - }), + attributesQueryValue, teamId: 1, }, { @@ -996,16 +961,13 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { }) as AttributesQueryValue; const { teamMembersMatchingAttributeLogic: result, troubleshooter } = - await findTeamMembersMatchingAttributeLogicOfRoute( + await findTeamMembersMatchingAttributeLogic( { - form: { + dynamicFieldValueOperands: { fields: [], + response: {}, }, - response: {}, - route: buildDefaultCustomPageRoute({ - id: "test-route", - attributesQueryValue: attributesQueryValue, - }), + attributesQueryValue, teamId: 1, }, { diff --git a/packages/app-store/routing-forms/lib/findTeamMembersMatchingAttributeLogicOfRoute.ts b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts similarity index 74% rename from packages/app-store/routing-forms/lib/findTeamMembersMatchingAttributeLogicOfRoute.ts rename to packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts index 8bc4ea5b95..7eb4b993c3 100644 --- a/packages/app-store/routing-forms/lib/findTeamMembersMatchingAttributeLogicOfRoute.ts +++ b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts @@ -1,21 +1,19 @@ -import type { App_RoutingForms_Form } from "@prisma/client"; import async from "async"; import type { ImmutableTree, JsonLogicResult, JsonTree } from "react-awesome-query-builder"; import type { Config } from "react-awesome-query-builder/lib"; import { Utils as QbUtils } from "react-awesome-query-builder/lib"; -import { getFieldResponse } from "../trpc/utils"; -import type { Attribute, AttributesQueryValue, Route } from "../types/types"; -import type { FormResponse, SerializableForm } from "../types/types"; +import type { dynamicFieldValueOperands } from "@calcom/lib/raqb/types"; + import { RaqbLogicResult } from "./evaluateRaqbLogic"; import { getTeamMembersWithAttributeOptionValuePerAttribute, getAttributesForTeam } from "./getAttributes"; -import isRouter from "./isRouter"; import jsonLogic from "./jsonLogic"; import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "./raqbUtils"; +import type { Attribute, AttributesQueryValue } from "./types"; const { getAttributesData: getAttributes, - getAttributesQueryBuilderConfig, + getAttributesQueryBuilderConfigHavingListofLabels, getAttributesQueryValue, } = acrossQueryValueCompatiblity; @@ -24,11 +22,12 @@ type TeamMemberWithAttributeOptionValuePerAttribute = Awaited< >[number]; type RunAttributeLogicData = { - attributesQueryValue: AttributesQueryValue | undefined; - attributesForTeam: Attribute[]; - form: Pick, "fields">; - teamId: number; - response: FormResponse; + attributesQueryValue: AttributesQueryValue | null; + attributesData: { + attributesForTeam: Attribute[]; + teamMembersWithAttributeOptionValuePerAttribute: TeamMemberWithAttributeOptionValuePerAttribute[]; + }; + dynamicFieldValueOperands?: dynamicFieldValueOperands; }; type RunAttributeLogicOptions = { @@ -39,11 +38,11 @@ type RunAttributeLogicOptions = { }; export const enum TroubleshooterCase { - EMPTY_QUERY_VALUE = "empty-query-value", IS_A_ROUTER = "is-a-router", NO_LOGIC_FOUND = "no-logic-found", MATCH_RESULTS_READY = "match-results-ready", MATCH_RESULTS_READY_WITH_FALLBACK = "match-results-ready-with-fallback", + MATCHES_ALL_MEMBERS_BECAUSE_OF_EMPTY_QUERY_VALUE = "matches-all-members-because-of-empty-query-value", MATCHES_ALL_MEMBERS = "matches-all-members", } @@ -85,7 +84,7 @@ function getErrorsFromImmutableTree(tree: ImmutableTree) { const valueError = rule.properties.valueError; if (valueError) { // Sometimes there are null values in it. - errors.push(valueError.filter((value) => !!value)); + errors.push(valueError.filter(Boolean)); } }); return errors; @@ -94,21 +93,18 @@ function getErrorsFromImmutableTree(tree: ImmutableTree) { function getJsonLogic({ attributesQueryValue, attributesQueryBuilderConfig, - isPreview, }: { - attributesQueryValue: JsonTree; + attributesQueryValue: AttributesQueryValue; attributesQueryBuilderConfig: Config; - isPreview: boolean; }) { const state = { tree: QbUtils.checkTree( - QbUtils.loadTree(attributesQueryValue), - // We know that attributesQueryBuilderConfig is a Config because getAttributesQueryBuilderConfig returns a Config. So, asserting it. + QbUtils.loadTree(attributesQueryValue as JsonTree), + // We know that attributesQueryBuilderConfig is a Config because getAttributesQueryBuilderConfigHavingListofLabels returns a Config. So, asserting it. attributesQueryBuilderConfig as unknown as Config ), config: attributesQueryBuilderConfig as unknown as Config, }; - const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config); const logic = jsonLogicQuery.logic; // Considering errors as warnings as we want to continue with the flow without throwing actual errors @@ -146,11 +142,11 @@ async function getLogicResultForAllMembers( attributesQueryValue: AttributesQueryValue; }, config: { - concurrency: number; - enableTroubleshooter: boolean; - } + concurrency?: number; + enableTroubleshooter?: boolean; + } = {} ) { - const { concurrency, enableTroubleshooter } = config; + const { concurrency = 2 } = config; const teamMembersMatchingAttributeLogicMap = new Map(); const attributesDataPerUser = new Map>(); @@ -162,11 +158,7 @@ async function getLogicResultForAllMembers( attributesData: member.attributes, attributesQueryValue, }); - - if (enableTroubleshooter) { - attributesDataPerUser.set(member.userId, attributesData); - } - + attributesDataPerUser.set(member.userId, attributesData); const result = !!jsonLogic.apply(attributeJsonLogic as any, attributesData) ? RaqbLogicResult.MATCH : RaqbLogicResult.NO_MATCH; @@ -185,49 +177,41 @@ async function getLogicResultForAllMembers( } async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttributeLogicOptions) { - const { attributesQueryValue: _attributesQueryValue, attributesForTeam, form, teamId, response } = data; - const { concurrency, enablePerf, isPreview, enableTroubleshooter } = options; - const [attributesQueryValue, ttGetAttributesQueryValue] = pf(() => - getAttributesQueryValue({ - attributesQueryValue: _attributesQueryValue, - attributes: attributesForTeam, - response, - fields: form.fields, - getFieldResponse, - }) - ); + const { + attributesQueryValue: _attributesQueryValue, + attributesData: { attributesForTeam, teamMembersWithAttributeOptionValuePerAttribute }, + dynamicFieldValueOperands, + } = data; + const { concurrency, enablePerf, enableTroubleshooter } = options; + const attributesQueryValue = getAttributesQueryValue({ + attributesQueryValue: _attributesQueryValue ?? null, + attributes: attributesForTeam, + dynamicFieldValueOperands, + }); - if (raqbQueryValueUtils.isQueryValueEmpty(attributesQueryValue)) { + // TODO: Do we really need to do !attributesQueryValue check separately? + if (!attributesQueryValue || raqbQueryValueUtils.isQueryValueEmpty(attributesQueryValue as JsonTree)) { return { logicBuildingWarnings: null, teamMembersMatchingAttributeLogic: null, + timeTaken: null, ...buildTroubleshooterData({ - type: TroubleshooterCase.EMPTY_QUERY_VALUE, + type: TroubleshooterCase.MATCHES_ALL_MEMBERS_BECAUSE_OF_EMPTY_QUERY_VALUE, data: { attributesQueryValue }, }), - timeTaken: { - ttGetAttributesQueryValue, - }, }; } - const [attributesQueryBuilderConfig, ttGetAttributesQueryBuilderConfig] = pf(() => - getAttributesQueryBuilderConfig({ - form, + const [attributesQueryBuilderConfig, ttgetAttributesQueryBuilderConfigHavingListofLabels] = pf(() => + getAttributesQueryBuilderConfigHavingListofLabels({ + dynamicFieldValueOperands, attributes: attributesForTeam, - attributesQueryValue, }) ); - const [ - teamMembersWithAttributeOptionValuePerAttribute, - ttGetTeamMembersWithAttributeOptionValuePerAttribute, - ] = await aPf(() => getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: teamId })); - const { logic, warnings: logicBuildingWarnings } = getJsonLogic({ - attributesQueryValue: attributesQueryValue as JsonTree, + attributesQueryValue, attributesQueryBuilderConfig: attributesQueryBuilderConfig as unknown as Config, - isPreview: !!isPreview, }); if (!logic) { @@ -235,9 +219,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib teamMembersMatchingAttributeLogic: null, logicBuildingWarnings: null, timeTaken: { - ttGetAttributesQueryValue, - ttGetAttributesQueryBuilderConfig, - ttGetTeamMembersWithAttributeOptionValuePerAttribute, + ttgetAttributesQueryBuilderConfigHavingListofLabels, }, ...buildTroubleshooterData({ type: TroubleshooterCase.NO_LOGIC_FOUND, @@ -276,10 +258,8 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib teamMembersMatchingAttributeLogic, logicBuildingWarnings, timeTaken: { - ttGetAttributesQueryBuilderConfig, - ttGetTeamMembersWithAttributeOptionValuePerAttribute, + ttgetAttributesQueryBuilderConfigHavingListofLabels, ttTeamMembersMatchingAttributeLogic, - ttGetAttributesQueryValue, }, ...buildTroubleshooterData({ type: TroubleshooterCase.MATCH_RESULTS_READY, @@ -323,18 +303,28 @@ async function runFallbackAttributeLogic(data: RunAttributeLogicData, options: R }; } -export async function findTeamMembersMatchingAttributeLogicOfRoute( - { - form, - response, - route, - teamId, - isPreview = false, - }: { - form: Pick, "fields">; - response: FormResponse; - route: Route; +export async function getAttributesForLogic({ teamId }: { teamId: number }) { + const [[attributesForTeam, teamMembersWithAttributeOptionValuePerAttribute], ttAttributes] = + await asyncPerf(async () => { + return [ + await getAttributesForTeam({ teamId: teamId }), + await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: teamId }), + ]; + }); + + return { + attributesForTeam, + teamMembersWithAttributeOptionValuePerAttribute, + timeTaken: ttAttributes, + }; +} + +export async function findTeamMembersMatchingAttributeLogic( + data: { teamId: number; + attributesQueryValue: AttributesQueryValue | null; + fallbackAttributesQueryValue?: AttributesQueryValue | null; + dynamicFieldValueOperands?: dynamicFieldValueOperands; isPreview?: boolean; }, options: { @@ -346,40 +336,34 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute( // Higher value of concurrency might not be performant as it might overwhelm the system. So, use a lower value as default. const { enablePerf = false, concurrency = 2, enableTroubleshooter = false } = options; - const checkedFallback = false; + // Any explicit value being passed should cause fallback to be considered. Even undefined + const considerFallback = "fallbackAttributesQueryValue" in data; - if (isRouter(route)) { - return { - teamMembersMatchingAttributeLogic: null, - mainAttributeLogicBuildingWarnings: null, - fallbackAttributeLogicBuildingWarnings: null, - checkedFallback, - timeTaken: null, - ...buildTroubleshooterData({ - type: TroubleshooterCase.IS_A_ROUTER, - data: { route }, - }), - }; - } + const { teamId, attributesQueryValue, fallbackAttributesQueryValue, dynamicFieldValueOperands, isPreview } = + data; - const [attributesForTeam, getAttributesForTeamTimeTaken] = await aPf( - async () => await getAttributesForTeam({ teamId: teamId }) - ); + const { + attributesForTeam, + teamMembersWithAttributeOptionValuePerAttribute, + timeTaken: ttGetAttributesForLogic, + } = await getAttributesForLogic({ + teamId, + }); const runAttributeLogicOptions = { concurrency, enablePerf, - isPreview, + isPreview: !!isPreview, enableTroubleshooter, }; - const runAttributeLogicData = { + const runAttributeLogicData: Omit = { // Change it as per the main/fallback query - attributesQueryValue: null, - attributesForTeam, - form, - teamId, - response, + attributesData: { + attributesForTeam, + teamMembersWithAttributeOptionValuePerAttribute, + }, + dynamicFieldValueOperands, }; const { @@ -390,7 +374,7 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute( } = await runMainAttributeLogic( { ...runAttributeLogicData, - attributesQueryValue: route.attributesQueryValue, + attributesQueryValue: attributesQueryValue, }, runAttributeLogicOptions ); @@ -400,17 +384,16 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute( if (!teamMembersMatchingMainAttributeLogic) { return { teamMembersMatchingAttributeLogic: null, - checkedFallback, + checkedFallback: false, mainAttributeLogicBuildingWarnings, fallbackAttributeLogicBuildingWarnings: [], timeTaken: { ...teamMembersMatchingMainAttributeLogicTimeTaken, - getAttributesForTeamTimeTaken, + ttGetAttributesForLogic, }, ...(enableTroubleshooter ? buildTroubleshooterData({ ...troubleshooter, - type: TroubleshooterCase.MATCHES_ALL_MEMBERS, }) : null), }; @@ -418,7 +401,7 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute( const noMatchingMembersFound = !teamMembersMatchingMainAttributeLogic.length; - if (noMatchingMembersFound) { + if (noMatchingMembersFound && considerFallback) { const { teamMembersMatchingFallbackLogic, timeTaken: teamMembersMatchingFallbackLogicTimeTaken, @@ -427,7 +410,7 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute( } = await runFallbackAttributeLogic( { ...runAttributeLogicData, - attributesQueryValue: route.fallbackAttributesQueryValue, + attributesQueryValue: fallbackAttributesQueryValue ?? null, }, runAttributeLogicOptions ); @@ -439,7 +422,7 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute( mainAttributeLogicBuildingWarnings, timeTaken: { ...teamMembersMatchingFallbackLogicTimeTaken, - getAttributesForTeamTimeTaken, + ttGetAttributesForLogic, }, ...(enableTroubleshooter ? buildTroubleshooterData({ @@ -452,12 +435,12 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute( return { teamMembersMatchingAttributeLogic: teamMembersMatchingMainAttributeLogic, - checkedFallback, + checkedFallback: false, mainAttributeLogicBuildingWarnings, fallbackAttributeLogicBuildingWarnings: [], timeTaken: { ...teamMembersMatchingMainAttributeLogicTimeTaken, - getAttributesForTeamTimeTaken, + ttGetAttributesForLogic, }, ...(enableTroubleshooter ? buildTroubleshooterData({ @@ -470,11 +453,4 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute( }) : null), }; - - async function aPf(fn: () => Promise): Promise<[ReturnValue, number | null]> { - if (!enablePerf) { - return [await fn(), null]; - } - return asyncPerf(fn); - } } diff --git a/packages/app-store/routing-forms/lib/getAttributes.ts b/packages/lib/raqb/getAttributes.ts similarity index 98% rename from packages/app-store/routing-forms/lib/getAttributes.ts rename to packages/lib/raqb/getAttributes.ts index f16f70b272..6656a35b63 100644 --- a/packages/app-store/routing-forms/lib/getAttributes.ts +++ b/packages/lib/raqb/getAttributes.ts @@ -3,7 +3,7 @@ import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; -import type { Attribute } from "../types/types"; +import type { Attribute } from "./types"; async function getAttributeToUserWithMembershipAndAttributesForTeam({ teamId }: { teamId: number }) { const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] }); diff --git a/packages/app-store/routing-forms/lib/jsonLogic.test.ts b/packages/lib/raqb/jsonLogic.test.ts similarity index 90% rename from packages/app-store/routing-forms/lib/jsonLogic.test.ts rename to packages/lib/raqb/jsonLogic.test.ts index 19628df7ad..d2a6dca1b8 100644 --- a/packages/app-store/routing-forms/lib/jsonLogic.test.ts +++ b/packages/lib/raqb/jsonLogic.test.ts @@ -202,4 +202,27 @@ describe("jsonLogic", () => { ).toBe(false); }); }); + + describe("not-in operation -> Used by select_not_any_in", () => { + it("should return true if the string is not in the array using case insensitivity", () => { + expect( + jsonLogic.apply( + { + "!": { + in: [ + { + var: "8e2233c9-d108-4473-918f-ae16345af817", + }, + ["immigration"], + ], + }, + }, + // Data + { + "8e2233c9-d108-4473-918f-ae16345af817": "immigration", + } + ) + ).toBe(false); + }); + }); }); diff --git a/packages/app-store/routing-forms/lib/jsonLogic.ts b/packages/lib/raqb/jsonLogic.ts similarity index 100% rename from packages/app-store/routing-forms/lib/jsonLogic.ts rename to packages/lib/raqb/jsonLogic.ts diff --git a/packages/app-store/routing-forms/lib/raqbUtils.ts b/packages/lib/raqb/raqbUtils.ts similarity index 55% rename from packages/app-store/routing-forms/lib/raqbUtils.ts rename to packages/lib/raqb/raqbUtils.ts index 6b9d6e8705..e8aee1fe05 100644 --- a/packages/app-store/routing-forms/lib/raqbUtils.ts +++ b/packages/lib/raqb/raqbUtils.ts @@ -1,33 +1,61 @@ -import type { App_RoutingForms_Form } from "@prisma/client"; import type { JsonGroup, JsonItem, JsonRule, JsonTree } from "react-awesome-query-builder"; +import type { Config } from "react-awesome-query-builder"; +import { Utils as QbUtils } from "react-awesome-query-builder"; +import { getQueryBuilderConfigForAttributes } from "@calcom/app-store/routing-forms/lib/getQueryBuilderConfig"; +import type { LocalRoute } from "@calcom/app-store/routing-forms/types/types"; import logger from "@calcom/lib/logger"; +import type { dynamicFieldValueOperands, dynamicFieldValueOperandsResponse } from "@calcom/lib/raqb/types"; +import type { Attribute, AttributesQueryValue } from "@calcom/lib/raqb/types"; import { safeStringify } from "@calcom/lib/safeStringify"; import { AttributeType } from "@calcom/prisma/enums"; -import type { Attribute, AttributesQueryValue } from "../types/types"; -import type { LocalRoute } from "../types/types"; -import type { FormResponse, SerializableForm } from "../types/types"; -import type { SerializableField } from "../types/types"; -import type { AttributesQueryBuilderConfigWithRaqbFields } from "./getQueryBuilderConfig"; -import { getQueryBuilderConfigForAttributes } from "./getQueryBuilderConfig"; - const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/raqbUtils"] }); -type GetFieldResponse = ({ +function getFieldResponseValueAsLabel({ field, fieldResponseValue, }: { - fieldResponseValue: FormResponse[keyof FormResponse]["value"]; - field: Pick; -}) => { value: string | number | string[] }; + fieldResponseValue: dynamicFieldValueOperandsResponse[keyof dynamicFieldValueOperandsResponse]["value"]; + field: { + type: string; + options?: { + id: string | null; + label: string; + }[]; + }; +}) { + if (!field.options) { + return fieldResponseValue; + } + + const valueArray = fieldResponseValue instanceof Array ? fieldResponseValue : [fieldResponseValue]; + + const chosenOptions = valueArray.map((idOrLabel) => { + const foundOptionById = field.options?.find((option) => { + return option.id === idOrLabel; + }); + if (foundOptionById) { + return { + label: foundOptionById.label, + id: foundOptionById.id, + }; + } else { + return { + label: idOrLabel.toString(), + id: null, + }; + } + }); + + return chosenOptions.map((option) => option.label); +} 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( +function caseInsensitive( stringOrStringArray: T ): T extends string[] ? string[] : string { return ( @@ -86,6 +114,31 @@ export const raqbQueryValueUtils = { }, }; +export function buildEmptyQueryValue() { + return { id: QbUtils.uuid(), type: "group" as const }; +} + +export const buildStateFromQueryValue = ({ + queryValue, + config, +}: { + /** + * Allow null as the queryValue as initially there could be no queryValue and without that we can't build the state and can't show the UI + */ + queryValue: JsonTree | null; + config: Config; +}) => { + const queryValueToUse = queryValue || buildEmptyQueryValue(); + const immutableTree = QbUtils.checkTree(QbUtils.loadTree(queryValueToUse), config); + return { + state: { + tree: immutableTree, + config, + }, + queryValue: QbUtils.getTree(immutableTree), + }; +}; + /** * Replace attribute option Ids with the attribute option label(compatible to be matched with form field value) */ @@ -102,7 +155,7 @@ const replaceAttributeOptionIdsWithOptionLabel = ({ const attributeOptionId = attributeOption.id; queryValueString = queryValueString.replace( new RegExp(`${attributeOptionId}`, "g"), - compatibleForAttributeAndFormFieldMatch(attributeOption.value) + caseInsensitive(attributeOption.value) ); }); return queryValueString; @@ -113,15 +166,15 @@ const replaceAttributeOptionIdsWithOptionLabel = ({ */ const replaceFieldTemplateVariableWithOptionLabel = ({ queryValueString, - fields, - response, - getFieldResponse, + dynamicFieldValueOperands, }: { queryValueString: string; - fields: SerializableField[] | undefined; - response: FormResponse; - getFieldResponse: GetFieldResponse; + dynamicFieldValueOperands?: dynamicFieldValueOperands; }) => { + if (!dynamicFieldValueOperands) { + return queryValueString; + } + const { fields, response } = dynamicFieldValueOperands; return queryValueString.replace(/{field:([\w-]+)}/g, (match, fieldId: string) => { const field = fields?.find((f) => f.id === fieldId); if (!field) { @@ -132,76 +185,17 @@ const replaceFieldTemplateVariableWithOptionLabel = ({ if (!fieldResponseValue) { return match; } - const { value: fieldValue } = getFieldResponse({ field, fieldResponseValue }); - moduleLogger.debug("matchingOptionLabel", safeStringify({ fieldValue, response, fieldId })); - if (fieldValue instanceof Array && fieldValue.length > 1) { + const responseValueAsLabel = getFieldResponseValueAsLabel({ field, fieldResponseValue }); + moduleLogger.debug("matchingOptionLabel", safeStringify({ responseValueAsLabel, response, fieldId })); + if (responseValueAsLabel instanceof Array && responseValueAsLabel.length > 1) { throw new Error("Array value not supported with 'Value of field'"); } - return fieldValue ? compatibleForAttributeAndFormFieldMatch(fieldValue.toString()) : match; + return responseValueAsLabel ? caseInsensitive(responseValueAsLabel.toString()) : match; }); }; -/** - * Utilities to handle compatiblity when attribute's type changes - */ -const attributeChangeCompatibility = { - /** - * FIXME: It isn't able to handle a case where for SINGLE_SELECT attribute, the queryValue->valueType is ["multiselect"]. It happens for select_any_in operator - * So, don't use it till that is fixed. - */ - 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 - * FIXME: It isn't able to handle a case where for SINGLE_SELECT attribute, the queryValue->valueType is ["multiselect"]. It happens for select_any_in operator - * So, don't use it till that is fixed. - */ - 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< string, @@ -213,14 +207,7 @@ function getAttributesData({ attributesQueryValue: NonNullable; }) { return Object.entries(attributesData).reduce((acc, [attributeId, { value, type: attributeType }]) => { - const compatibleValueForAttributeAndFormFieldMatching = compatibleForAttributeAndFormFieldMatch(value); - - // We do this to ensure that correct jsonLogic is generated for an existing route even if the attribute's type changes - // acc[attributeId] = attributeChangeCompatibility.ensureAttributeValueToBeOfRaqbFieldValueType({ - // attributeValue: compatibleValueForAttributeAndFormFieldMatching, - // attributesQueryValue, - // attributeId, - // }); + const compatibleValueForAttributeAndFormFieldMatching = caseInsensitive(value); // Right now we can't trust ensureAttributeValueToBeOfRaqbFieldValueType to give us the correct value acc[attributeId] = @@ -237,63 +224,46 @@ function getAttributesData({ function getAttributesQueryValue({ attributesQueryValue, attributes, - response, - fields, - getFieldResponse, + dynamicFieldValueOperands, }: { - attributesQueryValue: LocalRoute["attributesQueryValue"]; + attributesQueryValue: LocalRoute["attributesQueryValue"] | null; attributes: Attribute[]; - response: FormResponse; - fields: SerializableField[] | undefined; - getFieldResponse: GetFieldResponse; + dynamicFieldValueOperands?: dynamicFieldValueOperands; }) { if (!attributesQueryValue) { return null; } - const attributesMap = attributes.reduce((acc, attribute) => { - acc[attribute.id] = attribute; - return acc; - }, {} as Record); - const attributesQueryValueCompatibleForMatchingWithFormField: AttributesQueryValue = JSON.parse( replaceFieldTemplateVariableWithOptionLabel({ queryValueString: replaceAttributeOptionIdsWithOptionLabel({ queryValueString: JSON.stringify(attributesQueryValue), attributes, }), - fields, - response, - getFieldResponse, + dynamicFieldValueOperands, }) ); return attributesQueryValueCompatibleForMatchingWithFormField; } -function getAttributesQueryBuilderConfig({ - form, +/** + * Returns attributesQueryBuilderConfig with the list of labels instead of list of ids. + */ +export function getAttributesQueryBuilderConfigHavingListofLabels({ + dynamicFieldValueOperands, attributes, - attributesQueryValue, }: { - form: Pick, "fields">; + dynamicFieldValueOperands?: dynamicFieldValueOperands; attributes: Attribute[]; - attributesQueryValue: NonNullable; }) { const attributesQueryBuilderConfig = getQueryBuilderConfigForAttributes({ attributes, - form, + dynamicOperandFields: dynamicFieldValueOperands?.fields, }); const attributesQueryBuilderConfigFieldsWithCompatibleListValues = Object.fromEntries( Object.entries(attributesQueryBuilderConfig.fields).map(([raqbFieldId, raqbField]) => { - // const raqbFieldType = attributeChangeCompatibility.getRaqbFieldTypeCompatibleWithQueryValue({ - // attributesQueryValue, - // raqbField, - // raqbFieldId, - // }); - - // Right now we can't trust getRaqbFieldTypeCompatibleWithQueryValue to give us the correct type const raqbFieldType = raqbField.type; return [ @@ -307,7 +277,7 @@ function getAttributesQueryBuilderConfig({ 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), + value: caseInsensitive(option.title), }; }), }, @@ -328,7 +298,7 @@ function getAttributesQueryBuilderConfig({ * Utilities to establish compatibility between formFieldQueryValue and attributeQueryValue */ export const acrossQueryValueCompatiblity = { - getAttributesQueryBuilderConfig, + getAttributesQueryBuilderConfigHavingListofLabels, getAttributesQueryValue, getAttributesData, }; diff --git a/packages/lib/raqb/types.d.ts b/packages/lib/raqb/types.d.ts new file mode 100644 index 0000000000..f0f1db8fa7 --- /dev/null +++ b/packages/lib/raqb/types.d.ts @@ -0,0 +1,47 @@ +import type { z } from "zod"; + +import type { AttributeType } from "@calcom/prisma/client"; + +import type { raqbChildSchema, raqbQueryValueSchema, zodAttributesQueryValue } from "./zod"; + +export type RaqbChild = z.infer; +export type RaqbQueryValue = z.infer; +export type AttributesQueryValue = z.infer; + +type dynamicFieldValueOperandsResponse = Record< + // Field ID + string, + { + value: number | string | string[]; + label: string; + } +>; + +/** + * Holds the fields that build up the 'Value of field' select options. + * It also holds the response which is the value of the 'Value of field' select options. + */ +export type dynamicFieldValueOperands = { + fields: { + id: string; + label: string; + type: string; + options?: { + id: string | null; + label: string; + }[]; + }[]; + response: dynamicFieldValueOperandsResponse; +}; + +export type Attribute = { + name: string; + slug: string; + type: AttributeType; + id: string; + options: { + id: string; + value: string; + slug: string; + }[]; +}; diff --git a/packages/app-store/routing-forms/__tests__/zod.test.ts b/packages/lib/raqb/zod.test.ts similarity index 73% rename from packages/app-store/routing-forms/__tests__/zod.test.ts rename to packages/lib/raqb/zod.test.ts index da7ca17b9f..17df1c3a4c 100644 --- a/packages/app-store/routing-forms/__tests__/zod.test.ts +++ b/packages/lib/raqb/zod.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { queryValueSaveValidationSchema } from "../zod"; +import { raqbQueryValueSchema } from "./zod"; describe("queryValueValidationSchema", () => { it("should allow a rule with value", () => { @@ -21,10 +21,37 @@ describe("queryValueValidationSchema", () => { }, }; - const result = queryValueSaveValidationSchema.safeParse(validQueryValue); + const result = raqbQueryValueSchema.safeParse(validQueryValue); expect(result.success).toBe(true); }); + it("should save valueType", () => { + const validQueryValue = { + id: "1", + type: "group", + properties: {}, + children1: { + rule1: { + type: "rule", + properties: { + field: "name", + operator: "equal", + value: ["John"], + valueSrc: ["value"], + valueType: ["select"], + }, + }, + }, + }; + + const result = raqbQueryValueSchema.safeParse(validQueryValue); + expect(result.success).toBe(true); + if (!result.success) { + throw new Error("Failed to parse query value"); + } + expect(result.data?.children1?.rule1?.properties.valueType).toEqual(["select"]); + }); + it("should allow a query value with switch_group type for queryValue", () => { const switchGroupQueryValue = { id: "2", @@ -33,29 +60,7 @@ describe("queryValueValidationSchema", () => { children1: {}, }; - const result = queryValueSaveValidationSchema.safeParse(switchGroupQueryValue); - expect(result.success).toBe(true); - }); - - it('should allow a possibly invalid query value if the rule type is not "rule" - Goal is to ensure that rule type children1 is correct', () => { - const switchGroupQueryValue = { - id: "2", - type: "switch_group", - properties: {}, - children1: { - rule1: { - type: "abc", - properties: { - field: "name", - operator: "equal", - value: [], - valueSrc: ["value"], - }, - }, - }, - }; - - const result = queryValueSaveValidationSchema.safeParse(switchGroupQueryValue); + const result = raqbQueryValueSchema.safeParse(switchGroupQueryValue); expect(result.success).toBe(true); }); @@ -67,10 +72,11 @@ describe("queryValueValidationSchema", () => { children1: {}, }; - const result = queryValueSaveValidationSchema.safeParse(invalidTypeQueryValue); + const result = raqbQueryValueSchema.safeParse(invalidTypeQueryValue); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0].path).toEqual(["type"]); + // @ts-expect-error - TODO: unionErrors is available sometimes and it is available in this case. + expect(result.error.issues[0].unionErrors[0].issues[0].path).toEqual(["type"]); } }); @@ -92,7 +98,7 @@ describe("queryValueValidationSchema", () => { }, }; - const result = queryValueSaveValidationSchema.safeParse(emptyValueQueryValue); + const result = raqbQueryValueSchema.safeParse(emptyValueQueryValue); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues[0].message).toEqual( @@ -119,7 +125,7 @@ describe("queryValueValidationSchema", () => { }, }; - const result = queryValueSaveValidationSchema.safeParse(emptyValueQueryValue); + const result = raqbQueryValueSchema.safeParse(emptyValueQueryValue); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues[0].message).toEqual( @@ -146,13 +152,8 @@ describe("queryValueValidationSchema", () => { }, }; - const result = queryValueSaveValidationSchema.safeParse(emptyValueQueryValue); + const result = raqbQueryValueSchema.safeParse(emptyValueQueryValue); expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0].message).toEqual( - "Looks like you are trying to create a rule with no value" - ); - } }); it("should allow a rule with null values", () => { @@ -173,7 +174,7 @@ describe("queryValueValidationSchema", () => { }, }; - const result = queryValueSaveValidationSchema.safeParse(nullValueQueryValue); + const result = raqbQueryValueSchema.safeParse(nullValueQueryValue); expect(result.success).toBe(true); }); @@ -195,7 +196,7 @@ describe("queryValueValidationSchema", () => { }, }; - const result = queryValueSaveValidationSchema.safeParse(nullValueQueryValue); + const result = raqbQueryValueSchema.safeParse(nullValueQueryValue); expect(result.success).toBe(true); }); @@ -205,20 +206,12 @@ describe("queryValueValidationSchema", () => { type: "group", }; - const result = queryValueSaveValidationSchema.safeParse(queryValueWithoutChildren); + const result = raqbQueryValueSchema.safeParse(queryValueWithoutChildren); expect(result.success).toBe(true); }); - it("we are fine with no queryValue even", () => { - const result = queryValueSaveValidationSchema.safeParse(null); - expect(result.success).toBe(true); - - const result2 = queryValueSaveValidationSchema.safeParse(undefined); - expect(result2.success).toBe(true); - }); - it("should allow fields with no value if valueSrc is empty", () => { - const result = queryValueSaveValidationSchema.safeParse({ + const result = raqbQueryValueSchema.safeParse({ id: "7", type: "group", children1: { @@ -237,7 +230,7 @@ describe("queryValueValidationSchema", () => { }); it("should not allow fields with no value if valueSrc is not empty", () => { - const result = queryValueSaveValidationSchema.safeParse({ + const result = raqbQueryValueSchema.safeParse({ id: "7", type: "group", children1: { diff --git a/packages/lib/raqb/zod.ts b/packages/lib/raqb/zod.ts new file mode 100644 index 0000000000..6ff60044ea --- /dev/null +++ b/packages/lib/raqb/zod.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +export const raqbChildSchema = z.object({ + type: z.union([z.literal("rule"), z.literal("rule_group")]), + properties: z.object({ + // This is RAQB field that is used in the rules. The aluealue we provide here is used as a lookup key in RAQB_CONFIG.fields + field: z.string(), + // It could be any of the operators. There are many custom operators. + operator: z.string(), + // It is the value of the 'field' chosen for comparison in the rule + // It might not be set if the operator needs nothing to compare with e.g. is_empty, is_not_empty\ + // It could be a 2D array for MultiSelect field + value: z.array(z.union([z.string(), z.array(z.string()), z.null()])).optional(), + // It is the source of the value. It might not be set if the value is coming from the field itself. + valueSrc: z + .union([z.literal("value"), z.literal("field"), z.literal("func"), z.literal("const")]) + .array() + .optional(), + // It is the error message that is shown when the value is not valid. + valueError: z.array(z.union([z.string(), z.null()])).optional(), + // Type of the value - can be text, number, boolean, date, time, datetime, select, multiselect, treeselect, treemultiselect and maybe more + // We only use a few of them in our app + valueType: z.array(z.string()).optional(), + }), +}); + +// The actual schema of children1 is quite complex, we don't want to worry about that at the moment. +const raqbChildren1Schema = z + .record(raqbChildSchema) + // .catch((ctx) => { + // console.error("Failed to parse raqbChildren1Schema:", ctx.error); + // return ctx.input; + // }) + .superRefine((children1, ctx) => { + if (!children1) return; + const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + Object.entries(children1).forEach(([, _rule]) => { + const rule = _rule as unknown; + if (!isObject(rule) || rule.type !== "rule") return; + if (!isObject(rule.properties)) return; + + const value = rule.properties.value || []; + const valueSrc = rule.properties.valueSrc; + if (!(value instanceof Array) || !(valueSrc instanceof Array)) { + return; + } + + if (!valueSrc.length) { + // If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty + return; + } + + // MultiSelect array can be 2D array + const flattenedValues = value.flat(); + + const validValues = flattenedValues.filter((value: unknown) => { + // Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field + // Also, it is possible that RAQB has some requirements to support null or empty string values. + if (value === undefined) return false; + return true; + }); + + if (!validValues.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Looks like you are trying to create a rule with no value", + }); + } + }); + }); +export const raqbQueryValueSchema = z.union([ + z.object({ + id: z.string().optional(), + type: z.literal("group"), + children1: raqbChildren1Schema.optional(), + }), + z.object({ + id: z.string().optional(), + type: z.literal("switch_group"), + children1: raqbChildren1Schema.optional(), + }), +]); + +export const zodAttributesQueryValue = raqbQueryValueSchema; diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts index 37647638f9..3d077a16c8 100644 --- a/packages/lib/server/eventTypeSelect.ts +++ b/packages/lib/server/eventTypeSelect.ts @@ -58,5 +58,7 @@ export const eventTypeSelect = Prisma.validator()({ durationLimits: true, eventTypeColor: true, hideCalendarEventDetails: true, + rrSegmentQueryValue: true, + assignRRMembersUsingSegment: true, maxLeadThreshold: true, }); diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index f949e30f8d..831cde85b2 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -1,9 +1,10 @@ import type { Prisma, User } from "@prisma/client"; -import { getFieldResponse } from "@calcom/app-store/routing-forms/trpc/utils"; import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types/types"; -import { zodRoutes, children1Schema } from "@calcom/app-store/routing-forms/zod"; +import { zodRoutes } from "@calcom/app-store/routing-forms/zod"; import logger from "@calcom/lib/logger"; +import { acrossQueryValueCompatiblity } from "@calcom/lib/raqb/raqbUtils"; +import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod"; import { safeStringify } from "@calcom/lib/safeStringify"; import { BookingRepository } from "@calcom/lib/server/repository/booking"; import prisma from "@calcom/prisma"; @@ -12,12 +13,7 @@ import type { AttributeType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; const log = logger.getSubLogger({ prefix: ["getLuckyUser"] }); -async function getAttributesQueryValue() { - const { getAttributesQueryValue } = (await import("@calcom/app-store/routing-forms/lib/raqbUtils")) - .acrossQueryValueCompatiblity; - return getAttributesQueryValue; -} - +const { getAttributesQueryValue } = acrossQueryValueCompatiblity; type PartialBooking = Pick & { attendees: { email: string | null }[]; }; @@ -791,17 +787,18 @@ async function getQueueAndAttributeWeightData route.id === routingFormResponse.chosenRouteId); if (chosenRoute && "attributesQueryValue" in chosenRoute) { - const parsedAttributesQueryValue = children1Schema.parse(chosenRoute.attributesQueryValue); + const parsedAttributesQueryValue = raqbQueryValueSchema.parse(chosenRoute.attributesQueryValue); - const attributesQueryValueWithLabel = (await getAttributesQueryValue())({ + const attributesQueryValueWithLabel = getAttributesQueryValue({ attributesQueryValue: chosenRoute.attributesQueryValue, attributes: [attributeWithWeights], - response, - fields: routingFormResponse.form.fields as Fields, - getFieldResponse: getFieldResponse, + dynamicFieldValueOperands: { + fields: (routingFormResponse.form.fields as Fields) || [], + response, + }, }); - const parsedAttributesQueryValueWithLabel = children1Schema.parse(attributesQueryValueWithLabel); + const parsedAttributesQueryValueWithLabel = raqbQueryValueSchema.parse(attributesQueryValueWithLabel); if (parsedAttributesQueryValueWithLabel && parsedAttributesQueryValueWithLabel.children1) { averageWeightsHosts = getAverageAttributeWeights( diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index f3def55a35..3246d17224 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -451,6 +451,9 @@ export async function updateNewTeamMemberEventTypes(userId: number, teamId: numb bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined, durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined, eventTypeColor: (managedEventTypeValues.eventTypeColor as Prisma.InputJsonValue) ?? undefined, + rrSegmentQueryValue: + (managedEventTypeValues.rrSegmentQueryValue as Prisma.InputJsonValue) ?? + undefined, onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false, userId, users: { diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index 3bc148fd48..f240a419c3 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -467,6 +467,8 @@ export class EventTypeRepository { onlyShowFirstAvailableSlot: true, durationLimits: true, assignAllTeamMembers: true, + assignRRMembersUsingSegment: true, + rrSegmentQueryValue: true, isRRWeightsEnabled: true, rescheduleWithSameRoundRobinHost: true, successRedirectUrl: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 46e8405b87..d90adfb41d 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -139,6 +139,8 @@ export const buildEventType = (eventType?: Partial): EventType => { secondaryEmailId: null, isRRWeightsEnabled: false, eventTypeColor: null, + assignRRMembersUsingSegment: false, + rrSegmentQueryValue: null, autoTranslateDescriptionEnabled: false, ...eventType, }; diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 62771973a4..87e8c34697 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -113,6 +113,8 @@ export const useEventTypeForm = ({ autoTranslateDescriptionEnabled: eventType.autoTranslateDescriptionEnabled, rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost, assignAllTeamMembers: eventType.assignAllTeamMembers, + assignRRMembersUsingSegment: eventType.assignRRMembersUsingSegment, + rrSegmentQueryValue: eventType.rrSegmentQueryValue, aiPhoneCallConfig: { generalPrompt: eventType.aiPhoneCallConfig?.generalPrompt ?? DEFAULT_PROMPT_VALUE, enabled: eventType.aiPhoneCallConfig?.enabled, diff --git a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx index 004d3212ee..2819a7a42b 100644 --- a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx @@ -250,7 +250,14 @@ const EventTypeWeb = ({ teamMembers={teamMembers} /> ), - team: , + team: ( + + ), limits: , advanced: ( `${NAMESPACE}.${s}`; +export type TFindTeamMembersMatchingAttributeLogicInputSchema = z.infer< + typeof ZFindTeamMembersMatchingAttributeLogicInputSchema +>; + export const attributesRouter = router({ list: authedProcedure.query(async (opts) => { const handler = await importHandler(namespaced("list"), () => import("./list.handler")); @@ -63,4 +70,14 @@ export const attributesRouter = router({ ); return handler({ ctx, input }); }), + + findTeamMembersMatchingAttributeLogic: authedProcedure + .input(ZFindTeamMembersMatchingAttributeLogicInputSchema) + .query(async ({ ctx, input }) => { + const handler = await importHandler( + namespaced("findTeamMembersMatchingAttributeLogic"), + () => import("./findTeamMembersMatchingAttributeLogic.handler") + ); + return handler({ ctx, input }); + }), }); diff --git a/packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.handler.ts b/packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.handler.ts new file mode 100644 index 0000000000..84ca226a4c --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.handler.ts @@ -0,0 +1,65 @@ +import type { ServerResponse } from "http"; +import type { NextApiResponse } from "next"; + +import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic"; +import { UserRepository } from "@calcom/lib/server/repository/user"; +import type { PrismaClient } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema"; + +interface FindTeamMembersMatchingAttributeLogicHandlerOptions { + ctx: { + prisma: PrismaClient; + user: NonNullable; + res: ServerResponse | NextApiResponse | undefined; + }; + input: TFindTeamMembersMatchingAttributeLogicInputSchema; +} + +export const findTeamMembersMatchingAttributeLogicHandler = async ({ + input, +}: FindTeamMembersMatchingAttributeLogicHandlerOptions) => { + const { teamId, attributesQueryValue, _enablePerf, _concurrency } = input; + const { + teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult, + mainAttributeLogicBuildingWarnings: mainWarnings, + fallbackAttributeLogicBuildingWarnings: fallbackWarnings, + troubleshooter, + } = await findTeamMembersMatchingAttributeLogic( + { + teamId, + attributesQueryValue, + }, + { + enablePerf: _enablePerf, + enableTroubleshooter: _enablePerf, + concurrency: _concurrency, + } + ); + + if (!matchingTeamMembersWithResult) { + return { + troubleshooter, + mainWarnings, + fallbackWarnings, + result: null, + }; + } + + const matchingTeamMembersIds = matchingTeamMembersWithResult.map((member) => member.userId); + const matchingTeamMembers = await UserRepository.findByIds({ ids: matchingTeamMembersIds }); + + return { + mainWarnings, + fallbackWarnings, + troubleshooter: troubleshooter, + result: matchingTeamMembers.map((user) => ({ + id: user.id, + name: user.name, + email: user.email, + })), + }; +}; + +export default findTeamMembersMatchingAttributeLogicHandler; diff --git a/packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.schema.ts b/packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.schema.ts new file mode 100644 index 0000000000..5aecc787d6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +import { zodAttributesQueryValue } from "@calcom/lib/raqb/zod"; + +export const ZFindTeamMembersMatchingAttributeLogicInputSchema = z.object({ + teamId: z.number(), + attributesQueryValue: zodAttributesQueryValue.nullable(), + isPreview: z.boolean().optional(), + _enablePerf: z.boolean().optional(), + _concurrency: z.number().optional(), +}); + +export type TFindTeamMembersMatchingAttributeLogicInputSchema = z.infer< + typeof ZFindTeamMembersMatchingAttributeLogicInputSchema +>; diff --git a/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts index b26b7abe08..daa1a07eaa 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts @@ -117,6 +117,9 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => { eventTypeColor: eventTypeColor ?? undefined, metadata: metadata === null ? Prisma.DbNull : metadata, bookingFields: eventType.bookingFields === null ? Prisma.DbNull : eventType.bookingFields, + rrSegmentQueryValue: + eventType.rrSegmentQueryValue === null ? Prisma.DbNull : eventType.rrSegmentQueryValue, + assignRRMembersUsingSegment: eventType.assignRRMembersUsingSegment, }; // Validate the secondary email diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index c5d38a8f3a..4dcbefb26a 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -2,7 +2,12 @@ import { z } from "zod"; import { templateTypeEnum } from "@calcom/features/ee/cal-ai-phone/zod-utils"; import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod"; -import { customInputSchema, EventTypeMetaDataSchema, stringOrNumber } from "@calcom/prisma/zod-utils"; +import { + customInputSchema, + EventTypeMetaDataSchema, + stringOrNumber, + rrSegmentQueryValueSchema, +} from "@calcom/prisma/zod-utils"; import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; const aiPhoneCallConfig = z @@ -63,6 +68,8 @@ const BaseEventTypeUpdateInput = _EventTypeModel isRRWeightsEnabled: z.boolean(), metadata: EventTypeMetaDataSchema, bookingFields: eventTypeBookingFields, + assignRRMembersUsingSegment: z.boolean().optional(), + rrSegmentQueryValue: rrSegmentQueryValueSchema.optional(), }) .partial() .extend(_EventTypeModel.pick({ id: true }).shape); diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index 9a73c89379..954be95e80 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -159,6 +159,10 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { autoTranslateDescriptionEnabled: !!(ctx.user.organizationId && autoTranslateDescriptionEnabled), bookingFields, isRRWeightsEnabled, + rrSegmentQueryValue: + rest.rrSegmentQueryValue === null + ? Prisma.DbNull + : (rest.rrSegmentQueryValue as Prisma.InputJsonValue), metadata: rest.metadata === null ? Prisma.DbNull : (rest.metadata as Prisma.InputJsonObject), eventTypeColor: eventTypeColor === null ? Prisma.DbNull : (eventTypeColor as Prisma.InputJsonObject), }; diff --git a/packages/trpc/server/routers/viewer/routing-forms/_router.ts b/packages/trpc/server/routers/viewer/routing-forms/_router.ts index 41f2f47093..7cb5e9413b 100644 --- a/packages/trpc/server/routers/viewer/routing-forms/_router.ts +++ b/packages/trpc/server/routers/viewer/routing-forms/_router.ts @@ -1,18 +1,18 @@ import authedProcedure from "../../../procedures/authedProcedure"; import { router, importHandler } from "../../../trpc"; -import { ZFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema"; +import { ZFindTeamMembersMatchingAttributeLogicOfRouteInputSchema } from "./findTeamMembersMatchingAttributeLogicOfRoute.schema"; const NAMESPACE = "routingForms"; const namespaced = (s: string) => `${NAMESPACE}.${s}`; export const routingFormsRouter = router({ - findTeamMembersMatchingAttributeLogic: authedProcedure - .input(ZFindTeamMembersMatchingAttributeLogicInputSchema) + findTeamMembersMatchingAttributeLogicOfRoute: authedProcedure + .input(ZFindTeamMembersMatchingAttributeLogicOfRouteInputSchema) .mutation(async ({ ctx, input }) => { const handler = await importHandler( - namespaced("findTeamMembersMatchingAttributeLogic"), - () => import("./findTeamMembersMatchingAttributeLogic.handler") + namespaced("findTeamMembersMatchingAttributeLogicOfRoute"), + () => import("./findTeamMembersMatchingAttributeLogicOfRoute.handler") ); return handler({ ctx, input }); }), diff --git a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.handler.ts b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts similarity index 91% rename from packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.handler.ts rename to packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts index 0e32a3bf47..ca90c9f375 100644 --- a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogic.handler.ts +++ b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts @@ -10,27 +10,27 @@ import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enr import { getUrlSearchParamsToForwardForTestPreview } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward"; import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils"; import { fromEntriesWithDuplicateKeys } from "@calcom/lib/fromEntriesWithDuplicateKeys"; +import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic"; import { getOrderedListOfLuckyUsers } from "@calcom/lib/server/getLuckyUser"; import { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; import { UserRepository } from "@calcom/lib/server/repository/user"; import type { PrismaClient } from "@calcom/prisma"; import { getAbsoluteEventTypeRedirectUrl } from "@calcom/routing-forms/getEventTypeRedirectUrl"; -import { findTeamMembersMatchingAttributeLogicOfRoute } from "@calcom/routing-forms/lib/findTeamMembersMatchingAttributeLogicOfRoute"; import { getSerializableForm } from "@calcom/routing-forms/lib/getSerializableForm"; import isRouter from "@calcom/routing-forms/lib/isRouter"; import { RouteActionType } from "@calcom/routing-forms/zod"; import { TRPCError } from "@calcom/trpc/server"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; -import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema"; +import type { TFindTeamMembersMatchingAttributeLogicOfRouteInputSchema } from "./findTeamMembersMatchingAttributeLogicOfRoute.schema"; -interface FindTeamMembersMatchingAttributeLogicHandlerOptions { +interface FindTeamMembersMatchingAttributeLogicOfRouteHandlerOptions { ctx: { prisma: PrismaClient; user: NonNullable; res: ServerResponse | NextApiResponse | undefined; }; - input: TFindTeamMembersMatchingAttributeLogicInputSchema; + input: TFindTeamMembersMatchingAttributeLogicOfRouteInputSchema; } async function getEnrichedSerializableForm< @@ -60,10 +60,10 @@ async function getEnrichedSerializableForm< return serializableForm; } -export const findTeamMembersMatchingAttributeLogicHandler = async ({ +export const findTeamMembersMatchingAttributeLogicOfRouteHandler = async ({ ctx, input, -}: FindTeamMembersMatchingAttributeLogicHandlerOptions) => { +}: FindTeamMembersMatchingAttributeLogicOfRouteHandlerOptions) => { const { prisma, user } = ctx; const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import( "@calcom/web/lib/getTeamMemberEmailFromCrm" @@ -180,11 +180,14 @@ export const findTeamMembersMatchingAttributeLogicHandler = async ({ checkedFallback, mainAttributeLogicBuildingWarnings: mainWarnings, fallbackAttributeLogicBuildingWarnings: fallbackWarnings, - } = await findTeamMembersMatchingAttributeLogicOfRoute( + } = await findTeamMembersMatchingAttributeLogic( { - response, - route, - form: serializableForm, + dynamicFieldValueOperands: { + response, + fields: serializableForm.fields || [], + }, + attributesQueryValue: route.attributesQueryValue ?? null, + fallbackAttributesQueryValue: route.fallbackAttributesQueryValue ?? null, teamId: form.teamId, isPreview: !!isPreview, }, @@ -320,4 +323,4 @@ function getServerTimingHeader(timeTaken: Record; diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index c81414782c..b615426da8 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -14,7 +14,10 @@ import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/ee/organization import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import { parseBookingLimit, parseDurationLimit } from "@calcom/lib"; import { findQualifiedHosts } from "@calcom/lib/bookings/findQualifiedHosts"; -import { getRoutedHostsWithContactOwnerAndFixedHosts } from "@calcom/lib/bookings/getRoutedUsers"; +import { + getRoutedHostsWithContactOwnerAndFixedHosts, + findMatchingHostsWithEventSegment, +} from "@calcom/lib/bookings/getRoutedUsers"; import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants"; import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; @@ -32,7 +35,7 @@ import { PeriodType, Prisma } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; -import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { EventTypeMetaDataSchema, rrSegmentQueryValueSchema } from "@calcom/prisma/zod-utils"; import type { EventBusyDate } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; @@ -181,6 +184,8 @@ export async function getEventType( rescheduleWithSameRoundRobinHost: true, periodDays: true, metadata: true, + assignRRMembersUsingSegment: true, + rrSegmentQueryValue: true, maxLeadThreshold: true, team: { select: { @@ -264,6 +269,7 @@ export async function getEventType( return { ...eventType, metadata: EventTypeMetaDataSchema.parse(eventType.metadata), + rrSegmentQueryValue: rrSegmentQueryValueSchema.parse(eventType.rrSegmentQueryValue), }; } @@ -474,13 +480,16 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise, eventType); - + const hostsAfterSegmentMatching = await findMatchingHostsWithEventSegment({ + eventType, + normalizedHosts: eventHosts, + }); const contactOwnerEmailFromInput = input.teamMemberEmail ?? null; const skipContactOwner = input.skipContactOwner; const contactOwnerEmail = skipContactOwner ? null : contactOwnerEmailFromInput; const routedTeamMemberIds = input.routedTeamMemberIds ?? null; const routedHostsWithContactOwnerAndFixedHosts = getRoutedHostsWithContactOwnerAndFixedHosts({ - hosts: eventHosts, + hosts: hostsAfterSegmentMatching, routedTeamMemberIds, contactOwnerEmail, }); @@ -767,7 +776,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise ({ + hostsAfterSegmentMatching: hostsAfterSegmentMatching.map((host) => ({ userId: host.user.id, })), }, diff --git a/packages/ui/components/form/switch/SettingsToggle.tsx b/packages/ui/components/form/switch/SettingsToggle.tsx index 1611950aaf..95e29c6117 100644 --- a/packages/ui/components/form/switch/SettingsToggle.tsx +++ b/packages/ui/components/form/switch/SettingsToggle.tsx @@ -22,6 +22,7 @@ type Props = { switchContainerClassName?: string; labelClassName?: string; descriptionClassName?: string; + noIndentation?: boolean; }; function SettingsToggle({ @@ -39,6 +40,7 @@ function SettingsToggle({ switchContainerClassName, labelClassName, descriptionClassName, + noIndentation = false, ...rest }: Props) { const [animateRef] = useAutoAnimate(); @@ -108,7 +110,7 @@ function SettingsToggle({
)} {children && ( -
+
{checked &&
{children}
}
)}