feat: add fallback action options to routing form fallback section (#27449)
* feat: add no users found fallback action for routing forms - Add NoUsersFoundFallbackActionType enum with CustomPageMessage and ExternalRedirectUrl options - Add noUsersFoundFallbackAction field to zodNonRouterRoute schema - Add NoUsersFoundFallback UI component in RouteBuilder for configuring fallback action - Update handleResponse to return noUsersFoundFallbackAction when no team members match - Add i18n translation keys for new UI strings Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * refactor: change fallback to use same action options as main route - Replace noUsersFoundFallbackAction with fallbackAction that has same structure as main action - Add action dropdown and input fields to fallback attributes query builder section - Support Event redirect, External URL, and Custom page options in fallback - Remove separate NoUsersFoundFallback toggle component - Update handleResponse to return fallbackAction Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: ensure fallbackAction type is always defined in onChange handlers Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add guards for route.fallbackAction in onChange handlers Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: only return fallbackAction when no users are found Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * refactor: restructure fallback section to mirror main route structure Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: ensure backwards compatibility for existing routes with fallbackAttributesQueryValue Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: initialize fallbackAction with main event type for backwards compatibility Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: include fallbackAction in getRoutesToSave to persist changes Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: use fallbackAction in getRoutedUrl when no team members found Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: use i18n for fallback label and add tests for fallback action Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: do not trigger fallback action when CRM contact owner is found Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: prioritize fallbackAction over fallbackAttributesQueryValue When a route has a fallbackAction configured, skip the fallbackAttributesQueryValue to ensure the fallbackAction is triggered when no team members are found. This maintains backwards compatibility by only using fallbackAttributesQueryValue when fallbackAction is not set. Also treats teamMemberIdsMatchingAttributeLogic being null (routing couldn't run, e.g., missing orgId) the same as an empty array for the purpose of triggering the fallbackAction. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract RouteActionSelector shared component for action selection UI Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add i18n string for default custom page message Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat: add webhook trigger when routing form fallback route is hit - Add ROUTING_FORM_FALLBACK_HIT to WebhookTriggerEvents enum in Prisma schema - Add new trigger to routing-forms webhook triggers in constants - Add translation string for the new webhook trigger - Implement triggerFallbackWebhook function in formSubmissionUtils.ts - Call webhook trigger from handleResponse.ts when fallback action is used Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: remove webhook object from error log to avoid exposing secrets Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * revert: remove webhook trigger changes (to be moved to separate PR) Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: only show fallback query builder when fallbackAction is explicitly set The fallback attributes query builder was being shown for new routes when fallbackAction was not set, causing an extra 'Add rule' button to appear. This broke e2e tests that relied on the button index. Now the fallback query builder is only shown when fallbackAction is explicitly set to EventTypeRedirectUrl, which happens after the route is saved and reloaded (via auto-initialization in useRoutes). Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: use data-testid for attribute query builder selectors in e2e tests - Add data-testid='attributes-query-builder' to the main attributes query builder div - Update addAttributeRoutingRule and addAttributeRoutingRuleWithOperator to use the data-testid selector instead of nth() indices - This makes the tests more robust and independent of DOM structure changes Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: initialize fallbackAction for new routes in useCreateRoute hook - New routes created via useCreateRoute now initialize fallbackAction - This ensures new routes have a valid fallbackAction when saved - Matches the initialization logic for existing routes in useRoutes Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: initialize fallbackAction at save time when action is EventTypeRedirectUrl - When saving routes, if fallbackAction is undefined but action is EventTypeRedirectUrl, initialize fallbackAction from the main action - This handles the case where user creates a new route and changes action to an event type - Fixes e2e test failures where form save was failing due to undefined fallbackAction Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: initialize fallbackAction when main action changes to EventTypeRedirectUrl Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: only show fallback section when main action has valid event type selected Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: check for valid fallbackAction event type before preserving existing value Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Give more structure to main and fallback routes * Update comment * Rename fallbackAttributesQueryBuilder to matchingMembersFallbackRoute * Display custom event redirect input * fix: address Cubic AI review feedback for routing form fallback - Simplify getFallbackAction logic to use checkedFallback flag instead of manually parsing attributesQueryValue (confidence 9/10) - Add i18n translation for 'And connect with specific team members' string using t('connect_with_specific_team_members') (confidence 9/10) Co-Authored-By: unknown <> * fix: update getFallbackAction to use checkedFallback and handle missing orgId case - Use checkedFallback flag as primary indicator for triggering fallback action - Also handle edge case where attribute routing was configured but couldn't run due to missing orgId (teamMemberIdsMatchingAttributeLogic is null) - Update test to mock checkedFallback: true when expecting fallback action Co-Authored-By: unknown <> * Address UI comment * fix: Use fallbackAttributesQueryValue when fallback action is event redirect Only use fallbackAttributesQueryValue when: 1. fallbackAction is EventTypeRedirectUrl (attribute routing applies) 2. fallbackAction is not set (backwards compatibility) Skip it for CustomPageMessage and ExternalRedirectUrl fallback actions since attribute routing doesn't apply to those action types. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: correct JSX syntax error in matchingMembersFallbackRoute Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * refactor: extract getCustomSlug helper to deduplicate custom event type slug logic Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: only show fallback attribute filtering for teams under orgs Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: show fallback action for all team forms, gate attribute filtering on isOrganization Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
joe@cal.com
Devin AI
Hariom Balhara
parent
f6fa24de2b
commit
6089f47d24
@@ -1,43 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import Link from "next/link";
|
||||
import React, { useCallback, useState, useEffect } from "react";
|
||||
import { Query, Builder, Utils as QbUtils } from "react-awesome-query-builder";
|
||||
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 { Toaster } from "sonner";
|
||||
|
||||
import { buildEmptyQueryValue } from "@calcom/app-store/_utils/raqb/raqbUtils.client";
|
||||
import { raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils.server";
|
||||
import { routingFormAppComponents } from "@calcom/app-store/routing-forms/appComponents";
|
||||
import DynamicAppComponent from "@calcom/app-store/routing-forms/components/DynamicAppComponent";
|
||||
import { EmptyState } from "@calcom/app-store/routing-forms/components/_components/EmptyState";
|
||||
import { RoutingSkeleton } from "@calcom/app-store/routing-forms/components/_components/RoutingSkeleton";
|
||||
import DynamicAppComponent from "@calcom/app-store/routing-forms/components/DynamicAppComponent";
|
||||
import {
|
||||
withRaqbSettingsAndWidgets,
|
||||
ConfigFor,
|
||||
withRaqbSettingsAndWidgets,
|
||||
} from "@calcom/app-store/routing-forms/components/react-awesome-query-builder/config/uiConfig";
|
||||
import { RoutingPages } from "@calcom/app-store/routing-forms/lib/RoutingPages";
|
||||
import { createFallbackRoute } from "@calcom/app-store/routing-forms/lib/createFallbackRoute";
|
||||
import getEventTypeAppMetadata from "@calcom/app-store/routing-forms/lib/getEventTypeAppMetadata";
|
||||
import {
|
||||
getQueryBuilderConfigForFormFields,
|
||||
getQueryBuilderConfigForAttributes,
|
||||
type FormFieldsQueryBuilderConfigWithRaqbFields,
|
||||
type AttributesQueryBuilderConfigWithRaqbFields,
|
||||
type FormFieldsQueryBuilderConfigWithRaqbFields,
|
||||
getQueryBuilderConfigForAttributes,
|
||||
getQueryBuilderConfigForFormFields,
|
||||
isDynamicOperandField,
|
||||
} from "@calcom/app-store/routing-forms/lib/getQueryBuilderConfig";
|
||||
import isRouter from "@calcom/app-store/routing-forms/lib/isRouter";
|
||||
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { RoutingPages } from "@calcom/app-store/routing-forms/lib/RoutingPages";
|
||||
import type {
|
||||
Attribute,
|
||||
AttributeRoutingConfig,
|
||||
EditFormRoute,
|
||||
GlobalRoute,
|
||||
LocalRoute,
|
||||
RoutingFormWithResponseCount,
|
||||
SerializableRoute,
|
||||
Attribute,
|
||||
EditFormRoute,
|
||||
AttributeRoutingConfig,
|
||||
} from "@calcom/app-store/routing-forms/types/types";
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
@@ -52,15 +43,25 @@ import classNames from "@calcom/ui/classNames";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { FormCard } from "@calcom/ui/components/card";
|
||||
import { SelectWithValidation as Select, TextArea } from "@calcom/ui/components/form";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { SelectField } from "@calcom/ui/components/form";
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
import {
|
||||
SelectWithValidation as Select,
|
||||
SelectField,
|
||||
Switch,
|
||||
TextArea,
|
||||
TextField,
|
||||
} from "@calcom/ui/components/form";
|
||||
import type { IconName } from "@calcom/ui/components/icon";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import type { getServerSidePropsForSingleFormView as getServerSideProps } from "@calcom/web/lib/apps/routing-forms/[...pages]/getServerSidePropsSingleForm";
|
||||
|
||||
import SingleForm from "@components/apps/routing-forms/SingleForm";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { BuilderProps, Config, ImmutableTree, JsonTree } from "react-awesome-query-builder";
|
||||
import { Builder, Utils as QbUtils, Query } from "react-awesome-query-builder";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
type Form = inferSSRProps<typeof getServerSideProps>["form"];
|
||||
|
||||
@@ -342,6 +343,166 @@ const WeightedAttributesSelector = ({
|
||||
) : null;
|
||||
};
|
||||
|
||||
type RouteActionSelectorProps = {
|
||||
action: LocalRoute["action"] | undefined;
|
||||
onActionChange: (action: LocalRoute["action"]) => void;
|
||||
onEventTypeChange?: (action: LocalRoute["action"]) => void;
|
||||
eventTypeOptions: { label: string; value: string; eventTypeId: number }[];
|
||||
selectedEventTypeOption:
|
||||
| { label: string; value: string; eventTypeId: number }
|
||||
| { label: string; value: "custom"; eventTypeId: 0 }
|
||||
| undefined;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
showCustomEventTypeInput?: boolean;
|
||||
customEventTypeSlug?: string;
|
||||
onCustomEventTypeSlugChange?: (slug: string) => void;
|
||||
eventTypePrefix?: string;
|
||||
fieldIdentifiers?: string[];
|
||||
t: (key: string, options?: Record<string, string>) => string;
|
||||
};
|
||||
|
||||
const RouteActionSelector = ({
|
||||
action,
|
||||
onActionChange,
|
||||
onEventTypeChange,
|
||||
eventTypeOptions,
|
||||
selectedEventTypeOption,
|
||||
disabled = false,
|
||||
className = "",
|
||||
showCustomEventTypeInput = false,
|
||||
customEventTypeSlug = "",
|
||||
onCustomEventTypeSlugChange,
|
||||
eventTypePrefix = "",
|
||||
fieldIdentifiers = [],
|
||||
t,
|
||||
}: RouteActionSelectorProps) => {
|
||||
return (
|
||||
<div className={classNames("flex w-full flex-col gap-2 text-sm lg:flex-row", className)}>
|
||||
<div className="flex grow items-center gap-2">
|
||||
<Select
|
||||
size="sm"
|
||||
isDisabled={disabled}
|
||||
className="data-testid-select-routing-action block w-full grow"
|
||||
required
|
||||
value={RoutingPages.find((page) => page.value === action?.type)}
|
||||
onChange={(item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const newAction: LocalRoute["action"] = {
|
||||
type: item.value,
|
||||
value: "",
|
||||
};
|
||||
|
||||
if (newAction.type === "customPageMessage") {
|
||||
newAction.value = t("default_custom_page_message");
|
||||
} else {
|
||||
newAction.value = "";
|
||||
}
|
||||
|
||||
onActionChange(newAction);
|
||||
}}
|
||||
options={RoutingPages}
|
||||
/>
|
||||
</div>
|
||||
{action?.type ? (
|
||||
action?.type === "customPageMessage" ? (
|
||||
<TextArea
|
||||
required
|
||||
disabled={disabled}
|
||||
name="customPageMessage"
|
||||
className="border-default flex grow lg:w-fit"
|
||||
style={{
|
||||
minHeight: "38px",
|
||||
}}
|
||||
value={action.value}
|
||||
onChange={(e) => {
|
||||
onActionChange({ ...action, value: e.target.value });
|
||||
}}
|
||||
/>
|
||||
) : action?.type === "externalRedirectUrl" ? (
|
||||
<TextField
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
name="externalRedirectUrl"
|
||||
className="border-default flex grow text-sm"
|
||||
containerClassName="grow"
|
||||
type="url"
|
||||
required
|
||||
labelSrOnly
|
||||
value={action.value}
|
||||
onChange={(e) => {
|
||||
onActionChange({ ...action, value: e.target.value });
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
) : (
|
||||
<div className="grow">
|
||||
<Select
|
||||
size="sm"
|
||||
required
|
||||
className="data-testid-eventTypeRedirectUrl-select"
|
||||
isDisabled={disabled}
|
||||
options={eventTypeOptions}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
const newAction = {
|
||||
...action,
|
||||
value: option.value,
|
||||
eventTypeId: option.eventTypeId,
|
||||
};
|
||||
if (onEventTypeChange) {
|
||||
onEventTypeChange(newAction);
|
||||
} else {
|
||||
onActionChange(newAction);
|
||||
}
|
||||
}}
|
||||
value={selectedEventTypeOption}
|
||||
/>
|
||||
{showCustomEventTypeInput &&
|
||||
eventTypeOptions.length !== 0 &&
|
||||
action.value !== "" &&
|
||||
(action.value === "custom" ||
|
||||
!eventTypeOptions.find((eventOption) => eventOption.value === action.value) ||
|
||||
customEventTypeSlug.length) ? (
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
className="border-default flex w-full grow text-sm"
|
||||
containerClassName="grow mt-2"
|
||||
addOnLeading={eventTypePrefix}
|
||||
required
|
||||
value={customEventTypeSlug}
|
||||
onChange={(e) => {
|
||||
onCustomEventTypeSlugChange?.(e.target.value);
|
||||
onActionChange({
|
||||
...action,
|
||||
value: `${eventTypePrefix}${e.target.value}`,
|
||||
});
|
||||
}}
|
||||
placeholder="event-url"
|
||||
/>
|
||||
<div className="mt-2 ">
|
||||
<p className="text-subtle text-xs">
|
||||
{fieldIdentifiers.length
|
||||
? t("field_identifiers_as_variables_with_example", {
|
||||
variable: `{${fieldIdentifiers[0]}}`,
|
||||
})
|
||||
: t("field_identifiers_as_variables")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Route = ({
|
||||
form,
|
||||
route,
|
||||
@@ -395,10 +556,20 @@ const Route = ({
|
||||
? eventOptions[0].value.substring(0, eventOptions[0].value.lastIndexOf("/") + 1)
|
||||
: "";
|
||||
|
||||
const getCustomSlug = (actionValue: string | undefined) => {
|
||||
if (!actionValue) return "";
|
||||
const isCustom = !eventOptions.find((eventOption) => eventOption.value === actionValue);
|
||||
return isCustom ? (actionValue.split("/").pop() ?? "") : "";
|
||||
};
|
||||
|
||||
const [customEventTypeSlug, setCustomEventTypeSlug] = useState<string>(() => {
|
||||
const isCustom =
|
||||
!isRouter(route) && !eventOptions.find((eventOption) => eventOption.value === route.action.value);
|
||||
return isCustom && !isRouter(route) ? (route.action.value.split("/").pop() ?? "") : "";
|
||||
if (isRouter(route)) return "";
|
||||
return getCustomSlug(route.action.value);
|
||||
});
|
||||
|
||||
const [customFallbackEventTypeSlug, setCustomFallbackEventTypeSlug] = useState<string>(() => {
|
||||
if (isRouter(route)) return "";
|
||||
return getCustomSlug(route.fallbackAction?.value);
|
||||
});
|
||||
|
||||
useEnsureEventTypeIdInRedirectUrlAction({
|
||||
@@ -558,7 +729,7 @@ const Route = ({
|
||||
const attributesQueryBuilder =
|
||||
// team member attributes are only available for organization teams
|
||||
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm && isOrganization ? (
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
{/* TODO: */}
|
||||
{eventTypeRedirectUrlSelectedOption?.eventTypeAppMetadata &&
|
||||
"salesforce" in eventTypeRedirectUrlSelectedOption.eventTypeAppMetadata ? (
|
||||
@@ -573,13 +744,13 @@ const Route = ({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="bg-default border-subtle cal-query-builder-container mt-2 rounded-2xl border p-2">
|
||||
<div className="cal-query-builder-container mt-4" data-testid="attributes-query-builder">
|
||||
<div className="ml-2 flex items-center gap-0.5">
|
||||
<div className="border-subtle rounded-lg border p-1">
|
||||
<Icon name="user-check" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-emphasis ml-2 text-sm font-medium">
|
||||
And connect with specific team members
|
||||
{t("connect_with_specific_team_members")}
|
||||
</span>
|
||||
</div>
|
||||
{route.attributesQueryBuilderState && attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
|
||||
@@ -600,30 +771,71 @@ const Route = ({
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const fallbackAttributesQueryBuilder =
|
||||
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
|
||||
const fallbackActionOptions = eventTypeRedirectUrlOptions;
|
||||
const fallbackActionSelectedOption = route.fallbackAction?.eventTypeId
|
||||
? eventTypeRedirectUrlOptions.find((option) => option.eventTypeId === route.fallbackAction?.eventTypeId)
|
||||
: eventTypeRedirectUrlOptions.find((option) => option.value === route.fallbackAction?.value);
|
||||
|
||||
// Only show fallback section when main action has a valid event type selected
|
||||
// This prevents showing an empty fallback action selector before user selects an event type
|
||||
const hasValidEventTypeSelected =
|
||||
route.action?.type === RouteActionType.EventTypeRedirectUrl &&
|
||||
(route.action?.eventTypeId || route.action?.value);
|
||||
|
||||
const matchingMembersFallbackRoute =
|
||||
hasValidEventTypeSelected && isTeamForm ? (
|
||||
<div className="bg-default border-subtle cal-query-builder-container mt-2 rounded-2xl border p-2">
|
||||
<div className="ml-2 flex items-center gap-0.5">
|
||||
<div className="border-subtle rounded-lg border p-1">
|
||||
<Icon name="blocks" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-emphasis ml-2 text-sm font-medium">Fallback</span>
|
||||
<span className="text-emphasis ml-2 text-sm font-medium">{t("fallback_action")}</span>
|
||||
</div>
|
||||
{route.fallbackAttributesQueryBuilderState &&
|
||||
attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
|
||||
<Query
|
||||
{...attributesQueryBuilderConfigWithRaqbSettingsAndWidgets}
|
||||
value={route.fallbackAttributesQueryBuilderState.tree}
|
||||
onChange={(immutableTree, attributesQueryBuilderConfig) => {
|
||||
onChangeFallbackTeamMembersQuery(
|
||||
route,
|
||||
immutableTree,
|
||||
attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
|
||||
);
|
||||
}}
|
||||
renderBuilder={renderBuilder}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-cal-muted mt-2 rounded-xl p-2">
|
||||
<RouteActionSelector
|
||||
action={route.fallbackAction}
|
||||
onActionChange={(newAction) => setRoute(route.id, { fallbackAction: newAction })}
|
||||
eventTypeOptions={fallbackActionOptions}
|
||||
selectedEventTypeOption={fallbackActionSelectedOption}
|
||||
disabled={disabled}
|
||||
showCustomEventTypeInput
|
||||
customEventTypeSlug={customFallbackEventTypeSlug}
|
||||
onCustomEventTypeSlugChange={setCustomFallbackEventTypeSlug}
|
||||
eventTypePrefix={eventTypePrefix}
|
||||
fieldIdentifiers={fieldIdentifiers}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
{isOrganization && (
|
||||
<div className="mt-4">
|
||||
{route.fallbackAction?.type === RouteActionType.EventTypeRedirectUrl &&
|
||||
route.fallbackAttributesQueryBuilderState &&
|
||||
attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
|
||||
<div>
|
||||
<div className="ml-2 flex items-center gap-0.5">
|
||||
<div className="border-subtle rounded-lg border p-1">
|
||||
<Icon name="user-check" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-emphasis ml-2 text-sm font-medium">
|
||||
{t("connect_with_specific_team_members")}
|
||||
</span>
|
||||
</div>
|
||||
<Query
|
||||
{...attributesQueryBuilderConfigWithRaqbSettingsAndWidgets}
|
||||
value={route.fallbackAttributesQueryBuilderState.tree}
|
||||
onChange={(immutableTree, attributesQueryBuilderConfig) => {
|
||||
onChangeFallbackTeamMembersQuery(
|
||||
route,
|
||||
immutableTree,
|
||||
attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
|
||||
);
|
||||
}}
|
||||
renderBuilder={renderBuilder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -659,283 +871,87 @@ const Route = ({
|
||||
{formFieldsQueryBuilder}
|
||||
<div>
|
||||
{route.isFallback ? (
|
||||
<div className="flex w-full flex-col gap-2 text-sm lg:flex-row">
|
||||
<div className="flex grow items-center gap-2">
|
||||
{/* <div className="flex grow-0 whitespace-nowrap">
|
||||
<span>{t("send_booker_to")}</span>
|
||||
</div> */}
|
||||
<Select
|
||||
size="sm"
|
||||
isDisabled={disabled}
|
||||
className="data-testid-select-routing-action block w-full grow"
|
||||
required
|
||||
value={RoutingPages.find((page) => page.value === route.action?.type)}
|
||||
onChange={(item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const action: LocalRoute["action"] = {
|
||||
type: item.value,
|
||||
value: "",
|
||||
};
|
||||
|
||||
if (action.type === "customPageMessage") {
|
||||
action.value = "We are not ready for you yet :(";
|
||||
} else {
|
||||
action.value = "";
|
||||
}
|
||||
|
||||
setRoute(route.id, { action });
|
||||
}}
|
||||
options={RoutingPages}
|
||||
/>
|
||||
</div>
|
||||
{route.action?.type ? (
|
||||
route.action?.type === "customPageMessage" ? (
|
||||
<TextArea
|
||||
required
|
||||
disabled={disabled}
|
||||
name="customPageMessage"
|
||||
className="border-default flex grow lg:w-fit"
|
||||
style={{
|
||||
minHeight: "38px",
|
||||
}}
|
||||
value={route.action.value}
|
||||
onChange={(e) => {
|
||||
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
|
||||
}}
|
||||
/>
|
||||
) : route.action?.type === "externalRedirectUrl" ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
name="externalRedirectUrl"
|
||||
className="border-default flex grow text-sm"
|
||||
containerClassName="grow"
|
||||
type="url"
|
||||
required
|
||||
labelSrOnly
|
||||
value={route.action.value}
|
||||
onChange={(e) => {
|
||||
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
) : (
|
||||
<div className="grow">
|
||||
<Select
|
||||
size="sm"
|
||||
required
|
||||
className="data-testid-eventTypeRedirectUrl-select"
|
||||
isDisabled={disabled}
|
||||
options={eventTypeRedirectUrlOptions}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
if (option.value !== "custom") {
|
||||
setRoute(route.id, {
|
||||
action: {
|
||||
...route.action,
|
||||
value: option.value,
|
||||
eventTypeId: option.eventTypeId,
|
||||
},
|
||||
attributeRoutingConfig: {},
|
||||
});
|
||||
setCustomEventTypeSlug("");
|
||||
} else {
|
||||
setRoute(route.id, {
|
||||
action: { ...route.action, value: "custom", eventTypeId: 0 },
|
||||
attributeRoutingConfig: {},
|
||||
});
|
||||
setCustomEventTypeSlug("");
|
||||
}
|
||||
}}
|
||||
value={eventTypeRedirectUrlSelectedOption}
|
||||
/>
|
||||
{eventOptions.length !== 0 &&
|
||||
route.action.value !== "" &&
|
||||
(!eventOptions.find((eventOption) => eventOption.value === route.action.value) ||
|
||||
customEventTypeSlug.length) ? (
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
className="border-default flex w-full grow text-sm"
|
||||
containerClassName="grow mt-2"
|
||||
addOnLeading={eventTypePrefix}
|
||||
required
|
||||
value={customEventTypeSlug}
|
||||
onChange={(e) => {
|
||||
setCustomEventTypeSlug(e.target.value);
|
||||
setRoute(route.id, {
|
||||
action: { ...route.action, value: `${eventTypePrefix}${e.target.value}` },
|
||||
});
|
||||
}}
|
||||
placeholder="event-url"
|
||||
/>
|
||||
<div className="mt-2 ">
|
||||
<p className="text-subtle text-xs">
|
||||
{fieldIdentifiers.length
|
||||
? t("field_identifiers_as_variables_with_example", {
|
||||
variable: `{${fieldIdentifiers[0]}}`,
|
||||
})
|
||||
: t("field_identifiers_as_variables")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<RouteActionSelector
|
||||
action={route.action}
|
||||
onActionChange={(newAction) => setRoute(route.id, { action: newAction })}
|
||||
onEventTypeChange={(newAction) => {
|
||||
// Initialize fallbackAction with the same event type if not already set with a valid event type
|
||||
// Check if fallbackAction has a valid event type (eventTypeId or non-empty value)
|
||||
const hasValidFallbackEventType =
|
||||
route.fallbackAction?.eventTypeId || route.fallbackAction?.value;
|
||||
const fallbackAction = hasValidFallbackEventType
|
||||
? route.fallbackAction
|
||||
: newAction.type === RouteActionType.EventTypeRedirectUrl
|
||||
? {
|
||||
type: newAction.type,
|
||||
value: newAction.value,
|
||||
eventTypeId: newAction.eventTypeId,
|
||||
}
|
||||
: undefined;
|
||||
setRoute(route.id, { action: newAction, attributeRoutingConfig: {}, fallbackAction });
|
||||
setCustomEventTypeSlug("");
|
||||
}}
|
||||
eventTypeOptions={eventTypeRedirectUrlOptions}
|
||||
selectedEventTypeOption={eventTypeRedirectUrlSelectedOption}
|
||||
disabled={disabled}
|
||||
showCustomEventTypeInput
|
||||
customEventTypeSlug={customEventTypeSlug}
|
||||
onCustomEventTypeSlugChange={setCustomEventTypeSlug}
|
||||
eventTypePrefix={eventTypePrefix}
|
||||
fieldIdentifiers={fieldIdentifiers}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-default border-subtle my-3 rounded-xl border p-2">
|
||||
<div className="mb-2 ml-2 flex items-center gap-0.5">
|
||||
<div className="border-subtle rounded-lg border p-1">
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-emphasis ml-2 text-sm font-medium">Send booker to</span>
|
||||
<span className="text-emphasis ml-2 text-sm font-medium">{t("send_booker_to")}</span>
|
||||
</div>
|
||||
<div className="bg-cal-muted flex w-full flex-col gap-2 rounded-xl p-2 text-sm lg:flex-row">
|
||||
<div className="flex grow items-center gap-2">
|
||||
<Select
|
||||
size="sm"
|
||||
isDisabled={disabled}
|
||||
className="data-testid-select-routing-action block w-full grow"
|
||||
required
|
||||
value={RoutingPages.find((page) => page.value === route.action?.type)}
|
||||
onChange={(item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const action: LocalRoute["action"] = {
|
||||
type: item.value,
|
||||
value: "",
|
||||
};
|
||||
|
||||
if (action.type === "customPageMessage") {
|
||||
action.value = "We are not ready for you yet :(";
|
||||
} else {
|
||||
action.value = "";
|
||||
}
|
||||
|
||||
setRoute(route.id, { action });
|
||||
}}
|
||||
options={RoutingPages}
|
||||
/>
|
||||
</div>
|
||||
{route.action?.type ? (
|
||||
route.action?.type === "customPageMessage" ? (
|
||||
<TextArea
|
||||
required
|
||||
disabled={disabled}
|
||||
name="customPageMessage"
|
||||
className="border-default flex grow lg:w-fit"
|
||||
style={{
|
||||
minHeight: "38px",
|
||||
}}
|
||||
value={route.action.value}
|
||||
onChange={(e) => {
|
||||
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
|
||||
}}
|
||||
/>
|
||||
) : route.action?.type === "externalRedirectUrl" ? (
|
||||
<TextField
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
name="externalRedirectUrl"
|
||||
className="border-default flex grow text-sm"
|
||||
containerClassName="grow"
|
||||
type="url"
|
||||
required
|
||||
labelSrOnly
|
||||
value={route.action.value}
|
||||
onChange={(e) => {
|
||||
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
) : (
|
||||
<div className="grow">
|
||||
<Select
|
||||
size="sm"
|
||||
required
|
||||
className="data-testid-eventTypeRedirectUrl-select"
|
||||
isDisabled={disabled}
|
||||
options={eventTypeRedirectUrlOptions}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
<div className="bg-cal-muted rounded-xl p-2">
|
||||
<RouteActionSelector
|
||||
action={route.action}
|
||||
onActionChange={(newAction) => setRoute(route.id, { action: newAction })}
|
||||
onEventTypeChange={(newAction) => {
|
||||
// Initialize fallbackAction with the same event type if not already set with a valid event type
|
||||
// Check if fallbackAction has a valid event type (eventTypeId or non-empty value)
|
||||
const hasValidFallbackEventType =
|
||||
route.fallbackAction?.eventTypeId || route.fallbackAction?.value;
|
||||
const fallbackAction = hasValidFallbackEventType
|
||||
? route.fallbackAction
|
||||
: newAction.type === RouteActionType.EventTypeRedirectUrl
|
||||
? {
|
||||
type: newAction.type,
|
||||
value: newAction.value,
|
||||
eventTypeId: newAction.eventTypeId,
|
||||
}
|
||||
if (option.value !== "custom") {
|
||||
setRoute(route.id, {
|
||||
action: {
|
||||
...route.action,
|
||||
value: option.value,
|
||||
eventTypeId: option.eventTypeId,
|
||||
},
|
||||
attributeRoutingConfig: {},
|
||||
});
|
||||
setCustomEventTypeSlug("");
|
||||
} else {
|
||||
setRoute(route.id, {
|
||||
action: { ...route.action, value: "custom", eventTypeId: 0 },
|
||||
attributeRoutingConfig: {},
|
||||
});
|
||||
setCustomEventTypeSlug("");
|
||||
}
|
||||
}}
|
||||
value={eventTypeRedirectUrlSelectedOption}
|
||||
/>
|
||||
{eventOptions.length !== 0 &&
|
||||
route.action.value !== "" &&
|
||||
(!eventOptions.find((eventOption) => eventOption.value === route.action.value) ||
|
||||
customEventTypeSlug.length) ? (
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
className="border-default flex w-full grow text-sm"
|
||||
containerClassName="grow mt-2"
|
||||
addOnLeading={eventTypePrefix}
|
||||
required
|
||||
value={customEventTypeSlug}
|
||||
onChange={(e) => {
|
||||
setCustomEventTypeSlug(e.target.value);
|
||||
setRoute(route.id, {
|
||||
action: { ...route.action, value: `${eventTypePrefix}${e.target.value}` },
|
||||
});
|
||||
}}
|
||||
placeholder="event-url"
|
||||
/>
|
||||
<div className="mt-2 ">
|
||||
<p className="text-subtle text-xs">
|
||||
{fieldIdentifiers.length
|
||||
? t("field_identifiers_as_variables_with_example", {
|
||||
variable: `{${fieldIdentifiers[0]}}`,
|
||||
})
|
||||
: t("field_identifiers_as_variables")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
: undefined;
|
||||
setRoute(route.id, { action: newAction, attributeRoutingConfig: {}, fallbackAction });
|
||||
setCustomEventTypeSlug("");
|
||||
}}
|
||||
eventTypeOptions={eventTypeRedirectUrlOptions}
|
||||
selectedEventTypeOption={eventTypeRedirectUrlSelectedOption}
|
||||
disabled={disabled}
|
||||
showCustomEventTypeInput
|
||||
customEventTypeSlug={customEventTypeSlug}
|
||||
onCustomEventTypeSlugChange={setCustomEventTypeSlug}
|
||||
eventTypePrefix={eventTypePrefix}
|
||||
fieldIdentifiers={fieldIdentifiers}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
{attributesQueryBuilder}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attributesQueryBuilder}
|
||||
<WeightedAttributesSelector
|
||||
attributes={attributes}
|
||||
route={route}
|
||||
eventTypeRedirectUrlSelectedOption={eventTypeRedirectUrlSelectedOption}
|
||||
setRoute={setRoute}
|
||||
/>
|
||||
{fallbackAttributesQueryBuilder ? <>{fallbackAttributesQueryBuilder}</> : null}
|
||||
{matchingMembersFallbackRoute ? <>{matchingMembersFallbackRoute}</> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1026,6 +1042,16 @@ function useRoutes({
|
||||
if (!r.fallbackAttributesQueryValue) {
|
||||
r.fallbackAttributesQueryValue = getEmptyQueryValue() as LocalRoute["fallbackAttributesQueryValue"];
|
||||
}
|
||||
|
||||
// Initialize fallbackAction with the main action for backwards compatibility
|
||||
// This ensures existing routes show the correct default (same event type as main route)
|
||||
if (!r.fallbackAction && r.action?.type === RouteActionType.EventTypeRedirectUrl) {
|
||||
r.fallbackAction = {
|
||||
type: r.action.type,
|
||||
value: r.action.value,
|
||||
eventTypeId: r.action.eventTypeId,
|
||||
};
|
||||
}
|
||||
});
|
||||
return _routes;
|
||||
};
|
||||
@@ -1056,6 +1082,17 @@ function useRoutes({
|
||||
if (isRouter(route)) {
|
||||
return route;
|
||||
}
|
||||
// Initialize fallbackAction from action if not set and action is EventTypeRedirectUrl
|
||||
// This handles the case where user creates a new route and changes action to an event type
|
||||
const fallbackAction =
|
||||
route.fallbackAction ??
|
||||
(route.action?.type === RouteActionType.EventTypeRedirectUrl
|
||||
? {
|
||||
type: route.action.type,
|
||||
value: route.action.value,
|
||||
eventTypeId: route.action.eventTypeId,
|
||||
}
|
||||
: undefined);
|
||||
return {
|
||||
id: route.id,
|
||||
name: route.name,
|
||||
@@ -1066,6 +1103,7 @@ function useRoutes({
|
||||
attributesQueryValue: route.attributesQueryValue,
|
||||
fallbackAttributesQueryValue: route.fallbackAttributesQueryValue,
|
||||
attributeIdForWeights: route.attributeIdForWeights,
|
||||
fallbackAction,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1087,10 +1125,21 @@ const useCreateRoute = ({
|
||||
}) => {
|
||||
const createRoute = useCallback(() => {
|
||||
const newEmptyRoute = getEmptyRoute();
|
||||
// Initialize fallbackAction with the main action for new routes
|
||||
// This ensures new routes have a valid fallbackAction when saved
|
||||
const fallbackAction =
|
||||
newEmptyRoute.action?.type === RouteActionType.EventTypeRedirectUrl
|
||||
? {
|
||||
type: newEmptyRoute.action.type,
|
||||
value: newEmptyRoute.action.value,
|
||||
eventTypeId: newEmptyRoute.action.eventTypeId,
|
||||
}
|
||||
: undefined;
|
||||
const newRoutes = [
|
||||
...routes,
|
||||
{
|
||||
...newEmptyRoute,
|
||||
fallbackAction,
|
||||
formFieldsQueryBuilderState: buildState({
|
||||
queryValue: newEmptyRoute.queryValue,
|
||||
config: formFieldsQueryBuilderConfig,
|
||||
|
||||
@@ -3597,6 +3597,9 @@
|
||||
"use_attribute_weights": "Use Attribute weights",
|
||||
"if_enabled_ignore_event_type_weights": "If enabled, all weights set within the event type will be ignored",
|
||||
"attribute_for_weights": "Attribute for weights",
|
||||
"fallback_action": "Else Send Booker to",
|
||||
"connect_with_specific_team_members": "And connect with specific team members",
|
||||
"default_custom_page_message": "We are not ready for you yet :(",
|
||||
"managed_users": "Managed users",
|
||||
"managed_users_description": "See all the managed users created by your OAuth client",
|
||||
"can_you_tell_me_about": "Can you tell me about ",
|
||||
|
||||
@@ -67,9 +67,9 @@ async function selectOperatorOption({ fromLocator, option }: { fromLocator: Loca
|
||||
}
|
||||
|
||||
async function addAttributeRoutingRule(page: Page) {
|
||||
// TODO: Use a better selector maybe?
|
||||
await page.locator('text="Add rule"').nth(1).click();
|
||||
const attributeQueryBuilder = page.locator(".group-container").nth(1);
|
||||
// Use data-testid to target the main attributes query builder specifically
|
||||
const attributeQueryBuilder = page.locator('[data-testid="attributes-query-builder"]');
|
||||
await attributeQueryBuilder.locator('text="Add rule"').click();
|
||||
const attributeSelectorFirstRule = attributeQueryBuilder.locator(".rule--field").nth(0);
|
||||
await selectFirstAttributeOption({
|
||||
fromLocator: attributeSelectorFirstRule,
|
||||
@@ -84,8 +84,9 @@ async function addAttributeRoutingRule(page: Page) {
|
||||
}
|
||||
|
||||
async function addAttributeRoutingRuleWithOperator(page: Page, valueFrom: string, valueTo: string) {
|
||||
await page.locator('text="Add rule"').nth(1).click();
|
||||
const attributeQueryBuilder = page.locator(".group-container").nth(1);
|
||||
// Use data-testid to target the main attributes query builder specifically
|
||||
const attributeQueryBuilder = page.locator('[data-testid="attributes-query-builder"]');
|
||||
await attributeQueryBuilder.locator('text="Add rule"').click();
|
||||
const attributeSelectorFirstRule = attributeQueryBuilder.locator(".rule--field").nth(0);
|
||||
await selectFirstAttributeOption({
|
||||
fromLocator: attributeSelectorFirstRule,
|
||||
|
||||
@@ -92,6 +92,17 @@ export const zodNonRouterRoute = z.object({
|
||||
eventTypeId: z.number().optional(),
|
||||
value: z.string(),
|
||||
}),
|
||||
/**
|
||||
* Fallback action used when the main attributes query finds no matching team members
|
||||
* and no CRM contact owner (if configured to check) exists.
|
||||
*/
|
||||
fallbackAction: z
|
||||
.object({
|
||||
type: routeActionTypeSchema,
|
||||
eventTypeId: z.number().optional(),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const zodNonRouterRouteView = zodNonRouterRoute;
|
||||
|
||||
@@ -354,4 +354,152 @@ describe("getRoutedUrl", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fallback action", () => {
|
||||
it("should use fallbackAction for external redirect when no team members found", async () => {
|
||||
vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(
|
||||
mockForm as never
|
||||
);
|
||||
const mainRedirectUrl = "test-user/30min";
|
||||
const fallbackUrl = "https://example.com/fallback";
|
||||
const mockRoute = {
|
||||
id: "route1",
|
||||
action: { type: "eventTypeRedirectUrl", value: mainRedirectUrl },
|
||||
};
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
vi.mocked(handleResponse).mockResolvedValue({
|
||||
teamMembersMatchingAttributeLogic: [],
|
||||
formResponse: { id: 1 },
|
||||
queuedFormResponse: null,
|
||||
attributeRoutingConfig: null,
|
||||
timeTaken: {},
|
||||
crmContactOwnerEmail: null,
|
||||
crmContactOwnerRecordType: null,
|
||||
crmAppSlug: null,
|
||||
isPreview: false,
|
||||
fallbackAction: { type: "externalRedirectUrl", value: fallbackUrl },
|
||||
});
|
||||
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
expect(result).toEqual({
|
||||
redirect: {
|
||||
destination: expect.stringContaining(fallbackUrl),
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use fallbackAction for custom page message when no team members found", async () => {
|
||||
vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(
|
||||
mockForm as never
|
||||
);
|
||||
const mainRedirectUrl = "test-user/30min";
|
||||
const customMessage = "No team members available";
|
||||
const mockRoute = {
|
||||
id: "route1",
|
||||
action: { type: "eventTypeRedirectUrl", value: mainRedirectUrl },
|
||||
};
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
vi.mocked(handleResponse).mockResolvedValue({
|
||||
teamMembersMatchingAttributeLogic: [],
|
||||
formResponse: { id: 1 },
|
||||
queuedFormResponse: null,
|
||||
attributeRoutingConfig: null,
|
||||
timeTaken: {},
|
||||
crmContactOwnerEmail: null,
|
||||
crmContactOwnerRecordType: null,
|
||||
crmAppSlug: null,
|
||||
isPreview: false,
|
||||
fallbackAction: { type: "customPageMessage", value: customMessage },
|
||||
});
|
||||
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
expect(result).toEqual({
|
||||
props: {
|
||||
isEmbed: false,
|
||||
form: mockSerializableForm,
|
||||
message: customMessage,
|
||||
errorMessage: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use main action when fallbackAction is null", async () => {
|
||||
vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(
|
||||
mockForm as never
|
||||
);
|
||||
const redirectUrl = "test-user/30min";
|
||||
const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } };
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${redirectUrl}`);
|
||||
vi.mocked(handleResponse).mockResolvedValue({
|
||||
teamMembersMatchingAttributeLogic: [123],
|
||||
formResponse: { id: 1 },
|
||||
queuedFormResponse: null,
|
||||
attributeRoutingConfig: null,
|
||||
timeTaken: {},
|
||||
crmContactOwnerEmail: null,
|
||||
crmContactOwnerRecordType: null,
|
||||
crmAppSlug: null,
|
||||
isPreview: false,
|
||||
fallbackAction: null,
|
||||
});
|
||||
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
expect(result).toEqual({
|
||||
redirect: {
|
||||
destination: `/${redirectUrl}`,
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use fallbackAction for event type redirect when no team members found", async () => {
|
||||
vi.mocked(PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(
|
||||
mockForm as never
|
||||
);
|
||||
const mainRedirectUrl = "test-user/30min";
|
||||
const fallbackRedirectUrl = "test-user/60min";
|
||||
const mockRoute = {
|
||||
id: "route1",
|
||||
action: { type: "eventTypeRedirectUrl", value: mainRedirectUrl },
|
||||
};
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
vi.mocked(substituteVariables).mockReturnValue(fallbackRedirectUrl);
|
||||
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${fallbackRedirectUrl}`);
|
||||
vi.mocked(handleResponse).mockResolvedValue({
|
||||
teamMembersMatchingAttributeLogic: [],
|
||||
formResponse: { id: 1 },
|
||||
queuedFormResponse: null,
|
||||
attributeRoutingConfig: null,
|
||||
timeTaken: {},
|
||||
crmContactOwnerEmail: null,
|
||||
crmContactOwnerRecordType: null,
|
||||
crmAppSlug: null,
|
||||
isPreview: false,
|
||||
fallbackAction: { type: "eventTypeRedirectUrl", value: fallbackRedirectUrl },
|
||||
});
|
||||
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
expect(substituteVariables).toHaveBeenCalledWith(
|
||||
fallbackRedirectUrl,
|
||||
{ email: "test@cal.com" },
|
||||
mockSerializableForm.fields
|
||||
);
|
||||
expect(result).toEqual({
|
||||
redirect: {
|
||||
destination: `/${fallbackRedirectUrl}`,
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +158,7 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
let crmContactOwnerEmail: string | null = null;
|
||||
let crmContactOwnerRecordType: string | null = null;
|
||||
let crmAppSlug: string | null = null;
|
||||
let fallbackAction: typeof decidedAction | null = null;
|
||||
try {
|
||||
const result = await handleResponse({
|
||||
form: serializableForm,
|
||||
@@ -178,6 +179,7 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
crmContactOwnerEmail = result.crmContactOwnerEmail;
|
||||
crmContactOwnerRecordType = result.crmContactOwnerRecordType;
|
||||
crmAppSlug = result.crmAppSlug;
|
||||
fallbackAction = result.fallbackAction ?? null;
|
||||
timeTaken = {
|
||||
...timeTaken,
|
||||
...result.timeTaken,
|
||||
@@ -212,19 +214,22 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
// TODO: To be done using sentry tracing
|
||||
console.log("Server-Timing", getServerTimingHeader(timeTaken));
|
||||
|
||||
// Use fallbackAction if set (when no team members found), otherwise use the main decidedAction
|
||||
const actionToUse = fallbackAction ?? decidedAction;
|
||||
|
||||
//TODO: Maybe take action after successful mutation
|
||||
if (decidedAction.type === "customPageMessage") {
|
||||
if (actionToUse.type === "customPageMessage") {
|
||||
return {
|
||||
props: {
|
||||
...pageProps,
|
||||
form: serializableForm,
|
||||
message: decidedAction.value,
|
||||
message: actionToUse.value,
|
||||
errorMessage: null,
|
||||
},
|
||||
};
|
||||
} else if (decidedAction.type === "eventTypeRedirectUrl") {
|
||||
} else if (actionToUse.type === "eventTypeRedirectUrl") {
|
||||
const eventTypeUrlWithResolvedVariables = substituteVariables(
|
||||
decidedAction.value,
|
||||
actionToUse.value,
|
||||
response,
|
||||
serializableForm.fields
|
||||
);
|
||||
@@ -258,10 +263,10 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
} else if (decidedAction.type === "externalRedirectUrl") {
|
||||
} else if (actionToUse.type === "externalRedirectUrl") {
|
||||
return {
|
||||
redirect: {
|
||||
destination: `${decidedAction.value}?${stringify(context.query)}&cal.action=externalRedirectUrl`,
|
||||
destination: `${actionToUse.value}?${stringify(context.query)}&cal.action=externalRedirectUrl`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -342,4 +342,334 @@ describe("handleResponse", () => {
|
||||
})
|
||||
).rejects.toThrow(/Chosen route is a router/);
|
||||
});
|
||||
|
||||
describe("Fallback action", () => {
|
||||
// Mock attributesQueryValue with actual rules
|
||||
const mockAttributesQueryValueWithRules = {
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "attr-1",
|
||||
operator: "select_equals",
|
||||
value: ["option-1"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("should return fallbackAction when no team members match attribute logic", async () => {
|
||||
const fallbackAction = {
|
||||
type: "externalRedirectUrl" as const,
|
||||
value: "https://example.com/fallback",
|
||||
};
|
||||
const chosenRoute = {
|
||||
id: "route1",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryValue: { type: "group", children1: {} } as any,
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl" as const,
|
||||
value: "team/30min",
|
||||
},
|
||||
attributeRoutingConfig: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
attributesQueryValue: mockAttributesQueryValueWithRules as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
|
||||
fallbackAction,
|
||||
isFallback: false,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
|
||||
|
||||
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
|
||||
teamMembersMatchingAttributeLogic: [], // Empty array = no users found
|
||||
checkedFallback: true, // Fallback was checked and triggered
|
||||
fallbackAttributeLogicBuildingWarnings: [],
|
||||
mainAttributeLogicBuildingWarnings: [],
|
||||
timeTaken: {},
|
||||
});
|
||||
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: formWithRoute,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: "route1",
|
||||
isPreview: true,
|
||||
identifierKeyedResponse: {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.teamMembersMatchingAttributeLogic).toEqual([]);
|
||||
expect(result.fallbackAction).toEqual(fallbackAction);
|
||||
|
||||
// Verify that fallbackAttributesQueryValue is NOT passed when fallbackAction is configured
|
||||
expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fallbackAttributesQueryValue: undefined,
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null fallbackAction when team members are found", async () => {
|
||||
const fallbackAction = {
|
||||
type: "externalRedirectUrl" as const,
|
||||
value: "https://example.com/fallback",
|
||||
};
|
||||
const chosenRoute = {
|
||||
id: "route1",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryValue: { type: "group", children1: {} } as any,
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl" as const,
|
||||
value: "team/30min",
|
||||
},
|
||||
attributeRoutingConfig: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
attributesQueryValue: mockAttributesQueryValueWithRules as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
|
||||
fallbackAction,
|
||||
isFallback: false,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
|
||||
|
||||
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
teamMembersMatchingAttributeLogic: [{ userId: 123, result: "MATCH" as any }],
|
||||
checkedFallback: false,
|
||||
fallbackAttributeLogicBuildingWarnings: [],
|
||||
mainAttributeLogicBuildingWarnings: [],
|
||||
timeTaken: {},
|
||||
});
|
||||
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: formWithRoute,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: "route1",
|
||||
isPreview: true,
|
||||
identifierKeyedResponse: {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.teamMembersMatchingAttributeLogic).toEqual([123]);
|
||||
expect(result.fallbackAction).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null fallbackAction when route has no fallbackAction configured", async () => {
|
||||
const chosenRoute = {
|
||||
id: "route1",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryValue: { type: "group", children1: {} } as any,
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl" as const,
|
||||
value: "team/30min",
|
||||
},
|
||||
attributeRoutingConfig: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
attributesQueryValue: { type: "group", children1: {} } as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
|
||||
// No fallbackAction configured
|
||||
isFallback: false,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
|
||||
|
||||
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
|
||||
teamMembersMatchingAttributeLogic: [], // Empty array = no users found
|
||||
checkedFallback: false,
|
||||
fallbackAttributeLogicBuildingWarnings: [],
|
||||
mainAttributeLogicBuildingWarnings: [],
|
||||
timeTaken: {},
|
||||
});
|
||||
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: formWithRoute,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: "route1",
|
||||
isPreview: true,
|
||||
identifierKeyedResponse: {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.teamMembersMatchingAttributeLogic).toEqual([]);
|
||||
expect(result.fallbackAction).toBeNull();
|
||||
|
||||
// Verify that fallbackAttributesQueryValue IS passed when fallbackAction is NOT configured (backwards compatibility)
|
||||
expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fallbackAttributesQueryValue: { type: "group", children1: {} },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null fallbackAction when teamMembersMatchingAttributeLogic is null (non-team form)", async () => {
|
||||
const fallbackAction = {
|
||||
type: "externalRedirectUrl" as const,
|
||||
value: "https://example.com/fallback",
|
||||
};
|
||||
const chosenRoute = {
|
||||
id: "route1",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryValue: { type: "group", children1: {} } as any,
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl" as const,
|
||||
value: "team/30min",
|
||||
},
|
||||
attributeRoutingConfig: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
attributesQueryValue: { type: "group", children1: {} } as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
|
||||
fallbackAction,
|
||||
isFallback: false,
|
||||
};
|
||||
// Non-team form (no teamId or orgId)
|
||||
const nonTeamForm: TargetRoutingFormForResponse = {
|
||||
...mockForm,
|
||||
teamId: null,
|
||||
team: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
routes: [chosenRoute as any],
|
||||
};
|
||||
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: nonTeamForm,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: "route1",
|
||||
isPreview: true,
|
||||
identifierKeyedResponse: {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
// teamMembersMatchingAttributeLogic is null for non-team forms
|
||||
expect(result.teamMembersMatchingAttributeLogic).toBeNull();
|
||||
// fallbackAction should be null because attribute routing has no rules configured (empty children1)
|
||||
expect(result.fallbackAction).toBeNull();
|
||||
});
|
||||
|
||||
it("should return fallbackAction when team has no orgId but attribute routing is configured", async () => {
|
||||
const fallbackAction = {
|
||||
type: "externalRedirectUrl" as const,
|
||||
value: "https://example.com/fallback",
|
||||
};
|
||||
const chosenRoute = {
|
||||
id: "route1",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryValue: { type: "group", children1: {} } as any,
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl" as const,
|
||||
value: "team/30min",
|
||||
},
|
||||
attributeRoutingConfig: null,
|
||||
// Route has attribute routing rules configured
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
attributesQueryValue: mockAttributesQueryValueWithRules as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
|
||||
fallbackAction,
|
||||
isFallback: false,
|
||||
};
|
||||
// Team form but team has no parentId (not part of an org)
|
||||
const teamWithoutOrgForm: TargetRoutingFormForResponse = {
|
||||
...mockForm,
|
||||
teamId: 123,
|
||||
team: { parentId: null }, // No org
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
routes: [chosenRoute as any],
|
||||
};
|
||||
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: teamWithoutOrgForm,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: "route1",
|
||||
isPreview: true,
|
||||
identifierKeyedResponse: {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
// teamMembersMatchingAttributeLogic is null because orgId is missing (attribute routing couldn't run)
|
||||
expect(result.teamMembersMatchingAttributeLogic).toBeNull();
|
||||
// fallbackAction SHOULD be returned because attribute routing was configured but couldn't run
|
||||
expect(result.fallbackAction).toEqual(fallbackAction);
|
||||
});
|
||||
|
||||
it("should return null fallbackAction when no team members found but CRM contact owner exists", async () => {
|
||||
const fallbackAction = {
|
||||
type: "externalRedirectUrl" as const,
|
||||
value: "https://example.com/fallback",
|
||||
};
|
||||
const chosenRoute = {
|
||||
id: "route1",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryValue: { type: "group", children1: {} } as any,
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl" as const,
|
||||
value: "team/30min",
|
||||
},
|
||||
attributeRoutingConfig: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
attributesQueryValue: mockAttributesQueryValueWithRules as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
|
||||
fallbackAction,
|
||||
isFallback: false,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
|
||||
|
||||
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
|
||||
teamMembersMatchingAttributeLogic: [], // Empty array = no users found
|
||||
checkedFallback: false,
|
||||
fallbackAttributeLogicBuildingWarnings: [],
|
||||
mainAttributeLogicBuildingWarnings: [],
|
||||
timeTaken: {},
|
||||
});
|
||||
|
||||
// Mock CRM contact owner found
|
||||
vi.mocked(routerGetCrmContactOwnerEmail).mockResolvedValue({
|
||||
email: "crm-owner@example.com",
|
||||
recordType: "contact",
|
||||
crmAppSlug: "hubspot",
|
||||
recordId: "123",
|
||||
});
|
||||
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: formWithRoute,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: "route1",
|
||||
isPreview: true,
|
||||
fetchCrm: true,
|
||||
identifierKeyedResponse: {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.teamMembersMatchingAttributeLogic).toEqual([]);
|
||||
expect(result.crmContactOwnerEmail).toEqual("crm-owner@example.com");
|
||||
// fallbackAction should be null because CRM contact owner was found
|
||||
expect(result.fallbackAction).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type TargetRoutingFormForResponse,
|
||||
} from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
||||
import isRouter from "@calcom/app-store/routing-forms/lib/isRouter";
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
|
||||
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
@@ -135,6 +136,14 @@ const _handleResponse = async ({
|
||||
crmRecordId = contactOwnerQuery?.recordId ?? null;
|
||||
})(),
|
||||
(async () => {
|
||||
// Determine when to use fallbackAttributesQueryValue:
|
||||
// 1. If fallbackAction is EventTypeRedirectUrl → use it (attribute routing applies to event redirect)
|
||||
// 2. If fallbackAction is not set → use it (backwards compatibility)
|
||||
// 3. If fallbackAction is CustomPageMessage or ExternalRedirectUrl → don't use it (attribute routing doesn't apply)
|
||||
const hasFallbackAction = "fallbackAction" in chosenRoute && chosenRoute.fallbackAction;
|
||||
const shouldUseFallbackAttributesQuery =
|
||||
!hasFallbackAction ||
|
||||
chosenRoute.fallbackAction?.type === RouteActionType.EventTypeRedirectUrl;
|
||||
const teamMembersMatchingAttributeLogicWithResult =
|
||||
formTeamId && formOrgId
|
||||
? await findTeamMembersMatchingAttributeLogic(
|
||||
@@ -144,7 +153,9 @@ const _handleResponse = async ({
|
||||
fields: form.fields || [],
|
||||
},
|
||||
attributesQueryValue: chosenRoute.attributesQueryValue ?? null,
|
||||
fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue,
|
||||
fallbackAttributesQueryValue: shouldUseFallbackAttributesQuery
|
||||
? chosenRoute.fallbackAttributesQueryValue
|
||||
: undefined,
|
||||
teamId: formTeamId,
|
||||
orgId: formOrgId,
|
||||
routeName: chosenRoute.name,
|
||||
@@ -222,6 +233,37 @@ const _handleResponse = async ({
|
||||
};
|
||||
}
|
||||
}
|
||||
const getFallbackAction = () => {
|
||||
if (!chosenRoute || !("fallbackAction" in chosenRoute)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasCrmContactOwner = crmContactOwnerEmail !== null;
|
||||
if (hasCrmContactOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (checkedFallback) {
|
||||
return chosenRoute.fallbackAction;
|
||||
}
|
||||
|
||||
// Handle case where attribute routing was configured but couldn't run (e.g., missing orgId)
|
||||
const attributesQueryValue =
|
||||
"attributesQueryValue" in chosenRoute ? chosenRoute.attributesQueryValue : null;
|
||||
const hasAttributeRoutingConfigured =
|
||||
attributesQueryValue &&
|
||||
typeof attributesQueryValue === "object" &&
|
||||
"children1" in attributesQueryValue &&
|
||||
attributesQueryValue.children1 &&
|
||||
Object.keys(attributesQueryValue.children1 as Record<string, unknown>).length > 0;
|
||||
|
||||
if (hasAttributeRoutingConfigured && teamMemberIdsMatchingAttributeLogic === null) {
|
||||
return chosenRoute.fallbackAction;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
isPreview: !!isPreview,
|
||||
formResponse: dbFormResponse,
|
||||
@@ -238,6 +280,7 @@ const _handleResponse = async ({
|
||||
: null,
|
||||
checkedFallback,
|
||||
timeTaken,
|
||||
fallbackAction: getFallbackAction(),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
Reference in New Issue
Block a user