feat: Add RoutingFormTrace (#27316)
* Add DB schema * Init zod schema * Init RoutingTrace and PendingRoutingTrace repository interfaces * Create PrismaPendingRoutingTraceRepository * Init RoutingTraceService * Create RoutingTraceService container * User routing trace service in routing * Create RoutingTraceRepository and PrismaRoutingTraceRepoistory * Add findByFormResponseId and findByQueuedFormResponseId to PendingRoutingTraceRepository * Update DI containers * RoutingTraceService create process booking method * Use pending routing trace rather than URL params * Fix schema * Fix writing assignment reason for routed booking * Remove from service * Refactor RoutingTraceService to not rely on async local storage * Pass RoutingTraceService through routing call * Add attribute-logic-evaluated to routing trace step * Add routing trace to trpc endpoint * Add CRM routing trace step * Fix extracting routing trace to assignment reason * Add back CRM params to prevent refetching * test: Add unit tests for RoutingTraceService and repositories Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * test: Add missing mock for RoutingTraceService in getRoutedUrl tests Also fix pre-existing lint issues in the test file: - Add explicit types to mockForm and mockSerializableForm variables - Add explicit type to url parameter in mockContext - Replace 'as any' with 'as unknown as InstanceType<typeof UserRepository>' Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Link pending form submission to routing trace * Clean up * Add lookup field assignment reasons * Rename to PendingRoutingTrace * Add migration file * fix: Update RoutingTraceService tests to use assignmentReasonRepository mock - Add getStepsCount() method back to RoutingTraceService - Add queuedFormResponseId support to processForBooking method - Update tests to use mockAssignmentReasonRepository instead of prisma mock - Remove test for missing routingTraceRepository (all deps now required) Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: Remove PII (organizer email) from log payload in RoutingTraceService Addresses Cubic AI review feedback with confidence 9/10. Logging PII violates sensitive information logging rules. Co-Authored-By: unknown <> * Write field values at the time of routing * Write attributes used to route * fix: Add CHECK constraint to ensure at least one response ID is set in routing trace tables Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: Address PR review comments for routing trace feature - Add checkedFallback to routing trace step data (Comment 11) - Extract hardcoded domain/step strings to constants (Comment 13) - Use @default(now()) for createdAt in PendingRoutingTrace and RoutingTrace (Comment 15) - Add DEFAULT CURRENT_TIMESTAMP to migration for createdAt fields Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: Use UTC-safe timezone expression for createdAt defaults in routing trace tables Addresses Cubic AI review feedback (confidence 9/10) to prevent timezone issues by using dbgenerated("timezone('UTC', now())") instead of bare now() for the createdAt fields in PendingRoutingTrace and RoutingTrace models. Co-Authored-By: unknown <> * fix: Align migration SQL with Prisma's expected timezone syntax Use 'UTC'::text cast in timezone() function to match Prisma's generated SQL. Co-Authored-By: unknown <> * fix: Revert migration SQL to match Prisma schema timezone syntax Remove ::text cast from timezone() function to match what Prisma generates from the schema definition. Co-Authored-By: unknown <> * fix: Use explicit ::text cast in timezone() for PostgreSQL compatibility PostgreSQL normalizes timezone('UTC', now()) to timezone('UTC'::text, now()) internally. Update both schema and migration to use the explicit cast to ensure they match and pass the migration check. Co-Authored-By: unknown <> * feat: Add RoutingFormTraceService domain class for type-safe routing traces - Create RoutingFormTraceService class with routeMatched, fallbackRouteUsed, attributeLogicEvaluated, and attributeFallbackUsed methods - Add ROUTING_FORM_STEPS constants - Add unit tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: Integrate RoutingFormTraceService for routing form tracing - Add routingFormTraceService parameter to findMatchingRoute - Record route_matched or fallback_route_used step when route is selected - Use "Default Route" as name for form-level fallback routes - Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService - Update handleResponse to accept and pass routingFormTraceService - Create RoutingFormTraceService in getRoutedUrl and pass through the flow Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: Add test coverage for RoutingFormTraceService integration Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat: Integrate RoutingFormTraceService for routing form tracing - Add routingFormTraceService parameter to findMatchingRoute - Record route_matched or fallback_route_used step when route is selected - Use "Default Route" as name for form-level fallback routes - Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService - Update handleResponse to accept and pass routingFormTraceService - Create RoutingFormTraceService in getRoutedUrl and pass through the flow Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Restore schema and migration from main branch Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: Use correct parameter name routingFormTraceService in tests Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: Use 'default_route' instead of 'Default Route' for fallback route name 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>
This commit is contained in:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Claude Opus 4.5
parent
906ba99d3c
commit
f536100224
@@ -0,0 +1,214 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
|
||||
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
|
||||
|
||||
import type { FormResponse, SerializableForm } from "../types/types";
|
||||
import { findMatchingRoute } from "./processRoute";
|
||||
|
||||
vi.mock("@calcom/lib/raqb/evaluateRaqbLogic", () => ({
|
||||
evaluateRaqbLogic: vi.fn(),
|
||||
RaqbLogicResult: {
|
||||
MATCH: "MATCH",
|
||||
NO_MATCH: "NO_MATCH",
|
||||
LOGIC_NOT_FOUND_SO_MATCHED: "LOGIC_NOT_FOUND_SO_MATCHED",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./getQueryBuilderConfig", () => ({
|
||||
getQueryBuilderConfigForFormFields: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
const { evaluateRaqbLogic } = await import("@calcom/lib/raqb/evaluateRaqbLogic");
|
||||
|
||||
describe("findMatchingRoute", () => {
|
||||
let mockRoutingFormTrace: RoutingFormTraceService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRoutingFormTrace = {
|
||||
routeMatched: vi.fn(),
|
||||
fallbackRouteUsed: vi.fn(),
|
||||
attributeLogicEvaluated: vi.fn(),
|
||||
attributeFallbackUsed: vi.fn(),
|
||||
} as unknown as RoutingFormTraceService;
|
||||
});
|
||||
|
||||
const createMockForm = (
|
||||
routes: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
isFallback?: boolean;
|
||||
queryValue?: unknown;
|
||||
}>
|
||||
): Pick<SerializableForm<never>, "routes" | "fields"> => ({
|
||||
routes: routes.map((route) => ({
|
||||
id: route.id,
|
||||
name: route.name,
|
||||
isFallback: route.isFallback ?? false,
|
||||
queryValue: route.queryValue ?? { type: "group" },
|
||||
action: { type: "customPageMessage", value: "test" },
|
||||
})) as never,
|
||||
fields: [],
|
||||
});
|
||||
|
||||
const createMockResponse = (): Record<string, Pick<FormResponse[string], "value">> => ({});
|
||||
|
||||
it("should throw error if fallback route is missing", () => {
|
||||
const form = createMockForm([{ id: "route-1", name: "Route 1" }]);
|
||||
|
||||
expect(() =>
|
||||
findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
})
|
||||
).toThrow("Fallback route is missing");
|
||||
});
|
||||
|
||||
it("should return null if no route matches", () => {
|
||||
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.NO_MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Route 1" },
|
||||
{ id: "fallback", name: "Fallback", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("tracing", () => {
|
||||
it("should call routeMatched when a non-fallback route matches", () => {
|
||||
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Sales Route" },
|
||||
{ id: "fallback", name: "Default", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
routingFormTraceService: mockRoutingFormTrace,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
|
||||
routeId: "route-1",
|
||||
routeName: "Sales Route",
|
||||
});
|
||||
expect(mockRoutingFormTrace.fallbackRouteUsed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call fallbackRouteUsed when fallback route is used", () => {
|
||||
vi.mocked(evaluateRaqbLogic)
|
||||
.mockReturnValueOnce(RaqbLogicResult.NO_MATCH)
|
||||
.mockReturnValueOnce(RaqbLogicResult.MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Sales Route" },
|
||||
{ id: "fallback", name: "Default Route", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
routingFormTraceService: mockRoutingFormTrace,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.fallbackRouteUsed).toHaveBeenCalledWith({
|
||||
routeId: "fallback",
|
||||
routeName: "Default Route",
|
||||
});
|
||||
expect(mockRoutingFormTrace.routeMatched).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use 'default_route' as name when fallback route has no name", () => {
|
||||
vi.mocked(evaluateRaqbLogic)
|
||||
.mockReturnValueOnce(RaqbLogicResult.NO_MATCH)
|
||||
.mockReturnValueOnce(RaqbLogicResult.MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Sales Route" },
|
||||
{ id: "fallback", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
routingFormTraceService: mockRoutingFormTrace,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.fallbackRouteUsed).toHaveBeenCalledWith({
|
||||
routeId: "fallback",
|
||||
routeName: "default_route",
|
||||
});
|
||||
});
|
||||
|
||||
it("should use route id as name when route has no name", () => {
|
||||
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-123" },
|
||||
{ id: "fallback", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
routingFormTraceService: mockRoutingFormTrace,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
|
||||
routeId: "route-123",
|
||||
routeName: "route-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not call trace methods when routingFormTrace is not provided", () => {
|
||||
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Sales Route" },
|
||||
{ id: "fallback", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.routeMatched).not.toHaveBeenCalled();
|
||||
expect(mockRoutingFormTrace.fallbackRouteUsed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle LOGIC_NOT_FOUND_SO_MATCHED as a match", () => {
|
||||
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.LOGIC_NOT_FOUND_SO_MATCHED);
|
||||
|
||||
const form = createMockForm([
|
||||
{ id: "route-1", name: "Auto Match Route" },
|
||||
{ id: "fallback", isFallback: true },
|
||||
]);
|
||||
|
||||
const result = findMatchingRoute({
|
||||
form,
|
||||
response: createMockResponse(),
|
||||
routingFormTraceService: mockRoutingFormTrace,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
|
||||
routeId: "route-1",
|
||||
routeName: "Auto Match Route",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { JsonTree } from "react-awesome-query-builder";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
|
||||
import { evaluateRaqbLogic, RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
|
||||
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
|
||||
|
||||
import type { JsonTree } from "react-awesome-query-builder";
|
||||
import type { z } from "zod";
|
||||
import type { FormResponse, Route, SerializableForm } from "../types/types";
|
||||
import type { zodNonRouterRoute } from "../zod";
|
||||
import { getQueryBuilderConfigForFormFields } from "./getQueryBuilderConfig";
|
||||
@@ -15,9 +14,11 @@ import isRouter from "./isRouter";
|
||||
export function findMatchingRoute({
|
||||
form,
|
||||
response,
|
||||
routingFormTraceService,
|
||||
}: {
|
||||
form: Pick<SerializableForm<App_RoutingForms_Form>, "routes" | "fields">;
|
||||
response: Record<string, Pick<FormResponse[string], "value">>;
|
||||
routingFormTraceService?: RoutingFormTraceService;
|
||||
}) {
|
||||
const queryBuilderConfig = getQueryBuilderConfigForFormFields(form);
|
||||
|
||||
@@ -67,5 +68,21 @@ export function findMatchingRoute({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (routingFormTraceService) {
|
||||
let routeName: string;
|
||||
if ("name" in chosenRoute && chosenRoute.name) {
|
||||
routeName = chosenRoute.name;
|
||||
} else if (isFallbackRoute(chosenRoute)) {
|
||||
routeName = "default_route";
|
||||
} else {
|
||||
routeName = chosenRoute.id;
|
||||
}
|
||||
if (isFallbackRoute(chosenRoute)) {
|
||||
routingFormTraceService.fallbackRouteUsed({ routeId: chosenRoute.id, routeName });
|
||||
} else {
|
||||
routingFormTraceService.routeMatched({ routeId: chosenRoute.id, routeName });
|
||||
}
|
||||
}
|
||||
|
||||
return chosenRoute;
|
||||
}
|
||||
|
||||
@@ -1256,4 +1256,250 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
|
||||
// Should not match anyone as the option ID doesn't exist
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
describe("routingFormTrace integration", () => {
|
||||
it("should call attributeLogicEvaluated when routingFormTrace is provided and main logic matches", async () => {
|
||||
const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" };
|
||||
const Attribute1 = {
|
||||
id: "attr1",
|
||||
name: "Attribute 1",
|
||||
type: "SINGLE_SELECT" as const,
|
||||
slug: "attribute-1",
|
||||
options: [Option1OfAttribute1],
|
||||
};
|
||||
|
||||
mockAttributesScenario({
|
||||
attributes: [Attribute1],
|
||||
teamMembersWithAttributeOptionValuePerAttribute: [
|
||||
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
|
||||
],
|
||||
});
|
||||
|
||||
const attributesQueryValue = buildSelectTypeFieldQueryValue({
|
||||
rules: [
|
||||
{
|
||||
raqbFieldId: Attribute1.id,
|
||||
value: [Option1OfAttribute1.id],
|
||||
operator: "select_equals",
|
||||
},
|
||||
],
|
||||
}) as AttributesQueryValue;
|
||||
|
||||
const mockRoutingFormTrace = {
|
||||
attributeLogicEvaluated: vi.fn(),
|
||||
attributeFallbackUsed: vi.fn(),
|
||||
routeMatched: vi.fn(),
|
||||
fallbackRouteUsed: vi.fn(),
|
||||
};
|
||||
|
||||
await findTeamMembersMatchingAttributeLogic(
|
||||
{
|
||||
dynamicFieldValueOperands: {
|
||||
fields: [],
|
||||
response: {},
|
||||
},
|
||||
attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
routeName: "Test Route",
|
||||
routeIsFallback: false,
|
||||
},
|
||||
{
|
||||
routingFormTraceService: mockRoutingFormTrace as never,
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockRoutingFormTrace.attributeLogicEvaluated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
routeName: "Test Route",
|
||||
routeIsFallback: false,
|
||||
checkedFallback: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should call attributeLogicEvaluated with checkedFallback=true when fallback is used", async () => {
|
||||
const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" };
|
||||
const Option2OfAttribute1 = { id: "opt2", value: "Option 2", slug: "option-2" };
|
||||
const Attribute1 = {
|
||||
id: "attr1",
|
||||
name: "Attribute 1",
|
||||
type: "SINGLE_SELECT" as const,
|
||||
slug: "attribute-1",
|
||||
options: [Option1OfAttribute1, Option2OfAttribute1],
|
||||
};
|
||||
|
||||
mockAttributesScenario({
|
||||
attributes: [Attribute1],
|
||||
teamMembersWithAttributeOptionValuePerAttribute: [
|
||||
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
|
||||
],
|
||||
});
|
||||
|
||||
const failingAttributesQueryValue = buildSelectTypeFieldQueryValue({
|
||||
rules: [
|
||||
{
|
||||
raqbFieldId: Attribute1.id,
|
||||
value: [Option2OfAttribute1.id],
|
||||
operator: "select_equals",
|
||||
},
|
||||
],
|
||||
}) as AttributesQueryValue;
|
||||
|
||||
const matchingFallbackQueryValue = buildSelectTypeFieldQueryValue({
|
||||
rules: [
|
||||
{
|
||||
raqbFieldId: Attribute1.id,
|
||||
value: [Option1OfAttribute1.id],
|
||||
operator: "select_equals",
|
||||
},
|
||||
],
|
||||
}) as AttributesQueryValue;
|
||||
|
||||
const mockRoutingFormTrace = {
|
||||
attributeLogicEvaluated: vi.fn(),
|
||||
attributeFallbackUsed: vi.fn(),
|
||||
routeMatched: vi.fn(),
|
||||
fallbackRouteUsed: vi.fn(),
|
||||
};
|
||||
|
||||
await findTeamMembersMatchingAttributeLogic(
|
||||
{
|
||||
dynamicFieldValueOperands: {
|
||||
fields: [],
|
||||
response: {},
|
||||
},
|
||||
attributesQueryValue: failingAttributesQueryValue,
|
||||
fallbackAttributesQueryValue: matchingFallbackQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
routeName: "Test Route",
|
||||
routeIsFallback: false,
|
||||
},
|
||||
{
|
||||
routingFormTraceService: mockRoutingFormTrace as never,
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockRoutingFormTrace.attributeLogicEvaluated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
routeName: "Test Route",
|
||||
routeIsFallback: false,
|
||||
checkedFallback: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should not call attributeLogicEvaluated when routingFormTrace is not provided", async () => {
|
||||
const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" };
|
||||
const Attribute1 = {
|
||||
id: "attr1",
|
||||
name: "Attribute 1",
|
||||
type: "SINGLE_SELECT" as const,
|
||||
slug: "attribute-1",
|
||||
options: [Option1OfAttribute1],
|
||||
};
|
||||
|
||||
mockAttributesScenario({
|
||||
attributes: [Attribute1],
|
||||
teamMembersWithAttributeOptionValuePerAttribute: [
|
||||
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
|
||||
],
|
||||
});
|
||||
|
||||
const attributesQueryValue = buildSelectTypeFieldQueryValue({
|
||||
rules: [
|
||||
{
|
||||
raqbFieldId: Attribute1.id,
|
||||
value: [Option1OfAttribute1.id],
|
||||
operator: "select_equals",
|
||||
},
|
||||
],
|
||||
}) as AttributesQueryValue;
|
||||
|
||||
const mockRoutingFormTrace = {
|
||||
attributeLogicEvaluated: vi.fn(),
|
||||
attributeFallbackUsed: vi.fn(),
|
||||
routeMatched: vi.fn(),
|
||||
fallbackRouteUsed: vi.fn(),
|
||||
};
|
||||
|
||||
await findTeamMembersMatchingAttributeLogic(
|
||||
{
|
||||
dynamicFieldValueOperands: {
|
||||
fields: [],
|
||||
response: {},
|
||||
},
|
||||
attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(mockRoutingFormTrace.attributeLogicEvaluated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include attributeRoutingDetails in trace when attributes are used", async () => {
|
||||
const Option1OfAttribute1 = { id: "opt1", value: "Enterprise", slug: "enterprise" };
|
||||
const Attribute1 = {
|
||||
id: "attr1",
|
||||
name: "Company Size",
|
||||
type: "SINGLE_SELECT" as const,
|
||||
slug: "company-size",
|
||||
options: [Option1OfAttribute1],
|
||||
};
|
||||
|
||||
mockAttributesScenario({
|
||||
attributes: [Attribute1],
|
||||
teamMembersWithAttributeOptionValuePerAttribute: [
|
||||
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
|
||||
],
|
||||
});
|
||||
|
||||
const attributesQueryValue = buildSelectTypeFieldQueryValue({
|
||||
rules: [
|
||||
{
|
||||
raqbFieldId: Attribute1.id,
|
||||
value: [Option1OfAttribute1.id],
|
||||
operator: "select_equals",
|
||||
},
|
||||
],
|
||||
}) as AttributesQueryValue;
|
||||
|
||||
const mockRoutingFormTrace = {
|
||||
attributeLogicEvaluated: vi.fn(),
|
||||
attributeFallbackUsed: vi.fn(),
|
||||
routeMatched: vi.fn(),
|
||||
fallbackRouteUsed: vi.fn(),
|
||||
};
|
||||
|
||||
await findTeamMembersMatchingAttributeLogic(
|
||||
{
|
||||
dynamicFieldValueOperands: {
|
||||
fields: [],
|
||||
response: {},
|
||||
},
|
||||
attributesQueryValue,
|
||||
teamId: 1,
|
||||
orgId,
|
||||
routeName: "Enterprise Route",
|
||||
routeIsFallback: false,
|
||||
},
|
||||
{
|
||||
routingFormTraceService: mockRoutingFormTrace as never,
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockRoutingFormTrace.attributeLogicEvaluated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attributeRoutingDetails: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
attributeName: "Company Size",
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils";
|
||||
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { getAttributesAssignmentData } from "@calcom/features/attributes/lib/getAttributes";
|
||||
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
|
||||
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
|
||||
import jsonLogic from "@calcom/lib/raqb/jsonLogic";
|
||||
import type { AttributesQueryValue, dynamicFieldValueOperands } from "@calcom/lib/raqb/types";
|
||||
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 { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils";
|
||||
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { getAttributesAssignmentData } from "@calcom/features/attributes/lib/getAttributes";
|
||||
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
|
||||
import {
|
||||
ROUTING_TRACE_DOMAINS,
|
||||
ROUTING_TRACE_STEPS,
|
||||
} from "@calcom/features/routing-trace/services/RoutingTraceService";
|
||||
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
|
||||
import jsonLogic from "@calcom/lib/raqb/jsonLogic";
|
||||
import type { dynamicFieldValueOperands, AttributesQueryValue } from "@calcom/lib/raqb/types";
|
||||
|
||||
const {
|
||||
getAttributesData: getAttributes,
|
||||
getAttributesQueryBuilderConfigHavingListofLabels,
|
||||
@@ -41,7 +36,7 @@ type RunAttributeLogicOptions = {
|
||||
enableTroubleshooter: boolean;
|
||||
};
|
||||
|
||||
export const enum TroubleshooterCase {
|
||||
export enum TroubleshooterCase {
|
||||
IS_A_ROUTER = "is-a-router",
|
||||
NO_LOGIC_FOUND = "no-logic-found",
|
||||
MATCH_RESULTS_READY = "match-results-ready",
|
||||
@@ -467,11 +462,11 @@ export async function findTeamMembersMatchingAttributeLogic(
|
||||
enablePerf?: boolean;
|
||||
concurrency?: number;
|
||||
enableTroubleshooter?: boolean;
|
||||
routingTraceService?: RoutingTraceService;
|
||||
routingFormTraceService?: RoutingFormTraceService;
|
||||
} = {}
|
||||
) {
|
||||
// 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, routingTraceService } = options;
|
||||
const { enablePerf = false, concurrency = 2, enableTroubleshooter = false, routingFormTraceService } = options;
|
||||
|
||||
// Any explicit value being passed should cause fallback to be considered. Even undefined
|
||||
const considerFallback = "fallbackAttributesQueryValue" in data;
|
||||
@@ -528,22 +523,18 @@ export async function findTeamMembersMatchingAttributeLogic(
|
||||
|
||||
// Helper to add trace step for attribute logic evaluation
|
||||
const addTraceStep = (checkedFallback: boolean) => {
|
||||
if (routingTraceService) {
|
||||
if (routingFormTraceService) {
|
||||
const attributeRoutingDetails = extractAttributeRoutingDetails({
|
||||
resolvedAttributesQueryValue,
|
||||
attributesOfTheOrg,
|
||||
dynamicFieldValueOperands,
|
||||
});
|
||||
|
||||
routingTraceService.addStep({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_TRACE_STEPS.ATTRIBUTE_LOGIC_EVALUATED,
|
||||
data: {
|
||||
routeName,
|
||||
routeIsFallback,
|
||||
checkedFallback,
|
||||
attributeRoutingDetails,
|
||||
},
|
||||
routingFormTraceService.attributeLogicEvaluated({
|
||||
routeName,
|
||||
routeIsFallback,
|
||||
checkedFallback,
|
||||
attributeRoutingDetails,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
// !IMPORTANT! changes to this file requires publishing new version of platform libraries in order for the changes to be applied to APIV2
|
||||
import { createHash } from "node:crypto";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { stringify } from "node:querystring";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
|
||||
import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData";
|
||||
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
|
||||
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
||||
@@ -17,6 +13,7 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomain
|
||||
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
|
||||
import { PrismaRoutingFormRepository } from "@calcom/features/routing-forms/repositories/PrismaRoutingFormRepository";
|
||||
import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container";
|
||||
import { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -24,9 +21,10 @@ import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import z from "zod";
|
||||
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
|
||||
import { handleResponse } from "./handleResponse";
|
||||
|
||||
@@ -37,9 +35,7 @@ const querySchema = z
|
||||
})
|
||||
.catchall(z.string().or(z.array(z.string())));
|
||||
|
||||
const getDeterministicHashForResponse = (
|
||||
fieldsResponses: Record<string, unknown>
|
||||
) => {
|
||||
const getDeterministicHashForResponse = (fieldsResponses: Record<string, unknown>) => {
|
||||
const sortedFields = Object.keys(fieldsResponses)
|
||||
.sort()
|
||||
.reduce((obj: Record<string, unknown>, key) => {
|
||||
@@ -56,10 +52,7 @@ export function hasEmbedPath(pathWithQuery: string) {
|
||||
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
|
||||
}
|
||||
|
||||
const _getRoutedUrl = async (
|
||||
context: Pick<GetServerSidePropsContext, "query" | "req">,
|
||||
fetchCrm = true
|
||||
) => {
|
||||
const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" | "req">, fetchCrm = true) => {
|
||||
// Initialize trace service for tracking routing decisions
|
||||
const routingTraceService = getRoutingTraceService();
|
||||
|
||||
@@ -96,9 +89,7 @@ const _getRoutedUrl = async (
|
||||
const paramsToBeForwardedAsIs = {
|
||||
...fieldsResponses,
|
||||
// Must be forwarded if present to Booking Page. Setting it explicitly here as it is critical to be present in the URL.
|
||||
...(isBookingDryRunParam
|
||||
? { "cal.isBookingDryRun": isBookingDryRunParam }
|
||||
: null),
|
||||
...(isBookingDryRunParam ? { "cal.isBookingDryRun": isBookingDryRunParam } : null),
|
||||
};
|
||||
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
@@ -106,8 +97,7 @@ const _getRoutedUrl = async (
|
||||
let timeTaken: Record<string, number | null> = {};
|
||||
|
||||
const formQueryStart = performance.now();
|
||||
const form =
|
||||
await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
|
||||
const form = await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
|
||||
timeTaken.formQuery = performance.now() - formQueryStart;
|
||||
|
||||
if (!form) {
|
||||
@@ -150,7 +140,11 @@ const _getRoutedUrl = async (
|
||||
fieldsResponses,
|
||||
});
|
||||
|
||||
const matchingRoute = findMatchingRoute({ form: serializableForm, response });
|
||||
let routingFormTraceService: RoutingFormTraceService | undefined;
|
||||
if (!isBookingDryRun) {
|
||||
routingFormTraceService = new RoutingFormTraceService(routingTraceService);
|
||||
}
|
||||
const matchingRoute = findMatchingRoute({ form: serializableForm, response, routingFormTraceService });
|
||||
if (!matchingRoute) {
|
||||
throw new Error("No matching route could be found");
|
||||
}
|
||||
@@ -175,9 +169,9 @@ const _getRoutedUrl = async (
|
||||
queueFormResponse: shouldQueueFormResponse,
|
||||
fetchCrm,
|
||||
traceService: isBookingDryRun ? undefined : routingTraceService,
|
||||
routingFormTraceService,
|
||||
});
|
||||
teamMembersMatchingAttributeLogic =
|
||||
result.teamMembersMatchingAttributeLogic;
|
||||
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
|
||||
formResponseId = result.formResponse?.id;
|
||||
queuedFormResponseId = result.queuedFormResponse?.id;
|
||||
attributeRoutingConfig = result.attributeRoutingConfig;
|
||||
@@ -266,9 +260,7 @@ const _getRoutedUrl = async (
|
||||
} else if (decidedAction.type === "externalRedirectUrl") {
|
||||
return {
|
||||
redirect: {
|
||||
destination: `${decidedAction.value}?${stringify(
|
||||
context.query
|
||||
)}&cal.action=externalRedirectUrl`,
|
||||
destination: `${decidedAction.value}?${stringify(context.query)}&cal.action=externalRedirectUrl`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import routerGetCrmContactOwnerEmail from "@calcom/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail";
|
||||
import {
|
||||
onSubmissionOfFormResponse,
|
||||
type TargetRoutingFormForResponse,
|
||||
} from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
||||
import isRouter from "@calcom/app-store/routing-forms/lib/isRouter";
|
||||
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";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -15,7 +14,7 @@ import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { findTeamMembersMatchingAttributeLogic } from "./findTeamMembersMatchingAttributeLogic";
|
||||
|
||||
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/handleResponse"] });
|
||||
@@ -31,6 +30,7 @@ const _handleResponse = async ({
|
||||
queueFormResponse,
|
||||
fetchCrm,
|
||||
traceService,
|
||||
routingFormTraceService,
|
||||
}: {
|
||||
response: Record<
|
||||
string,
|
||||
@@ -48,6 +48,7 @@ const _handleResponse = async ({
|
||||
queueFormResponse?: boolean;
|
||||
fetchCrm?: boolean;
|
||||
traceService?: RoutingTraceService;
|
||||
routingFormTraceService?: RoutingFormTraceService;
|
||||
}) => {
|
||||
try {
|
||||
if (!form.fields) {
|
||||
@@ -151,7 +152,7 @@ const _handleResponse = async ({
|
||||
},
|
||||
{
|
||||
enablePerf: true,
|
||||
routingTraceService: traceService,
|
||||
routingFormTraceService,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RoutingTraceService } from "../services/RoutingTraceService";
|
||||
import { ROUTING_TRACE_DOMAINS } from "../services/RoutingTraceService";
|
||||
import { ROUTING_FORM_STEPS, RoutingFormTraceService } from "./RoutingFormTraceService";
|
||||
|
||||
describe("RoutingFormTraceService", () => {
|
||||
let mockTraceService: RoutingTraceService;
|
||||
let routingFormTraceService: RoutingFormTraceService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTraceService = {
|
||||
addStep: vi.fn(),
|
||||
} as unknown as RoutingTraceService;
|
||||
routingFormTraceService = new RoutingFormTraceService(mockTraceService);
|
||||
});
|
||||
|
||||
describe("routeMatched", () => {
|
||||
it("should add a route_matched step with correct data", () => {
|
||||
routingFormTraceService.routeMatched({
|
||||
routeId: "route-123",
|
||||
routeName: "Sales Route",
|
||||
});
|
||||
|
||||
expect(mockTraceService.addStep).toHaveBeenCalledWith({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_FORM_STEPS.ROUTE_MATCHED,
|
||||
data: {
|
||||
routeId: "route-123",
|
||||
routeName: "Sales Route",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallbackRouteUsed", () => {
|
||||
it("should add a fallback_route_used step with correct data", () => {
|
||||
routingFormTraceService.fallbackRouteUsed({
|
||||
routeId: "fallback-route",
|
||||
routeName: "Default Route",
|
||||
});
|
||||
|
||||
expect(mockTraceService.addStep).toHaveBeenCalledWith({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_FORM_STEPS.FALLBACK_ROUTE_USED,
|
||||
data: {
|
||||
routeId: "fallback-route",
|
||||
routeName: "Default Route",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("attributeLogicEvaluated", () => {
|
||||
it("should add an attribute-logic-evaluated step with routing details", () => {
|
||||
routingFormTraceService.attributeLogicEvaluated({
|
||||
routeName: "Enterprise Route",
|
||||
routeIsFallback: false,
|
||||
checkedFallback: true,
|
||||
attributeRoutingDetails: [
|
||||
{ attributeName: "Company Size", attributeValue: "Enterprise" },
|
||||
{ attributeName: "Region", attributeValue: "APAC" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockTraceService.addStep).toHaveBeenCalledWith({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_FORM_STEPS.ATTRIBUTE_LOGIC_EVALUATED,
|
||||
data: {
|
||||
routeName: "Enterprise Route",
|
||||
routeIsFallback: false,
|
||||
checkedFallback: true,
|
||||
attributeRoutingDetails: [
|
||||
{ attributeName: "Company Size", attributeValue: "Enterprise" },
|
||||
{ attributeName: "Region", attributeValue: "APAC" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should add an attribute-logic-evaluated step with minimal data", () => {
|
||||
routingFormTraceService.attributeLogicEvaluated({});
|
||||
|
||||
expect(mockTraceService.addStep).toHaveBeenCalledWith({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_FORM_STEPS.ATTRIBUTE_LOGIC_EVALUATED,
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("attributeFallbackUsed", () => {
|
||||
it("should add an attribute_fallback_used step with route name", () => {
|
||||
routingFormTraceService.attributeFallbackUsed({
|
||||
routeName: "Fallback Route",
|
||||
});
|
||||
|
||||
expect(mockTraceService.addStep).toHaveBeenCalledWith({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_FORM_STEPS.ATTRIBUTE_FALLBACK_USED,
|
||||
data: {
|
||||
routeName: "Fallback Route",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should add an attribute_fallback_used step with undefined route name", () => {
|
||||
routingFormTraceService.attributeFallbackUsed({});
|
||||
|
||||
expect(mockTraceService.addStep).toHaveBeenCalledWith({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_FORM_STEPS.ATTRIBUTE_FALLBACK_USED,
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ROUTING_FORM_STEPS constants", () => {
|
||||
it("should have correct step values", () => {
|
||||
expect(ROUTING_FORM_STEPS.ROUTE_MATCHED).toBe("route_matched");
|
||||
expect(ROUTING_FORM_STEPS.FALLBACK_ROUTE_USED).toBe("fallback_route_used");
|
||||
expect(ROUTING_FORM_STEPS.ATTRIBUTE_LOGIC_EVALUATED).toBe("attribute-logic-evaluated");
|
||||
expect(ROUTING_FORM_STEPS.ATTRIBUTE_FALLBACK_USED).toBe("attribute_fallback_used");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { RoutingTraceService } from "../services/RoutingTraceService";
|
||||
import { ROUTING_TRACE_DOMAINS } from "../services/RoutingTraceService";
|
||||
|
||||
export const ROUTING_FORM_STEPS = {
|
||||
ROUTE_MATCHED: "route_matched",
|
||||
FALLBACK_ROUTE_USED: "fallback_route_used",
|
||||
ATTRIBUTE_LOGIC_EVALUATED: "attribute-logic-evaluated",
|
||||
ATTRIBUTE_FALLBACK_USED: "attribute_fallback_used",
|
||||
} as const;
|
||||
|
||||
export class RoutingFormTraceService {
|
||||
constructor(private readonly traceService: RoutingTraceService) {}
|
||||
|
||||
routeMatched(data: { routeId: string; routeName: string }): void {
|
||||
this.traceService.addStep({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_FORM_STEPS.ROUTE_MATCHED,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
fallbackRouteUsed(data: { routeId: string; routeName: string }): void {
|
||||
this.traceService.addStep({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_FORM_STEPS.FALLBACK_ROUTE_USED,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
attributeLogicEvaluated(data: {
|
||||
routeName?: string;
|
||||
routeIsFallback?: boolean;
|
||||
checkedFallback?: boolean;
|
||||
attributeRoutingDetails?: Array<{ attributeName: string; attributeValue: string }>;
|
||||
}): void {
|
||||
this.traceService.addStep({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_FORM_STEPS.ATTRIBUTE_LOGIC_EVALUATED,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
attributeFallbackUsed(data: { routeName?: string }): void {
|
||||
this.traceService.addStep({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_FORM_STEPS.ATTRIBUTE_FALLBACK_USED,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user