feat: Attribute filter/Segment for Round Robin hosts (#17361)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Hariom Balhara
2024-11-20 10:22:53 +05:30
committed by GitHub
co-authored by CarinaWolli
parent e3abd35ba7
commit 82e52a1948
78 changed files with 2454 additions and 1122 deletions
+173
View File
@@ -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) => (
<div className="query-builder-container" data-testid="query-builder-container">
<div className="query-builder qb-lite">
<Builder {...props} />
</div>
</div>
),
[]
);
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
<div>
<div className={cn("cal-query-builder", className)}>
<Query
{...attributesQueryBuilderConfigWithRaqbSettingsAndWidgets}
value={queryBuilderData.state.tree}
onChange={onChange}
renderBuilder={renderBuilder}
/>
</div>
<div className="mt-4 text-sm">
<MatchingTeamMembers teamId={teamId} queryValue={queryValue} />
</div>
</div>
);
}
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 <span>{t("loading")}</span>;
if (!matchingTeamMembersWithResult) return <span>{t("something_went_wrong")}</span>;
const { result: matchingTeamMembers } = matchingTeamMembersWithResult;
if (!matchingTeamMembers || !queryValue) {
return (
<div className="border-subtle bg-muted mt-4 space-y-3 rounded-md border p-4">
<div className="text-subtle flex items-center text-sm font-medium">
<span>{t("no_filter_set")}</span>
</div>
</div>
);
}
return (
<div className="border-subtle bg-muted mt-4 space-y-3 rounded-md border p-4">
<div className="text-emphasis flex items-center text-sm font-medium">
<span>{t("x_matching_members", { x: matchingTeamMembers.length })}</span>
</div>
<ul className="divide-subtle divide-y">
{matchingTeamMembers.map((member) => (
<li key={member.id} className="flex items-center py-2">
<div className="flex flex-1 items-center space-x-2 text-sm">
<span className="font-medium">{member.name}</span>
<span className="text-subtle">({member.email})</span>
</div>
</li>
))}
</ul>
</div>
);
}
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 <span>Loading...</span>;
if (!attributes) {
console.log("Error fetching attributes");
return <span>{t("something_went_wrong")}</span>;
}
return (
<SegmentWithAttributes
teamId={teamId}
attributes={attributes}
queryValue={queryValue}
onQueryValueChange={onQueryValueChange}
className={className}
/>
);
}
@@ -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(<Segment {...defaultProps} />);
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(<Segment {...defaultProps} />);
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(<Segment {...defaultProps} queryValue={queryValue} />);
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(<Segment {...defaultProps} queryValue={null} />);
await waitFor(() => {
expect(screen.getByText("no_filter_set")).toBeInTheDocument();
});
});
});
+1 -1
View File
@@ -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);
},
@@ -183,7 +183,7 @@ vi.mock("@calcom/trpc/react", () => ({
},
},
routingForms: {
findTeamMembersMatchingAttributeLogic: {
findTeamMembersMatchingAttributeLogicOfRoute: {
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn(() => {
@@ -87,8 +87,6 @@ async function createRoutingFormWithResponse({
queryValue: {
id: v4(),
type: "group",
children1: [],
properties: {},
},
id: v4(),
...route,
@@ -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",
+3
View File
@@ -483,3 +483,6 @@ select:focus {
.intercom-lightweight-app {
@apply z-40 !important;
}
@import "./raqb.css";
@@ -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: {
@@ -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<typeof eventTypeBookingFields>;
logger.settings.minLevel = 1;
-27
View File
@@ -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
@@ -98,7 +98,7 @@ vi.mock("@calcom/trpc/react", () => ({
trpc: {
viewer: {
routingForms: {
findTeamMembersMatchingAttributeLogic: {
findTeamMembersMatchingAttributeLogicOfRoute: {
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn(() => {
@@ -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<SerializableForm<App_RoutingForms_Form>, "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 (
<>
@@ -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")}
</span>
<AddMembersWithSwitch
teamId={form.teamId}
teamMembers={form.teamMembers.map((member) => ({
value: member.id.toString(),
label: member.name || member.email,
@@ -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<string, Conjunction>;
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;
@@ -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(),
};
@@ -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 <T1>(props: T1 | undefined, Component: React.FC<T1>) {
if (!props) {
return <div />;
}
return <Component {...props} />;
};
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 <div />;
}
return <TextWidget type="tel" {...props} />;
},
valuePlaceholder: "Enter Phone Number",
},
email: {
...BasicConfig.widgets.text,
factory: (props) => {
if (!props) {
return <div />;
}
return (
<EmailWidget
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
props.setValue(val);
}}
containerClassName="w-full"
className="dark:placeholder:text-darkgray-600 focus:border-brand border-subtle dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500"
{...props}
/>
);
},
},
};
return widgets;
}
function getTypes(configFor: ConfigFor) {
const 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),
};
@@ -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<Widgets[key], "factory"> };
@@ -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 <T1>(props: T1 | undefined, Component: React.FC<T1>) {
if (!props) {
return <div />;
}
return <Component {...props} />;
};
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 <div />;
}
return <TextWidget type="tel" {...props} />;
};
const EmailFactory = (props: WidgetProps | undefined) => {
if (!props) {
return <div />;
}
return (
<EmailWidget
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
props.setValue(val);
}}
containerClassName="w-full"
className="dark:placeholder:text-darkgray-600 focus:border-brand border-subtle dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500"
{...props}
/>
);
};
// 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<T extends { widgets: WidgetsWithoutFactory; settings: Settings }>({
config,
}: {
config: T;
configFor: ConfigFor;
}) {
const { settings, widgets, ...rest } = config;
return {
...rest,
settings: withRenderFnsSettings(settings),
widgets: withFactoryWidgets(widgets),
};
}
@@ -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 <field-label>' in the dropdown.
*/
dynamicOperandFields = [],
}: {
attributes: Attribute[];
form: Pick<RoutingForm, "fields">;
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] = {
@@ -28,7 +28,7 @@ export async function getSerializableForm<TForm extends App_RoutingForms_Form>({
const prisma = (await import("@calcom/prisma")).default;
const routesParsed = zodRoutes.safeParse(form.routes);
if (!routesParsed.success) {
log.error("Error parsing routes", safeStringify(routesParsed.error));
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<TForm extends App_RoutingForms_Form>({
const fields = parsedFields as NonNullable<z.infer<typeof zodFieldsView>>;
const fieldsExistInForm: Record<string, true> = {};
parsedFields?.forEach((f) => {
parsedFields.forEach((f) => {
fieldsExistInForm[f.id] = true;
});
@@ -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,
});
@@ -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,
@@ -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<SerializableRoute, GlobalRoute> => {
@@ -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 ? (
<div>
<span className="text-emphasis flex w-full items-center text-sm">
For responses matching the following criteria (matches all by default)
</span>
<Query
{...(formFieldsQueryBuilderConfig as unknown as Config)}
{...withRaqbSettingsAndWidgets({
config: formFieldsQueryBuilderConfig,
configFor: ConfigFor.FormFields,
})}
value={route.formFieldsQueryBuilderState.tree}
onChange={(immutableTree, formFieldsQueryBuilderConfig) => {
onChangeFormFieldsQuery(
@@ -370,6 +371,13 @@ const Route = ({
</div>
) : null;
const attributesQueryBuilderConfigWithRaqbSettingsAndWidgets = attributesQueryBuilderConfig
? withRaqbSettingsAndWidgets({
config: attributesQueryBuilderConfig,
configFor: ConfigFor.Attributes,
})
: null;
const attributesQueryBuilder =
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
<div className="mt-4">
@@ -391,9 +399,9 @@ const Route = ({
) : null}
<div className="mt-2">
{route.attributesQueryBuilderState && attributesQueryBuilderConfig && (
{route.attributesQueryBuilderState && attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
<Query
{...(attributesQueryBuilderConfig as unknown as Config)}
{...attributesQueryBuilderConfigWithRaqbSettingsAndWidgets}
value={route.attributesQueryBuilderState.tree}
onChange={(immutableTree, attributesQueryBuilderConfig) => {
onChangeTeamMembersQuery(
@@ -416,20 +424,21 @@ const Route = ({
{t("fallback_attribute_logic_description")}
</span>
<div className="mt-2">
{route.fallbackAttributesQueryBuilderState && attributesQueryBuilderConfig && (
<Query
{...(attributesQueryBuilderConfig as unknown as Config)}
value={route.fallbackAttributesQueryBuilderState.tree}
onChange={(immutableTree, attributesQueryBuilderConfig) => {
onChangeFallbackTeamMembersQuery(
route,
immutableTree,
attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
);
}}
renderBuilder={renderBuilder}
/>
)}
{route.fallbackAttributesQueryBuilderState &&
attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
<Query
{...attributesQueryBuilderConfigWithRaqbSettingsAndWidgets}
value={route.fallbackAttributesQueryBuilderState.tree}
onChange={(immutableTree, attributesQueryBuilderConfig) => {
onChangeFallbackTeamMembersQuery(
route,
immutableTree,
attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
);
}}
renderBuilder={renderBuilder}
/>
)}
</div>
</div>
) : 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;
@@ -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,
});
}
@@ -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;
@@ -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 = {
@@ -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) {
+5 -74
View File
@@ -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<string, unknown> =>
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
*/
@@ -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) {
@@ -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,
};
};
+324 -119
View File
@@ -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<AdditionalInformation & { url?: string; iCalUID?: string }>[] = [];
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,
};
@@ -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,
};
};
@@ -22,7 +22,16 @@ type Users = (Awaited<ReturnType<typeof loadUsers>>[number] & {
type EventType = Pick<
NewBookingEventType,
"hosts" | "users" | "id" | "userId" | "schedulingType" | "maxLeadThreshold"
| "hosts"
| "users"
| "id"
| "userId"
| "schedulingType"
| "maxLeadThreshold"
| "team"
| "assignAllTeamMembers"
| "assignRRMembersUsingSegment"
| "rrSegmentQueryValue"
>;
type InputProps = {
@@ -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<NewBookingEventType, "hosts" | "users" | "id">;
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<NewBookingEventType["users"]> => {
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) => {
@@ -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 && {
@@ -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 (
<Controller<FormValues>
name="assignRRMembersUsingSegment"
render={() => (
<SettingsToggle
noIndentation
data-testid="segment-toggle"
title={t("filter_by_attributes")}
labelClassName="mt-0.5 font-normal"
checked={assignRRMembersUsingSegment}
onCheckedChange={(active) => {
setAssignRRMembersUsingSegment(active);
}}>
<Segment
teamId={teamId}
queryValue={rrSegmentQueryValue}
onQueryValueChange={onQueryValueChange}
className={className}
/>
</SettingsToggle>
)}
/>
);
}
type AddMembersWithSwitchProps = {
teamMembers: TeamMember[];
value: Host[];
onChange: (hosts: Host[]) => void;
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
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<FormValues>();
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<SetStateAction<boolean>>;
automaticAddAllEnabled: boolean;
onActive: () => void;
isFixed: boolean;
placeholder?: string;
containerClassName?: string;
isRRWeightsEnabled?: boolean;
"data-testid"?: string;
}) => {
}: AddMembersWithSwitchProps) {
const { t } = useLocale();
const { setValue } = useFormContext<FormValues>();
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 (
<>
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={() => {
onActive();
}}
onInactive={onAssignAllTeamMembersInactive}
/>
{assignmentState !== AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE && (
<div className="mt-2">
<MembersSegmentWithToggle
teamId={teamId}
assignRRMembersUsingSegment={assignRRMembersUsingSegment}
setAssignRRMembersUsingSegment={setAssignRRMembersUsingSegment}
rrSegmentQueryValue={rrSegmentQueryValue}
setRrSegmentQueryValue={setRrSegmentQueryValue}
/>
</div>
)}
</>
);
case AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE:
case AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE:
return (
<>
<div className="mb-2">
{assignmentState === AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE && (
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={onActive}
onInactive={onAssignAllTeamMembersInactive}
/>
)}
</div>
<div className="mb-2">
<CheckedHostField
data-testid={rest["data-testid"]}
value={value}
onChange={onChange}
isFixed={isFixed}
className="mb-2"
options={teamMembers.sort(sortByLabel)}
placeholder={placeholder ?? t("add_attendees")}
isRRWeightsEnabled={isRRWeightsEnabled}
/>
</div>
</>
);
}
}
const AddMembersWithSwitchWrapper = ({
containerClassName,
...props
}: AddMembersWithSwitchProps & {
containerClassName?: string;
}) => {
return (
<div className="rounded-md ">
<div className={`flex flex-col rounded-md pb-2 pt-6 ${containerClassName}`}>
{automaticAddAllEnabled ? (
<div className="mb-2">
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={onActive}
onInactive={() => setValue("hosts", [], { shouldDirty: true })}
/>
</div>
) : (
<></>
)}
{!assignAllTeamMembers || !automaticAddAllEnabled ? (
<CheckedHostField
data-testid={rest["data-testid"]}
value={value}
onChange={onChange}
isFixed={isFixed}
options={teamMembers.sort(sortByLabel)}
placeholder={placeholder ?? t("add_attendees")}
isRRWeightsEnabled={isRRWeightsEnabled}
/>
) : (
<></>
)}
<AddMembersWithSwitch {...props} />
</div>
</div>
);
};
export default AddMembersWithSwitch;
export default AddMembersWithSwitchWrapper;
@@ -24,6 +24,7 @@ const AssignAllTeamMembers = ({
name="assignAllTeamMembers"
render={() => (
<SettingsToggle
data-testid="assign-all-team-members-toggle"
title={t("automatically_add_all_team_members")}
labelClassName="mt-0.5 font-normal"
checked={assignAllTeamMembers}
@@ -0,0 +1,203 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import * as React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it, vi } from "vitest";
import type { Host, TeamMember } from "../../lib/types";
import AddMembersWithSwitch from "../AddMembersWithSwitch";
// Mock matchMedia
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
// Mock Segment component
vi.mock("@calcom/web/components/Segment", () => ({
Segment: vi.fn().mockImplementation(({ onQueryValueChange }) => (
<div data-testid="mock-segment">
<button onClick={() => onQueryValueChange({ queryValue: { id: "test" } })}>Update Query</button>
</div>
)),
}));
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<typeof AddMembersWithSwitch>[0];
formDefaultValues?: Record<string, unknown>;
}) => {
const Wrapper = ({ children }: { children: React.ReactNode }) => {
const methods = useForm({
defaultValues: formDefaultValues,
});
const [assignAllTeamMembers, setAssignAllTeamMembers] = React.useState(false);
console.log(methods.getValues());
return (
<FormProvider {...methods}>
{React.cloneElement(children as React.ReactElement, {
assignAllTeamMembers,
setAssignAllTeamMembers,
})}
</FormProvider>
);
};
return render(<AddMembersWithSwitch {...componentProps} />, { 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();
}
@@ -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<EventTypeSetupProps, "teamMembers" | "team" | "eventType">;
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 = ({
</div>
<div className="border-subtle rounded-b-md border border-t-0 px-6">
<AddMembersWithSwitch
teamId={teamId}
teamMembers={teamMembers}
value={value}
onChange={onChange}
@@ -177,6 +185,7 @@ const FixedHosts = ({
<div className="border-subtle flex flex-col gap-6 rounded-bl-md rounded-br-md border border-t-0 px-6">
<AddMembersWithSwitch
data-testid="fixed-hosts-select"
teamId={teamId}
teamMembers={teamMembers}
value={value}
onChange={onChange}
@@ -211,22 +220,26 @@ const FixedHosts = ({
};
const RoundRobinHosts = ({
orgId,
teamMembers,
value,
onChange,
assignAllTeamMembers,
setAssignAllTeamMembers,
teamId,
}: {
orgId: number | null;
value: Host[];
onChange: (hosts: Host[]) => void;
teamMembers: TeamMember[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
teamId: number;
}) => {
const { t } = useLocale();
const { setValue, getValues, control } = useFormContext<FormValues>();
const assignRRMembersUsingSegment = getValues("assignRRMembersUsingSegment");
const isRRWeightsEnabled = useWatch({
control,
name: "isRRWeightsEnabled",
@@ -239,7 +252,7 @@ const RoundRobinHosts = ({
<p className="text-subtle max-w-full break-words text-sm leading-tight">{t("round_robin_helper")}</p>
</div>
<div className="border-subtle rounded-b-md border border-t-0 px-6 pt-4">
{!assignAllTeamMembers && (
{!assignAllTeamMembers && !assignRRMembersUsingSegment && (
<Controller<FormValues>
name="isRRWeightsEnabled"
render={({ field: { value, onChange } }) => (
@@ -259,11 +272,13 @@ const RoundRobinHosts = ({
/>
)}
<AddMembersWithSwitch
teamId={teamId}
teamMembers={teamMembers}
value={value}
onChange={onChange}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
isSegmentApplicable={!!orgId}
automaticAddAllEnabled={true}
isRRWeightsEnabled={isRRWeightsEnabled}
isFixed={false}
@@ -327,10 +342,14 @@ const ChildrenEventTypes = ({
};
const Hosts = ({
orgId,
teamId,
teamMembers,
assignAllTeamMembers,
setAssignAllTeamMembers,
}: {
orgId: number | null;
teamId: number;
teamMembers: TeamMember[];
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
@@ -386,6 +405,7 @@ const Hosts = ({
const schedulingTypeRender = {
COLLECTIVE: (
<FixedHosts
teamId={teamId}
teamMembers={teamMembers}
value={value}
onChange={(changeValue) => {
@@ -398,6 +418,7 @@ const Hosts = ({
ROUND_ROBIN: (
<>
<FixedHosts
teamId={teamId}
teamMembers={teamMembers}
value={value}
onChange={(changeValue) => {
@@ -408,6 +429,8 @@ const Hosts = ({
isRoundRobinEvent={true}
/>
<RoundRobinHosts
orgId={orgId}
teamId={teamId}
teamMembers={teamMembers}
value={value}
onChange={(changeValue) => {
@@ -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 (
<div>
{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
</div>
</div>
<Hosts
orgId={orgId}
teamId={team.id}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
teamMembers={teamMembersOptions}
+3 -1
View File
@@ -10,7 +10,7 @@ import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { eventTypeColor } from "@calcom/prisma/zod-utils";
import type { RouterOutputs, RouterInputs } from "@calcom/trpc/react";
import type { IntervalLimit, RecurringEvent } from "@calcom/types/Calendar";
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
export type CustomInputParsed = typeof customInputSchema._output;
export type AvailabilityOption = {
@@ -136,6 +136,8 @@ export type FormValues = {
multipleDurationEnabled: boolean;
users: EventTypeSetup["users"];
assignAllTeamMembers: boolean;
assignRRMembersUsingSegment: boolean;
rrSegmentQueryValue: AttributesQueryValue | null;
rescheduleWithSameRoundRobinHost: boolean;
useEventTypeDestinationCalendarEmail: boolean;
forwardParamsSuccessRedirect: boolean | null;
@@ -85,6 +85,7 @@ describe("findQualifiedHosts", async () => {
user: user,
isFixed: true,
email: user.email,
createdAt: null,
}))
);
+16 -23
View File
@@ -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;
};
+121
View File
@@ -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<User extends BaseUser> = {
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<User extends BaseUser, Host extends BaseHost<User>>({
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<User extends BaseUser>({
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];
}
+2
View File
@@ -105,6 +105,8 @@ const commons = {
metadata: EventTypeMetaDataSchema.parse({}),
bookingFields: [],
assignAllTeamMembers: false,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
rescheduleWithSameRoundRobinHost: false,
useEventTypeDestinationCalendarEmail: false,
+26
View File
@@ -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<string, unknown>)[key], (other as Record<string, unknown>)[key]);
});
}
return value === other;
}
@@ -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", () => ({
@@ -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,
},
{
@@ -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<SerializableForm<App_RoutingForms_Form>, "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<number, RaqbLogicResult>();
const attributesDataPerUser = new Map<number, ReturnType<typeof getAttributes>>();
@@ -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<SerializableForm<App_RoutingForms_Form>, "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<RunAttributeLogicData, "attributesQueryValue"> = {
// 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<ReturnValue>(fn: () => Promise<ReturnValue>): Promise<[ReturnValue, number | null]> {
if (!enablePerf) {
return [await fn(), null];
}
return asyncPerf(fn);
}
}
@@ -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"] });
@@ -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);
});
});
});
@@ -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<SerializableField, "type" | "options">;
}) => { 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<T extends string | string[]>(
function caseInsensitive<T extends string | string[]>(
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<LocalRoute["attributesQueryValue"]>;
raqbField: AttributesQueryBuilderConfigWithRaqbFields["fields"][string];
raqbFieldId: string;
}) {
const fieldTypeFromAttributesQueryValue =
raqbQueryValueUtils.getValueTypeFromAttributesQueryValueForRaqbField({
attributesQueryValue,
raqbFieldId,
});
if (raqbField.type === "select" && fieldTypeFromAttributesQueryValue === "multiselect") {
// One could argue why not use fieldTypeFromAttributesQueryValue directly. This could potentially cause issues, so we do this only when we think it makes sense.
moduleLogger.warn(
`Switching field type from ${raqbField.type} to multiselect as the queryValue expects it to be so`
);
return "multiselect";
}
return raqbField.type;
},
/**
* Ensure the attribute value if of type same as the valueType in queryValue
* 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<LocalRoute["attributesQueryValue"]>;
attributeId: string;
}) {
const fieldTypeFromAttributesQueryValue =
raqbQueryValueUtils.getValueTypeFromAttributesQueryValueForRaqbField({
attributesQueryValue,
raqbFieldId: attributeId,
});
if (fieldTypeFromAttributesQueryValue === "multiselect") {
return ensureArray(attributeValue);
}
return attributeValue;
},
};
function getAttributesData({
attributesData,
attributesQueryValue,
}: {
attributesData: Record<
string,
@@ -213,14 +207,7 @@ function getAttributesData({
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
}) {
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<string, Attribute>);
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<SerializableForm<App_RoutingForms_Form>, "fields">;
dynamicFieldValueOperands?: dynamicFieldValueOperands;
attributes: Attribute[];
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
}) {
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,
};
+47
View File
@@ -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<typeof raqbChildSchema>;
export type RaqbQueryValue = z.infer<typeof raqbQueryValueSchema>;
export type AttributesQueryValue = z.infer<typeof zodAttributesQueryValue>;
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;
}[];
};
@@ -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: {
+85
View File
@@ -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<string, unknown> =>
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;
+2
View File
@@ -58,5 +58,7 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
durationLimits: true,
eventTypeColor: true,
hideCalendarEventDetails: true,
rrSegmentQueryValue: true,
assignRRMembersUsingSegment: true,
maxLeadThreshold: true,
});
+11 -14
View File
@@ -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<Booking, "id" | "createdAt" | "userId" | "status"> & {
attendees: { email: string | null }[];
};
@@ -791,17 +787,18 @@ async function getQueueAndAttributeWeightData<T extends PartialUser & { priority
const chosenRoute = routes?.find((route) => 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(
@@ -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: {
@@ -467,6 +467,8 @@ export class EventTypeRepository {
onlyShowFirstAvailableSlot: true,
durationLimits: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: true,
rrSegmentQueryValue: true,
isRRWeightsEnabled: true,
rescheduleWithSameRoundRobinHost: true,
successRedirectUrl: true,
+2
View File
@@ -139,6 +139,8 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
secondaryEmailId: null,
isRRWeightsEnabled: false,
eventTypeColor: null,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
autoTranslateDescriptionEnabled: false,
...eventType,
};
@@ -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,
@@ -250,7 +250,14 @@ const EventTypeWeb = ({
teamMembers={teamMembers}
/>
),
team: <EventTeamAssignmentTab teamMembers={teamMembers} team={team} eventType={eventType} />,
team: (
<EventTeamAssignmentTab
orgId={orgBranding?.id ?? null}
teamMembers={teamMembers}
team={team}
eventType={eventType}
/>
),
limits: <EventLimitsTab eventType={eventType} />,
advanced: (
<EventAdvancedTab
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "assignRRMembersUsingSegment" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "rrSegmentQueryValue" JSONB;
+4
View File
@@ -148,6 +148,10 @@ model EventType {
instantMeetingSchedule Schedule? @relation("InstantMeetingSchedule", fields: [instantMeetingScheduleId], references: [id])
instantMeetingParameters String[]
assignAllTeamMembers Boolean @default(false)
// It is applicable only when assignAllTeamMembers is true and it filters out all the team members using rrSegmentQueryValue
assignRRMembersUsingSegment Boolean @default(false)
/// @zod.custom(imports.rrSegmentQueryValueSchema)
rrSegmentQueryValue Json?
useEventTypeDestinationCalendarEmail Boolean @default(false)
aiPhoneCallConfig AIPhoneCallConfiguration?
isRRWeightsEnabled Boolean @default(false)
+7
View File
@@ -20,6 +20,7 @@ import type { FieldType as FormBuilderFieldType } from "@calcom/features/form-bu
import { fieldsSchema as formBuilderFieldsSchema } from "@calcom/features/form-builder/schema";
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
import { emailSchema as emailRegexSchema, emailRegex } from "@calcom/lib/emailSchema";
import { zodAttributesQueryValue } from "@calcom/lib/raqb/zod";
import { slugify } from "@calcom/lib/slugify";
import { EventTypeCustomInputType } from "@calcom/prisma/enums";
@@ -258,6 +259,10 @@ export const bookingCreateBodySchema = z.object({
* Holds the corrected responses of the Form for a booking, provided during rerouting
*/
reroutingFormResponses: routingFormResponseInDbSchema.optional(),
/**
* Used to identify if the booking is a dry run.
*/
_isDryRun: z.boolean().optional(),
});
export const requiredCustomInputSchema = z.union([
@@ -757,3 +762,5 @@ export const bookingSeatDataSchema = z.object({
description: z.string().optional(),
responses: bookingResponses,
});
export const rrSegmentQueryValueSchema = zodAttributesQueryValue.nullish();
@@ -1,3 +1,5 @@
import type { z } from "zod";
import authedProcedure, { authedOrgAdminProcedure } from "../../../procedures/authedProcedure";
import { router, importHandler } from "../../../trpc";
import { assignUserToAttributeSchema } from "./assignUserToAttribute.schema";
@@ -5,6 +7,7 @@ import { bulkAssignAttributesSchema } from "./bulkAssignAttributes.schema";
import { createAttributeSchema } from "./create.schema";
import { deleteAttributeSchema } from "./delete.schema";
import { editAttributeSchema } from "./edit.schema";
import { ZFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema";
import { getAttributeSchema } from "./get.schema";
import { getByUserIdSchema } from "./getByUserId.schema";
import { toggleActiveSchema } from "./toggleActive.schema";
@@ -13,6 +16,10 @@ const NAMESPACE = "attributes";
const namespaced = (s: string) => `${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 });
}),
});
@@ -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<TrpcSessionUser>;
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;
@@ -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
>;
@@ -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
@@ -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);
@@ -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),
};
@@ -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 });
}),
@@ -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<TrpcSessionUser>;
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<string, number | null | undefin
return headerParts.join(", ");
}
export default findTeamMembersMatchingAttributeLogicHandler;
export default findTeamMembersMatchingAttributeLogicOfRouteHandler;
@@ -2,7 +2,7 @@ import { z } from "zod";
import { zodNonRouterRoute } from "@calcom/routing-forms/zod";
export const ZFindTeamMembersMatchingAttributeLogicInputSchema = z.object({
export const ZFindTeamMembersMatchingAttributeLogicOfRouteInputSchema = z.object({
formId: z.string(),
response: z.record(
z.object({
@@ -16,6 +16,6 @@ export const ZFindTeamMembersMatchingAttributeLogicInputSchema = z.object({
_concurrency: z.number().optional(),
});
export type TFindTeamMembersMatchingAttributeLogicInputSchema = z.infer<
typeof ZFindTeamMembersMatchingAttributeLogicInputSchema
export type TFindTeamMembersMatchingAttributeLogicOfRouteInputSchema = z.infer<
typeof ZFindTeamMembersMatchingAttributeLogicOfRouteInputSchema
>;
@@ -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<I
}
const eventHosts = await monitorCallbackAsync(findQualifiedHosts<GetAvailabilityUser>, 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<I
userId: user.id,
};
}),
hosts: eventHosts.map((host) => ({
hostsAfterSegmentMatching: hostsAfterSegmentMatching.map((host) => ({
userId: host.user.id,
})),
},
@@ -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<HTMLDivElement>();
@@ -108,7 +110,7 @@ function SettingsToggle({
</div>
)}
{children && (
<div className={classNames("lg:ml-14", childrenClassName)} ref={animateRef}>
<div className={classNames(noIndentation ? "" : "lg:ml-14", childrenClassName)} ref={animateRef}>
{checked && <div className={classNames(!toggleSwitchAtTheEnd && "mt-4")}>{children}</div>}
</div>
)}