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, + }); + } +}