From f536100224841fdc4031c4dc41eea39ae6f7603f Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:15:22 -0500 Subject: [PATCH] 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 * 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' Co-Authored-By: joe@cal.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 * 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 * 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 * 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 * 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 * test: Add test coverage for RoutingFormTraceService integration Co-Authored-By: joe@cal.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 * fix: Restore schema and migration from main branch Co-Authored-By: joe@cal.com * fix: Use correct parameter name routingFormTraceService in tests Co-Authored-By: joe@cal.com * fix: Use 'default_route' instead of 'Default Route' for fallback route name Co-Authored-By: joe@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 --- .../routing-forms/lib/processRoute.test.ts | 214 +++++++++++++++ .../routing-forms/lib/processRoute.tsx | 25 +- ...dTeamMembersMatchingAttributeLogic.test.ts | 246 ++++++++++++++++++ .../findTeamMembersMatchingAttributeLogic.ts | 41 ++- .../routing-forms/lib/getRoutedUrl.ts | 40 ++- .../routing-forms/lib/handleResponse.ts | 9 +- .../domains/RoutingFormTraceService.test.ts | 126 +++++++++ .../domains/RoutingFormTraceService.ts | 50 ++++ 8 files changed, 694 insertions(+), 57 deletions(-) create mode 100644 packages/app-store/routing-forms/lib/processRoute.test.ts create mode 100644 packages/features/routing-trace/domains/RoutingFormTraceService.test.ts create mode 100644 packages/features/routing-trace/domains/RoutingFormTraceService.ts diff --git a/packages/app-store/routing-forms/lib/processRoute.test.ts b/packages/app-store/routing-forms/lib/processRoute.test.ts new file mode 100644 index 0000000000..18c5990d23 --- /dev/null +++ b/packages/app-store/routing-forms/lib/processRoute.test.ts @@ -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, "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> => ({}); + + 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", + }); + }); + }); +}); diff --git a/packages/app-store/routing-forms/lib/processRoute.tsx b/packages/app-store/routing-forms/lib/processRoute.tsx index 0ddd624d94..7a7851f0e1 100644 --- a/packages/app-store/routing-forms/lib/processRoute.tsx +++ b/packages/app-store/routing-forms/lib/processRoute.tsx @@ -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, "routes" | "fields">; response: Record>; + 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; } diff --git a/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts b/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts index 2185821a9b..d12e1bb52a 100644 --- a/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts +++ b/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts @@ -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", + }), + ]), + }) + ); + }); + }); }); diff --git a/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts b/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts index 42d373555b..a27fde1338 100644 --- a/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts +++ b/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts @@ -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, }); } }; diff --git a/packages/features/routing-forms/lib/getRoutedUrl.ts b/packages/features/routing-forms/lib/getRoutedUrl.ts index a0d87f26ab..8e0eea5c7e 100644 --- a/packages/features/routing-forms/lib/getRoutedUrl.ts +++ b/packages/features/routing-forms/lib/getRoutedUrl.ts @@ -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 -) => { +const getDeterministicHashForResponse = (fieldsResponses: Record) => { const sortedFields = Object.keys(fieldsResponses) .sort() .reduce((obj: Record, key) => { @@ -56,10 +52,7 @@ export function hasEmbedPath(pathWithQuery: string) { return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/"); } -const _getRoutedUrl = async ( - context: Pick, - fetchCrm = true -) => { +const _getRoutedUrl = async (context: Pick, 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 = {}; 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, }, }; diff --git a/packages/features/routing-forms/lib/handleResponse.ts b/packages/features/routing-forms/lib/handleResponse.ts index bbd1e14e1d..dacbd1bee3 100644 --- a/packages/features/routing-forms/lib/handleResponse.ts +++ b/packages/features/routing-forms/lib/handleResponse.ts @@ -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; diff --git a/packages/features/routing-trace/domains/RoutingFormTraceService.test.ts b/packages/features/routing-trace/domains/RoutingFormTraceService.test.ts new file mode 100644 index 0000000000..1be223b50c --- /dev/null +++ b/packages/features/routing-trace/domains/RoutingFormTraceService.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/features/routing-trace/domains/RoutingFormTraceService.ts b/packages/features/routing-trace/domains/RoutingFormTraceService.ts new file mode 100644 index 0000000000..f4b5cbf024 --- /dev/null +++ b/packages/features/routing-trace/domains/RoutingFormTraceService.ts @@ -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, + }); + } +}