From 620445d85fe2e30d71e60de30aac1fa407b6e6f8 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:45:15 -0500 Subject: [PATCH] feat: Use `RoutingTraceService` to write assignment reasons (#27225) * 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 <> * fix: Use CURRENT_TIMESTAMP for createdAt defaults (standard Cal.com pattern) Co-Authored-By: joe@cal.com * fix: Use @default(now()) for createdAt in routing trace tables to match migration Co-Authored-By: joe@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Pedro Castro --- .../routing-forms/queued-response/route.ts | 13 + .../routerGetCrmContactOwnerEmail.ts | 57 +- .../lib/service/RegularBookingService.ts | 28 +- .../findTeamMembersMatchingAttributeLogic.ts | 177 ++++- .../routing-forms/lib/getRoutedUrl.test.ts | 52 +- .../routing-forms/lib/getRoutedUrl.ts | 62 +- .../lib/getUrlSearchParamsToForward.ts | 18 +- .../routing-forms/lib/handleResponse.ts | 10 + .../PendingRoutingTraceRepository.module.ts | 23 + .../di/RoutingTraceRepository.module.ts | 23 + .../di/RoutingTraceService.container.ts | 37 + packages/features/routing-trace/di/tokens.ts | 8 + ...PendingRoutingTraceRepository.interface.ts | 43 ++ ...rismaPendingRoutingTraceRepository.test.ts | 208 ++++++ .../PrismaPendingRoutingTraceRepository.ts | 64 ++ .../PrismaRoutingTraceRepository.test.ts | 252 +++++++ .../PrismaRoutingTraceRepository.ts | 30 + .../RoutingTraceRepository.interface.ts | 38 + packages/features/routing-trace/schema/zod.ts | 8 + .../services/RoutingTraceService.test.ts | 663 ++++++++++++++++++ .../services/RoutingTraceService.ts | 269 +++++++ .../migration.sql | 65 ++ packages/prisma/schema.prisma | 54 +- .../viewer/routing-forms/response.handler.ts | 21 +- 24 files changed, 2152 insertions(+), 71 deletions(-) create mode 100644 packages/features/routing-trace/di/PendingRoutingTraceRepository.module.ts create mode 100644 packages/features/routing-trace/di/RoutingTraceRepository.module.ts create mode 100644 packages/features/routing-trace/di/RoutingTraceService.container.ts create mode 100644 packages/features/routing-trace/di/tokens.ts create mode 100644 packages/features/routing-trace/repositories/PendingRoutingTraceRepository.interface.ts create mode 100644 packages/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository.test.ts create mode 100644 packages/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository.ts create mode 100644 packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.test.ts create mode 100644 packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.ts create mode 100644 packages/features/routing-trace/repositories/RoutingTraceRepository.interface.ts create mode 100644 packages/features/routing-trace/schema/zod.ts create mode 100644 packages/features/routing-trace/services/RoutingTraceService.test.ts create mode 100644 packages/features/routing-trace/services/RoutingTraceService.ts create mode 100644 packages/prisma/migrations/20260127035022_add_routing_trace_tables/migration.sql diff --git a/apps/web/app/api/routing-forms/queued-response/route.ts b/apps/web/app/api/routing-forms/queued-response/route.ts index e2f52acfb9..719c3b7cde 100644 --- a/apps/web/app/api/routing-forms/queued-response/route.ts +++ b/apps/web/app/api/routing-forms/queued-response/route.ts @@ -5,6 +5,7 @@ import { z, ZodError } from "zod"; import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore"; import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm"; +import { PrismaPendingRoutingTraceRepository } from "@calcom/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; @@ -58,6 +59,18 @@ export const queuedResponseHandler = async ({ chosenRouteId: queuedFormResponse.chosenRouteId, }); + // Link the pending routing trace to the new formResponseId so it can be found when booking is created + try { + const pendingTraceRepo = new PrismaPendingRoutingTraceRepository(prisma); + await pendingTraceRepo.linkToFormResponse({ + queuedFormResponseId: queuedFormResponse.id, + formResponseId: formResponse.id, + }); + } catch (error) { + // Log but don't fail - trace linking is not critical + logger.warn("Failed to link pending routing trace to form response", safeStringify(error)); + } + const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId); await onSubmissionOfFormResponse({ form: { diff --git a/packages/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail.ts b/packages/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail.ts index 863edb3d7c..4df620448c 100644 --- a/packages/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail.ts +++ b/packages/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail.ts @@ -1,5 +1,6 @@ import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip"; import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; +import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService"; import { prisma } from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -10,10 +11,12 @@ export default async function routerGetCrmContactOwnerEmail({ attributeRoutingConfig, identifierKeyedResponse, action, + routingTraceService, }: { attributeRoutingConfig: LocalRoute["attributeRoutingConfig"]; identifierKeyedResponse: Record | null; action: LocalRoute["action"]; + routingTraceService?: RoutingTraceService; }) { // Check if route is skipping CRM contact check if (attributeRoutingConfig?.skipContactOwner) return null; @@ -23,7 +26,8 @@ export default async function routerGetCrmContactOwnerEmail({ for (const identifier of Object.keys(identifierKeyedResponse)) { const fieldResponse = identifierKeyedResponse[identifier]; if (identifier === "email") { - prospectEmail = fieldResponse instanceof Array ? fieldResponse[0] : fieldResponse; + prospectEmail = + fieldResponse instanceof Array ? fieldResponse[0] : fieldResponse; break; } } @@ -31,11 +35,15 @@ export default async function routerGetCrmContactOwnerEmail({ if (!prospectEmail) return null; // Determine if the action is an event type redirect - if (action.type !== "eventTypeRedirectUrl" || !action.eventTypeId) return null; + if (action.type !== "eventTypeRedirectUrl" || !action.eventTypeId) + return null; const eventTypeRepo = new EventTypeRepository(prisma); - const eventType = await eventTypeRepo.findByIdIncludeHostsAndTeam({ id: action.eventTypeId }); - if (!eventType || eventType.schedulingType !== SchedulingType.ROUND_ROBIN) return null; + const eventType = await eventTypeRepo.findByIdIncludeHostsAndTeam({ + id: action.eventTypeId, + }); + if (!eventType || eventType.schedulingType !== SchedulingType.ROUND_ROBIN) + return null; const eventTypeMetadata = eventType.metadata; if (!eventTypeMetadata) return null; @@ -53,16 +61,23 @@ export default async function routerGetCrmContactOwnerEmail({ }; // Determine if there is a CRM option enabled in the chosen route for (const appSlug of enabledAppSlugs) { - const routingOptions = attributeRoutingConfig?.[appSlug as keyof typeof attributeRoutingConfig]; + const routingOptions = + attributeRoutingConfig?.[appSlug as keyof typeof attributeRoutingConfig]; if (!routingOptions) continue; // See if any options are true if (Object.values(routingOptions).some((option) => option === true)) { - const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler")) - .default; - const appHandler = appBookingFormHandler[appSlug as keyof typeof appBookingFormHandler]; + const appBookingFormHandler = ( + await import("@calcom/app-store/routing-forms/appBookingFormHandler") + ).default; + const appHandler = + appBookingFormHandler[appSlug as keyof typeof appBookingFormHandler]; - const ownerQuery = await appHandler(prospectEmail, attributeRoutingConfig, action.eventTypeId); + const ownerQuery = await appHandler( + prospectEmail, + attributeRoutingConfig, + action.eventTypeId + ); if (ownerQuery?.email) { contactOwner = { ...ownerQuery, crmAppSlug: appSlug }; @@ -72,13 +87,33 @@ export default async function routerGetCrmContactOwnerEmail({ } if (!contactOwner || (!contactOwner.email && !contactOwner.recordType)) { - const ownerQuery = await getCRMContactOwnerForRRLeadSkip(prospectEmail, eventTypeMetadata); + const ownerQuery = await getCRMContactOwnerForRRLeadSkip( + prospectEmail, + eventTypeMetadata + ); if (ownerQuery?.email) contactOwner = ownerQuery; } if (!contactOwner) return null; // Check that the contact owner is a part of the event type - if (!eventType.hosts.some((host) => host.user.email === contactOwner.email)) return null; + if (!eventType.hosts.some((host) => host.user.email === contactOwner.email)) + return null; + + if (routingTraceService && contactOwner.email && contactOwner.crmAppSlug) { + const salesforceSettings = attributeRoutingConfig?.salesforce; + routingTraceService.addStep({ + domain: contactOwner.crmAppSlug, + step: `${contactOwner.crmAppSlug}_assignment`, + data: { + email: contactOwner.email, + recordType: contactOwner.recordType, + recordId: contactOwner.recordId, + crmAppSlug: contactOwner.crmAppSlug, + rrSkipToAccountLookupField: salesforceSettings?.rrSkipToAccountLookupField, + rrSKipToAccountLookupFieldName: salesforceSettings?.rrSKipToAccountLookupFieldName, + }, + }); + } return contactOwner; } diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index f6506bbd0e..6ce8b12347 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -46,6 +46,7 @@ import { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/B import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container"; import { CreditService } from "@calcom/features/ee/billing/credit-service"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; +import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container"; import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder"; import { BookingLocationService } from "@calcom/features/ee/round-robin/lib/bookingLocationService"; import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; @@ -1945,25 +1946,22 @@ async function handler( } // If it's a round robin event, record the reason for the host assignment - if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) { - if (reqBody.crmOwnerRecordType && reqBody.crmAppSlug && contactOwnerEmail && routingFormResponseId) { - assignmentReason = await AssignmentReasonRecorder.CRMOwnership({ + if (eventType.schedulingType === SchedulingType.ROUND_ROBIN && routingFormResponseId) { + try { + const routingTraceService = getRoutingTraceService(); + const result = await routingTraceService.processForBooking({ + formResponseId: routingFormResponseId, bookingId: booking.id, - crmAppSlug: reqBody.crmAppSlug, - teamMemberEmail: contactOwnerEmail, - recordType: reqBody.crmOwnerRecordType, - routingFormResponseId, - recordId: crmRecordId, - }); - } else if (routingFormResponseId && teamId) { - assignmentReason = await AssignmentReasonRecorder.routingFormRoute({ - bookingId: booking.id, - routingFormResponseId, - organizerId: organizerUser.id, - teamId, + bookingUid: booking.uid, + organizerEmail: organizerUser.email, isRerouting: !!reroutingFormResponses, reroutedByEmail: reqBody.rescheduledBy, }); + if (result?.assignmentReason) { + assignmentReason = result.assignmentReason; + } + } catch (error) { + criticalLogger.warn("Failed to process routing trace", { error }); } } diff --git a/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts b/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts index d62fecf35a..42d373555b 100644 --- a/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts +++ b/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts @@ -6,6 +6,11 @@ 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"; @@ -130,6 +135,136 @@ function buildTroubleshooterData({ type, data }: { type: TroubleshooterCase; dat }; } +/** + * Checks if a string is a field template placeholder (e.g., "{field:uuid}") + */ +function isFieldTemplate(str: string): boolean { + const trimmedStr = str.trim(); + return trimmedStr.startsWith("{field:") && trimmedStr.endsWith("}"); +} + +/** + * Extracts field ID from a field template string + */ +function extractFieldIdFromTemplate(template: string): string | null { + const trimmedTemplate = template.trim(); + if (!isFieldTemplate(trimmedTemplate)) return null; + return trimmedTemplate.slice(7, -1); +} + +/** + * Resolves a field template to its actual value from the form response + */ +function resolveFieldTemplateValue( + value: string, + dynamicFieldValueOperands?: dynamicFieldValueOperands +): string { + if (!isFieldTemplate(value) || !dynamicFieldValueOperands) { + return value; + } + + const fieldId = extractFieldIdFromTemplate(value); + if (!fieldId) { + return value; + } + + const { fields, response } = dynamicFieldValueOperands; + const field = fields.find((f) => f.id === fieldId); + + if (!field) { + // Field definition not found - return original template + return value; + } + + const fieldResponse = response[field.id]; + if (!fieldResponse) { + // Field not in response object - return original template + return value; + } + + const fieldValue = fieldResponse.value; + if (fieldValue === null || fieldValue === undefined || fieldValue === "") { + return "(empty)"; + } + + if (Array.isArray(fieldValue)) { + return fieldValue.join(", "); + } + + return String(fieldValue); +} + +/** + * Extracts attribute name/value pairs from a resolved attributesQueryValue. + * This is used to build the assignment reason string. + */ +function extractAttributeRoutingDetails({ + resolvedAttributesQueryValue, + attributesOfTheOrg, + dynamicFieldValueOperands, +}: { + resolvedAttributesQueryValue: AttributesQueryValue | null; + attributesOfTheOrg: Attribute[]; + dynamicFieldValueOperands?: dynamicFieldValueOperands; +}): Array<{ attributeName: string; attributeValue: string }> { + if (!resolvedAttributesQueryValue) { + return []; + } + + if (!raqbQueryValueUtils.isQueryValueARuleGroup(resolvedAttributesQueryValue as JsonTree)) { + return []; + } + + const children1 = resolvedAttributesQueryValue.children1; + if (!children1) { + return []; + } + + const result: Array<{ attributeName: string; attributeValue: string }> = []; + + for (const ruleId of Object.keys(children1)) { + const rule = children1[ruleId]; + // Check if it's a rule (has properties with field) + if (rule.type !== "rule" || !rule.properties) { + continue; + } + + const properties = rule.properties; + const fieldId = properties.field; + const value = properties.value; + + if (!fieldId || !value || value[0] === null || value[0] === undefined) { + continue; + } + + const attribute = attributesOfTheOrg.find((attr) => attr.id === fieldId); + if (!attribute) { + continue; + } + + const attributeValueString = (() => { + const firstValue = value[0]; + if (Array.isArray(firstValue)) { + // Handle array values - resolve any field templates within + const resolvedValues = firstValue.map((v) => + typeof v === "string" ? resolveFieldTemplateValue(v, dynamicFieldValueOperands) : String(v) + ); + return resolvedValues.join(", "); + } + const stringValue = String(firstValue); + // Resolve field template if present + return resolveFieldTemplateValue(stringValue, dynamicFieldValueOperands); + })(); + + result.push({ + attributeName: attribute.name, + attributeValue: attributeValueString, + }); + } + + return result; +} + async function getLogicResultForAllMembers( { teamMembersWithAttributeOptionValuePerAttribute, @@ -193,6 +328,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib return { logicBuildingWarnings: null, teamMembersMatchingAttributeLogic: null, + resolvedAttributesQueryValue: null, timeTaken: null, ...buildTroubleshooterData({ type: TroubleshooterCase.MATCHES_ALL_MEMBERS_BECAUSE_OF_EMPTY_QUERY_VALUE, @@ -217,6 +353,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib return { teamMembersMatchingAttributeLogic: null, logicBuildingWarnings: null, + resolvedAttributesQueryValue: attributesQueryValue, timeTaken: { ttgetAttributesQueryBuilderConfigHavingListofLabels, }, @@ -256,6 +393,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib return { teamMembersMatchingAttributeLogic, logicBuildingWarnings, + resolvedAttributesQueryValue: attributesQueryValue, timeTaken: { ttgetAttributesQueryBuilderConfigHavingListofLabels, ttTeamMembersMatchingAttributeLogic, @@ -322,15 +460,18 @@ export async function findTeamMembersMatchingAttributeLogic( fallbackAttributesQueryValue?: AttributesQueryValue | null; dynamicFieldValueOperands?: dynamicFieldValueOperands; isPreview?: boolean; + routeName?: string; + routeIsFallback?: boolean; }, options: { enablePerf?: boolean; concurrency?: number; enableTroubleshooter?: boolean; + routingTraceService?: RoutingTraceService; } = {} ) { // Higher value of concurrency might not be performant as it might overwhelm the system. So, use a lower value as default. - const { enablePerf = false, concurrency = 2, enableTroubleshooter = false } = options; + const { enablePerf = false, concurrency = 2, enableTroubleshooter = false, routingTraceService } = options; // Any explicit value being passed should cause fallback to be considered. Even undefined const considerFallback = "fallbackAttributesQueryValue" in data; @@ -342,6 +483,8 @@ export async function findTeamMembersMatchingAttributeLogic( fallbackAttributesQueryValue, dynamicFieldValueOperands, isPreview, + routeName, + routeIsFallback, } = data; const { @@ -374,6 +517,7 @@ export async function findTeamMembersMatchingAttributeLogic( timeTaken: teamMembersMatchingMainAttributeLogicTimeTaken, troubleshooter, logicBuildingWarnings: mainAttributeLogicBuildingWarnings, + resolvedAttributesQueryValue, } = await runMainAttributeLogic( { ...runAttributeLogicData, @@ -382,14 +526,39 @@ export async function findTeamMembersMatchingAttributeLogic( runAttributeLogicOptions ); + // Helper to add trace step for attribute logic evaluation + const addTraceStep = (checkedFallback: boolean) => { + if (routingTraceService) { + 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, + }, + }); + } + }; + // It being null means that no logic was found and thus all members match. In such case, we don't fallback intentionally. // This is the case when user added no rules so, he expects to match all members if (!teamMembersMatchingMainAttributeLogic) { + addTraceStep(false); return { teamMembersMatchingAttributeLogic: null, checkedFallback: false, mainAttributeLogicBuildingWarnings, fallbackAttributeLogicBuildingWarnings: [], + routeName, + routeIsFallback, timeTaken: { ...teamMembersMatchingMainAttributeLogicTimeTaken, ttGetAttributesForLogic, @@ -418,11 +587,14 @@ export async function findTeamMembersMatchingAttributeLogic( runAttributeLogicOptions ); + addTraceStep(true); return { teamMembersMatchingAttributeLogic: teamMembersMatchingFallbackLogic, checkedFallback: true, fallbackAttributeLogicBuildingWarnings, mainAttributeLogicBuildingWarnings, + routeName, + routeIsFallback, timeTaken: { ...teamMembersMatchingFallbackLogicTimeTaken, ttGetAttributesForLogic, @@ -436,11 +608,14 @@ export async function findTeamMembersMatchingAttributeLogic( }; } + addTraceStep(false); return { teamMembersMatchingAttributeLogic: teamMembersMatchingMainAttributeLogic, checkedFallback: false, mainAttributeLogicBuildingWarnings, fallbackAttributeLogicBuildingWarnings: [], + routeName, + routeIsFallback, timeTaken: { ...teamMembersMatchingMainAttributeLogicTimeTaken, ttGetAttributesForLogic, diff --git a/packages/features/routing-forms/lib/getRoutedUrl.test.ts b/packages/features/routing-forms/lib/getRoutedUrl.test.ts index 90632586e8..5f78910add 100644 --- a/packages/features/routing-forms/lib/getRoutedUrl.test.ts +++ b/packages/features/routing-forms/lib/getRoutedUrl.test.ts @@ -1,9 +1,6 @@ import "@calcom/lib/__mocks__/logger"; import { createHash } from "node:crypto"; -import type { GetServerSidePropsContext } from "next"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl"; import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore"; import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm"; @@ -14,6 +11,8 @@ import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-form import { PrismaRoutingFormRepository } from "@calcom/features/routing-forms/repositories/PrismaRoutingFormRepository"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import type { GetServerSidePropsContext } from "next"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { getRoutedUrl } from "./getRoutedUrl"; import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward"; @@ -26,9 +25,11 @@ vi.mock("@calcom/lib/checkRateLimitAndThrowError"); vi.mock("@calcom/features/routing-forms/repositories/PrismaRoutingFormRepository"); vi.mock("@calcom/features/users/repositories/UserRepository", () => { return { - UserRepository: vi.fn().mockImplementation(function() { return { - enrichUserWithItsProfile: vi.fn(), - }; }), + UserRepository: vi.fn().mockImplementation(function () { + return { + enrichUserWithItsProfile: vi.fn(), + }; + }), }; }); vi.mock("@calcom/features/ee/organizations/lib/orgDomains"); @@ -44,8 +45,33 @@ vi.mock("@calcom/app-store/routing-forms/enrichFormWithMigrationData", () => ({ vi.mock("@calcom/lib/sentryWrapper", () => ({ withReporting: (fn: unknown) => fn, })); +vi.mock("@calcom/features/routing-trace/di/RoutingTraceService.container", () => ({ + getRoutingTraceService: vi.fn(() => ({ + addStep: vi.fn(), + getStepsCount: vi.fn().mockReturnValue(0), + savePendingRoutingTrace: vi.fn().mockResolvedValue(undefined), + processForBooking: vi.fn().mockResolvedValue(undefined), + })), +})); -const mockForm = { +const mockForm: { + id: string; + user: { id: number; name: string }; + team: null; + name: string; + fields: never[]; + routes: never[]; + userId: number; + teamId: null; + createdAt: Date; + updatedAt: Date; + description: null; + enabled: boolean; + isDefault: boolean; + fieldsOrder: never[]; + position: number; + slug: string; +} = { id: "form-id", user: { id: 1, name: "Test User" }, team: null, @@ -64,7 +90,12 @@ const mockForm = { slug: "test-form", }; -const mockSerializableForm = { +const mockSerializableForm: { + id: string; + fields: { id: string; type: string; label: string; identifier: string }[]; + routes: never[]; + user: { id: number }; +} = { id: "form-id", fields: [{ id: "email", type: "email", label: "Email", identifier: "email" }], routes: [], @@ -73,7 +104,7 @@ const mockSerializableForm = { const mockContext = ( query: Record, - url = "/link/form-id" + url: string = "/link/form-id" ): Pick => ({ query: { form: "form-id", ...query }, req: { url }, @@ -92,8 +123,7 @@ describe("getRoutedUrl", () => { mockUserRepository.mockImplementation(function () { return { enrichUserWithItsProfile: mockEnrichUserWithItsProfile, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; + } as unknown as InstanceType; }); } vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(true); diff --git a/packages/features/routing-forms/lib/getRoutedUrl.ts b/packages/features/routing-forms/lib/getRoutedUrl.ts index 13d946a92c..a0d87f26ab 100644 --- a/packages/features/routing-forms/lib/getRoutedUrl.ts +++ b/packages/features/routing-forms/lib/getRoutedUrl.ts @@ -16,6 +16,7 @@ import type { FormResponse } from "@calcom/app-store/routing-forms/types/types"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; 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 { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { HttpError } from "@calcom/lib/http-error"; @@ -36,7 +37,9 @@ 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) => { @@ -53,7 +56,13 @@ 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(); + const queryParsed = querySchema.safeParse(context.query); const isEmbed = hasEmbedPath(context.req.url || ""); const pageProps = { @@ -87,7 +96,9 @@ const _getRoutedUrl = async (context: Pick = {}; const formQueryStart = performance.now(); - const form = await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId); + const form = + await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId); timeTaken.formQuery = performance.now() - formQueryStart; if (!form) { @@ -113,7 +125,11 @@ const _getRoutedUrl = async (context: Pick; const paramsFromResponse: Params = {}; @@ -118,9 +118,6 @@ export function getUrlSearchParamsToForward({ const allQueryParams: Params = { ...(teamId && { ["cal.teamId"]: `${teamId}` }), ...(orgId && { ["cal.orgId"]: `${orgId}` }), - ...(crmContactOwnerEmail && { ["cal.crmContactOwnerEmail"]: `${crmContactOwnerEmail}` }), - ...(crmContactOwnerRecordType && { ["cal.crmContactOwnerRecordType"]: `${crmContactOwnerRecordType}` }), - ...(crmAppSlug && { ["cal.crmAppSlug"]: `${crmAppSlug}` }), ...paramsFromCurrentUrl, // In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value. ...paramsFromResponse, @@ -132,6 +129,9 @@ export function getUrlSearchParamsToForward({ : null), ...(queuedFormResponseId ? { ["cal.queuedFormResponseId"]: queuedFormResponseId } : null), ...attributeRoutingConfigParams, + ...(crmContactOwnerEmail ? { ["cal.crmContactOwnerEmail"]: crmContactOwnerEmail } : null), + ...(crmContactOwnerRecordType ? { ["cal.crmContactOwnerRecordType"]: crmContactOwnerRecordType } : null), + ...(crmAppSlug ? { ["cal.crmAppSlug"]: crmAppSlug } : null), ...(reroutingFormResponses ? { ["cal.reroutingFormResponses"]: JSON.stringify(reroutingFormResponses) } : null), diff --git a/packages/features/routing-forms/lib/handleResponse.ts b/packages/features/routing-forms/lib/handleResponse.ts index c2e54e8428..bbd1e14e1d 100644 --- a/packages/features/routing-forms/lib/handleResponse.ts +++ b/packages/features/routing-forms/lib/handleResponse.ts @@ -6,6 +6,7 @@ import { type TargetRoutingFormForResponse, } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; import isRouter from "@calcom/app-store/routing-forms/lib/isRouter"; +import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService"; import { emailSchema } from "@calcom/lib/emailSchema"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; @@ -29,6 +30,7 @@ const _handleResponse = async ({ isPreview, queueFormResponse, fetchCrm, + traceService, }: { response: Record< string, @@ -45,6 +47,7 @@ const _handleResponse = async ({ isPreview: boolean; queueFormResponse?: boolean; fetchCrm?: boolean; + traceService?: RoutingTraceService; }) => { try { if (!form.fields) { @@ -104,6 +107,7 @@ const _handleResponse = async ({ let crmAppSlug: string | null = null; let crmRecordId: string | null = null; let timeTaken: Record = {}; + let checkedFallback = false; if (chosenRoute) { if (isRouter(chosenRoute)) { throw new HttpError({ @@ -121,6 +125,7 @@ const _handleResponse = async ({ attributeRoutingConfig: chosenRoute.attributeRoutingConfig, identifierKeyedResponse, action: chosenRoute.action, + routingTraceService: traceService, }) : null; crmContactOwnerEmail = contactOwnerQuery?.email ?? null; @@ -141,9 +146,12 @@ const _handleResponse = async ({ fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue, teamId: formTeamId, orgId: formOrgId, + routeName: chosenRoute.name, + routeIsFallback: chosenRoute.isFallback, }, { enablePerf: true, + routingTraceService: traceService, } ) : null; @@ -161,6 +169,7 @@ const _handleResponse = async ({ : null; timeTaken = teamMembersMatchingAttributeLogicWithResult?.timeTaken ?? {}; + checkedFallback = teamMembersMatchingAttributeLogicWithResult?.checkedFallback ?? false; })(), ]); @@ -226,6 +235,7 @@ const _handleResponse = async ({ ? chosenRoute.attributeRoutingConfig : null : null, + checkedFallback, timeTaken, }; } catch (e) { diff --git a/packages/features/routing-trace/di/PendingRoutingTraceRepository.module.ts b/packages/features/routing-trace/di/PendingRoutingTraceRepository.module.ts new file mode 100644 index 0000000000..fdbe68ea88 --- /dev/null +++ b/packages/features/routing-trace/di/PendingRoutingTraceRepository.module.ts @@ -0,0 +1,23 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; + +import { PrismaPendingRoutingTraceRepository } from "../repositories/PrismaPendingRoutingTraceRepository"; +import { ROUTING_TRACE_DI_TOKENS } from "./tokens"; + +export const pendingRoutingTraceRepositoryModule = createModule(); +const token = ROUTING_TRACE_DI_TOKENS.PENDING_ROUTING_TRACE_REPOSITORY; +const moduleToken = ROUTING_TRACE_DI_TOKENS.PENDING_ROUTING_TRACE_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: pendingRoutingTraceRepositoryModule, + moduleToken, + token, + classs: PrismaPendingRoutingTraceRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { PrismaPendingRoutingTraceRepository }; diff --git a/packages/features/routing-trace/di/RoutingTraceRepository.module.ts b/packages/features/routing-trace/di/RoutingTraceRepository.module.ts new file mode 100644 index 0000000000..884aac2558 --- /dev/null +++ b/packages/features/routing-trace/di/RoutingTraceRepository.module.ts @@ -0,0 +1,23 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; + +import { PrismaRoutingTraceRepository } from "../repositories/PrismaRoutingTraceRepository"; +import { ROUTING_TRACE_DI_TOKENS } from "./tokens"; + +export const routingTraceRepositoryModule = createModule(); +const token = ROUTING_TRACE_DI_TOKENS.ROUTING_TRACE_REPOSITORY; +const moduleToken = ROUTING_TRACE_DI_TOKENS.ROUTING_TRACE_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: routingTraceRepositoryModule, + moduleToken, + token, + classs: PrismaRoutingTraceRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { PrismaRoutingTraceRepository }; diff --git a/packages/features/routing-trace/di/RoutingTraceService.container.ts b/packages/features/routing-trace/di/RoutingTraceService.container.ts new file mode 100644 index 0000000000..7e58a2c05a --- /dev/null +++ b/packages/features/routing-trace/di/RoutingTraceService.container.ts @@ -0,0 +1,37 @@ +import { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository"; +import { createContainer } from "@calcom/features/di/di"; +import prisma from "@calcom/prisma"; + +import type { IPendingRoutingTraceRepository } from "../repositories/PendingRoutingTraceRepository.interface"; +import type { IRoutingTraceRepository } from "../repositories/RoutingTraceRepository.interface"; +import { RoutingTraceService } from "../services/RoutingTraceService"; +import { moduleLoader as pendingRoutingTraceRepositoryModuleLoader } from "./PendingRoutingTraceRepository.module"; +import { moduleLoader as routingTraceRepositoryModuleLoader } from "./RoutingTraceRepository.module"; + +const routingTraceContainer = createContainer(); + +function getPendingRoutingTraceRepository(): IPendingRoutingTraceRepository { + pendingRoutingTraceRepositoryModuleLoader.loadModule(routingTraceContainer); + return routingTraceContainer.get( + pendingRoutingTraceRepositoryModuleLoader.token + ); +} + +function getRoutingTraceRepository(): IRoutingTraceRepository { + routingTraceRepositoryModuleLoader.loadModule(routingTraceContainer); + return routingTraceContainer.get(routingTraceRepositoryModuleLoader.token); +} + +/** + * Creates a RoutingTraceService instance with all required repositories. + * Use this for processing pending traces during booking creation. + */ +export function getRoutingTraceService(): RoutingTraceService { + return new RoutingTraceService({ + pendingRoutingTraceRepository: getPendingRoutingTraceRepository(), + routingTraceRepository: getRoutingTraceRepository(), + assignmentReasonRepository: new AssignmentReasonRepository(prisma), + }); +} + +export { RoutingTraceService }; diff --git a/packages/features/routing-trace/di/tokens.ts b/packages/features/routing-trace/di/tokens.ts new file mode 100644 index 0000000000..0c185e8853 --- /dev/null +++ b/packages/features/routing-trace/di/tokens.ts @@ -0,0 +1,8 @@ +export const ROUTING_TRACE_DI_TOKENS = { + ROUTING_TRACE_SERVICE: Symbol("RoutingTraceService"), + ROUTING_TRACE_SERVICE_MODULE: Symbol("RoutingTraceServiceModule"), + PENDING_ROUTING_TRACE_REPOSITORY: Symbol("PendingRoutingTraceRepository"), + PENDING_ROUTING_TRACE_REPOSITORY_MODULE: Symbol("PendingRoutingTraceRepositoryModule"), + ROUTING_TRACE_REPOSITORY: Symbol("RoutingTraceRepository"), + ROUTING_TRACE_REPOSITORY_MODULE: Symbol("RoutingTraceRepositoryModule"), +}; diff --git a/packages/features/routing-trace/repositories/PendingRoutingTraceRepository.interface.ts b/packages/features/routing-trace/repositories/PendingRoutingTraceRepository.interface.ts new file mode 100644 index 0000000000..d4406a756d --- /dev/null +++ b/packages/features/routing-trace/repositories/PendingRoutingTraceRepository.interface.ts @@ -0,0 +1,43 @@ +import type { + BaseRoutingTrace, + RoutingTrace, +} from "./RoutingTraceRepository.interface"; + +export interface RoutingTraceWithQueuedResponse extends BaseRoutingTrace { + queuedFormResponseId: string; +} + +export interface PendingRoutingTraceRecord { + id: string; + createdAt: Date; + trace: RoutingTrace; + formResponseId: number | null; + queuedFormResponseId: string | null; +} + +export interface IPendingRoutingTraceRepository { + create(args: IPendingRoutingTraceRepositoryCreateArgs): Promise; + findByFormResponseId(formResponseId: number): Promise; + findByQueuedFormResponseId(queuedFormResponseId: string): Promise; + /** Link a pending trace (created with queuedFormResponseId) to a formResponseId when the queued response is processed */ + linkToFormResponse(args: { + queuedFormResponseId: string; + formResponseId: number; + }): Promise; +} + +interface BaseCreateArgs { + trace: RoutingTrace; +} + +interface CreateWithFormResponse extends BaseCreateArgs { + formResponseId: number; +} + +interface CreateWithQueuedFormResponse extends BaseCreateArgs { + queuedFormResponseId: string; +} + +export type IPendingRoutingTraceRepositoryCreateArgs = + | CreateWithFormResponse + | CreateWithQueuedFormResponse; diff --git a/packages/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository.test.ts b/packages/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository.test.ts new file mode 100644 index 0000000000..ceb0c1836b --- /dev/null +++ b/packages/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository.test.ts @@ -0,0 +1,208 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PrismaPendingRoutingTraceRepository } from "./PrismaPendingRoutingTraceRepository"; + +describe("PrismaPendingRoutingTraceRepository", () => { + let repository: PrismaPendingRoutingTraceRepository; + + const mockPrisma = { + pendingRoutingTrace: { + create: vi.fn(), + findUnique: vi.fn(), + }, + } as unknown as PrismaClient; + + beforeEach(() => { + vi.clearAllMocks(); + repository = new PrismaPendingRoutingTraceRepository(mockPrisma); + }); + + describe("create", () => { + it("should create a pending routing trace with formResponseId", async () => { + const trace = [ + { + domain: "routing_form", + step: "attribute-logic-evaluated", + timestamp: Date.now(), + data: { routeIsFallback: false }, + }, + ]; + + vi.mocked(mockPrisma.pendingRoutingTrace.create).mockResolvedValue({ + id: "pending-1", + createdAt: new Date(), + trace, + formResponseId: 123, + queuedFormResponseId: null, + }); + + await repository.create({ + trace, + formResponseId: 123, + }); + + expect(mockPrisma.pendingRoutingTrace.create).toHaveBeenCalledWith({ + data: { + createdAt: expect.any(Date), + trace, + formResponseId: 123, + queuedFormResponseId: undefined, + }, + }); + }); + + it("should create a pending routing trace with queuedFormResponseId", async () => { + const trace = [ + { + domain: "routing_form", + step: "attribute-logic-evaluated", + timestamp: Date.now(), + data: { routeIsFallback: true }, + }, + ]; + + vi.mocked(mockPrisma.pendingRoutingTrace.create).mockResolvedValue({ + id: "pending-2", + createdAt: new Date(), + trace, + formResponseId: null, + queuedFormResponseId: "queued-123", + }); + + await repository.create({ + trace, + queuedFormResponseId: "queued-123", + }); + + expect(mockPrisma.pendingRoutingTrace.create).toHaveBeenCalledWith({ + data: { + createdAt: expect.any(Date), + trace, + formResponseId: undefined, + queuedFormResponseId: "queued-123", + }, + }); + }); + + it("should create a pending routing trace with empty trace array", async () => { + const trace: never[] = []; + + vi.mocked(mockPrisma.pendingRoutingTrace.create).mockResolvedValue({ + id: "pending-3", + createdAt: new Date(), + trace, + formResponseId: 456, + queuedFormResponseId: null, + }); + + await repository.create({ + trace, + formResponseId: 456, + }); + + expect(mockPrisma.pendingRoutingTrace.create).toHaveBeenCalledWith({ + data: { + createdAt: expect.any(Date), + trace, + formResponseId: 456, + queuedFormResponseId: undefined, + }, + }); + }); + }); + + describe("findByFormResponseId", () => { + it("should find a pending routing trace by formResponseId", async () => { + const trace = [ + { + domain: "routing_form", + step: "attribute-logic-evaluated", + timestamp: Date.now(), + data: { routeIsFallback: false }, + }, + ]; + + const mockResult = { + id: "pending-1", + createdAt: new Date("2025-01-01T10:00:00Z"), + trace, + formResponseId: 123, + queuedFormResponseId: null, + }; + + vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(mockResult); + + const result = await repository.findByFormResponseId(123); + + expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({ + where: { formResponseId: 123 }, + }); + expect(result).toEqual({ + id: "pending-1", + createdAt: new Date("2025-01-01T10:00:00Z"), + trace, + formResponseId: 123, + queuedFormResponseId: null, + }); + }); + + it("should return null when no pending routing trace is found", async () => { + vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(null); + + const result = await repository.findByFormResponseId(999); + + expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({ + where: { formResponseId: 999 }, + }); + expect(result).toBeNull(); + }); + }); + + describe("findByQueuedFormResponseId", () => { + it("should find a pending routing trace by queuedFormResponseId", async () => { + const trace = [ + { + domain: "salesforce", + step: "salesforce_assignment", + timestamp: Date.now(), + data: { email: "owner@example.com" }, + }, + ]; + + const mockResult = { + id: "pending-2", + createdAt: new Date("2025-01-02T10:00:00Z"), + trace, + formResponseId: null, + queuedFormResponseId: "queued-456", + }; + + vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(mockResult); + + const result = await repository.findByQueuedFormResponseId("queued-456"); + + expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({ + where: { queuedFormResponseId: "queued-456" }, + }); + expect(result).toEqual({ + id: "pending-2", + createdAt: new Date("2025-01-02T10:00:00Z"), + trace, + formResponseId: null, + queuedFormResponseId: "queued-456", + }); + }); + + it("should return null when no pending routing trace is found by queuedFormResponseId", async () => { + vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(null); + + const result = await repository.findByQueuedFormResponseId("non-existent"); + + expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({ + where: { queuedFormResponseId: "non-existent" }, + }); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository.ts b/packages/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository.ts new file mode 100644 index 0000000000..d1850e0e3b --- /dev/null +++ b/packages/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository.ts @@ -0,0 +1,64 @@ +import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +import type { RoutingTrace } from "./RoutingTraceRepository.interface"; +import type { + IPendingRoutingTraceRepository, + IPendingRoutingTraceRepositoryCreateArgs, + PendingRoutingTraceRecord, +} from "./PendingRoutingTraceRepository.interface"; + +export class PrismaPendingRoutingTraceRepository + implements IPendingRoutingTraceRepository +{ + constructor(private readonly prisma: PrismaClient) {} + + async create(args: IPendingRoutingTraceRepositoryCreateArgs): Promise { + await this.prisma.pendingRoutingTrace.create({ + data: { + createdAt: new Date(), + trace: args.trace as Prisma.InputJsonValue, + formResponseId: + "formResponseId" in args ? args.formResponseId : undefined, + queuedFormResponseId: + "queuedFormResponseId" in args + ? args.queuedFormResponseId + : undefined, + }, + }); + } + + async findByFormResponseId(formResponseId: number): Promise { + const result = await this.prisma.pendingRoutingTrace.findUnique({ + where: { formResponseId }, + }); + if (!result) return null; + return { + ...result, + trace: result.trace as RoutingTrace, + }; + } + + async findByQueuedFormResponseId( + queuedFormResponseId: string + ): Promise { + const result = await this.prisma.pendingRoutingTrace.findUnique({ + where: { queuedFormResponseId }, + }); + if (!result) return null; + return { + ...result, + trace: result.trace as RoutingTrace, + }; + } + + async linkToFormResponse(args: { + queuedFormResponseId: string; + formResponseId: number; + }): Promise { + await this.prisma.pendingRoutingTrace.update({ + where: { queuedFormResponseId: args.queuedFormResponseId }, + data: { formResponseId: args.formResponseId }, + }); + } +} diff --git a/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.test.ts b/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.test.ts new file mode 100644 index 0000000000..0bdfd258ca --- /dev/null +++ b/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.test.ts @@ -0,0 +1,252 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PrismaRoutingTraceRepository } from "./PrismaRoutingTraceRepository"; + +describe("PrismaRoutingTraceRepository", () => { + let repository: PrismaRoutingTraceRepository; + + const mockPrisma = { + routingTrace: { + create: vi.fn(), + }, + } as unknown as PrismaClient; + + beforeEach(() => { + vi.clearAllMocks(); + repository = new PrismaRoutingTraceRepository(mockPrisma); + }); + + describe("create", () => { + it("should create a routing trace with all fields", async () => { + const trace = [ + { + domain: "routing_form", + step: "attribute-logic-evaluated", + timestamp: Date.now(), + data: { routeIsFallback: false }, + }, + ]; + + const mockResult = { + id: "trace-1", + createdAt: new Date("2025-01-01T10:00:00Z"), + trace, + formResponseId: 123, + queuedFormResponseId: null, + bookingUid: "booking-uid-123", + assignmentReasonId: 42, + }; + + vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult); + + const result = await repository.create({ + trace, + formResponseId: 123, + bookingUid: "booking-uid-123", + assignmentReasonId: 42, + }); + + expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({ + data: { + createdAt: expect.any(Date), + trace, + formResponseId: 123, + queuedFormResponseId: undefined, + bookingUid: "booking-uid-123", + assignmentReasonId: 42, + }, + }); + expect(result).toEqual({ + id: "trace-1", + createdAt: new Date("2025-01-01T10:00:00Z"), + trace, + formResponseId: 123, + queuedFormResponseId: null, + bookingUid: "booking-uid-123", + assignmentReasonId: 42, + }); + }); + + it("should create a routing trace with queuedFormResponseId", async () => { + const trace = [ + { + domain: "salesforce", + step: "salesforce_assignment", + timestamp: Date.now(), + data: { email: "owner@example.com" }, + }, + ]; + + const mockResult = { + id: "trace-2", + createdAt: new Date("2025-01-02T10:00:00Z"), + trace, + formResponseId: null, + queuedFormResponseId: "queued-456", + bookingUid: "booking-uid-456", + assignmentReasonId: 100, + }; + + vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult); + + const result = await repository.create({ + trace, + queuedFormResponseId: "queued-456", + bookingUid: "booking-uid-456", + assignmentReasonId: 100, + }); + + expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({ + data: { + createdAt: expect.any(Date), + trace, + formResponseId: undefined, + queuedFormResponseId: "queued-456", + bookingUid: "booking-uid-456", + assignmentReasonId: 100, + }, + }); + expect(result).toEqual({ + id: "trace-2", + createdAt: new Date("2025-01-02T10:00:00Z"), + trace, + formResponseId: null, + queuedFormResponseId: "queued-456", + bookingUid: "booking-uid-456", + assignmentReasonId: 100, + }); + }); + + it("should create a routing trace without assignmentReasonId", async () => { + const trace = [ + { + domain: "other_domain", + step: "some_step", + timestamp: Date.now(), + data: {}, + }, + ]; + + const mockResult = { + id: "trace-3", + createdAt: new Date("2025-01-03T10:00:00Z"), + trace, + formResponseId: 789, + queuedFormResponseId: null, + bookingUid: "booking-uid-789", + assignmentReasonId: null, + }; + + vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult); + + const result = await repository.create({ + trace, + formResponseId: 789, + bookingUid: "booking-uid-789", + }); + + expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({ + data: { + createdAt: expect.any(Date), + trace, + formResponseId: 789, + queuedFormResponseId: undefined, + bookingUid: "booking-uid-789", + assignmentReasonId: undefined, + }, + }); + expect(result).toEqual({ + id: "trace-3", + createdAt: new Date("2025-01-03T10:00:00Z"), + trace, + formResponseId: 789, + queuedFormResponseId: null, + bookingUid: "booking-uid-789", + assignmentReasonId: null, + }); + }); + + it("should create a routing trace with multiple trace steps", async () => { + const trace = [ + { + domain: "salesforce", + step: "salesforce_assignment", + timestamp: Date.now() - 1000, + data: { email: "owner@example.com", recordType: "Contact", recordId: "003ABC" }, + }, + { + domain: "routing_form", + step: "attribute-logic-evaluated", + timestamp: Date.now(), + data: { routeIsFallback: false }, + }, + ]; + + const mockResult = { + id: "trace-4", + createdAt: new Date("2025-01-04T10:00:00Z"), + trace, + formResponseId: 111, + queuedFormResponseId: null, + bookingUid: "booking-uid-111", + assignmentReasonId: 50, + }; + + vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult); + + const result = await repository.create({ + trace, + formResponseId: 111, + bookingUid: "booking-uid-111", + assignmentReasonId: 50, + }); + + expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({ + data: { + createdAt: expect.any(Date), + trace, + formResponseId: 111, + queuedFormResponseId: undefined, + bookingUid: "booking-uid-111", + assignmentReasonId: 50, + }, + }); + expect(result.trace).toHaveLength(2); + }); + + it("should create a routing trace with empty trace array", async () => { + const trace: never[] = []; + + const mockResult = { + id: "trace-5", + createdAt: new Date("2025-01-05T10:00:00Z"), + trace, + formResponseId: 222, + queuedFormResponseId: null, + bookingUid: "booking-uid-222", + assignmentReasonId: null, + }; + + vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult); + + const result = await repository.create({ + trace, + formResponseId: 222, + bookingUid: "booking-uid-222", + }); + + expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({ + data: { + createdAt: expect.any(Date), + trace, + formResponseId: 222, + queuedFormResponseId: undefined, + bookingUid: "booking-uid-222", + assignmentReasonId: undefined, + }, + }); + expect(result.trace).toEqual([]); + }); + }); +}); diff --git a/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.ts b/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.ts new file mode 100644 index 0000000000..ed910763bf --- /dev/null +++ b/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.ts @@ -0,0 +1,30 @@ +import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +import type { + IRoutingTraceRepository, + IRoutingTraceRepositoryCreateArgs, + RoutingTrace, + RoutingTraceRecord, +} from "./RoutingTraceRepository.interface"; + +export class PrismaRoutingTraceRepository implements IRoutingTraceRepository { + constructor(private readonly prisma: PrismaClient) {} + + async create(args: IRoutingTraceRepositoryCreateArgs): Promise { + const result = await this.prisma.routingTrace.create({ + data: { + createdAt: new Date(), + trace: args.trace as Prisma.InputJsonValue, + formResponseId: args.formResponseId, + queuedFormResponseId: args.queuedFormResponseId, + bookingUid: args.bookingUid, + assignmentReasonId: args.assignmentReasonId, + }, + }); + return { + ...result, + trace: result.trace as RoutingTrace, + }; + } +} diff --git a/packages/features/routing-trace/repositories/RoutingTraceRepository.interface.ts b/packages/features/routing-trace/repositories/RoutingTraceRepository.interface.ts new file mode 100644 index 0000000000..34b9042ae5 --- /dev/null +++ b/packages/features/routing-trace/repositories/RoutingTraceRepository.interface.ts @@ -0,0 +1,38 @@ +import z from "zod"; +import type { routingStepSchema } from "../schema/zod"; + +export type RoutingStep = z.infer; + +export type RoutingTrace = RoutingStep[]; + +export interface BaseRoutingTrace { + id: string; + createdAt: Date; + trace: string; +} + +export interface RoutingTraceWithFormResponse extends BaseRoutingTrace { + formResponseId: string; +} + +export interface RoutingTraceRecord { + id: string; + createdAt: Date; + trace: RoutingTrace; + formResponseId: number | null; + queuedFormResponseId: string | null; + bookingUid: string | null; + assignmentReasonId: number | null; +} + +export interface IRoutingTraceRepositoryCreateArgs { + trace: RoutingTrace; + formResponseId?: number; + queuedFormResponseId?: string; + bookingUid: string; + assignmentReasonId?: number; +} + +export interface IRoutingTraceRepository { + create(args: IRoutingTraceRepositoryCreateArgs): Promise; +} diff --git a/packages/features/routing-trace/schema/zod.ts b/packages/features/routing-trace/schema/zod.ts new file mode 100644 index 0000000000..ab36789fb8 --- /dev/null +++ b/packages/features/routing-trace/schema/zod.ts @@ -0,0 +1,8 @@ +import z from "zod"; + +export const routingStepSchema = z.object({ + domain: z.string(), + step: z.string(), + timestamp: z.number(), + data: z.record(z.unknown()), +}); diff --git a/packages/features/routing-trace/services/RoutingTraceService.test.ts b/packages/features/routing-trace/services/RoutingTraceService.test.ts new file mode 100644 index 0000000000..3f2b522fea --- /dev/null +++ b/packages/features/routing-trace/services/RoutingTraceService.test.ts @@ -0,0 +1,663 @@ +import type { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository"; +import { AssignmentReasonEnum } from "@calcom/prisma/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + IPendingRoutingTraceRepository, + PendingRoutingTraceRecord, +} from "../repositories/PendingRoutingTraceRepository.interface"; +import type { IRoutingTraceRepository } from "../repositories/RoutingTraceRepository.interface"; +import { RoutingTraceService } from "./RoutingTraceService"; + +describe("RoutingTraceService", () => { + let service: RoutingTraceService; + let mockPendingRoutingTraceRepository: IPendingRoutingTraceRepository; + let mockRoutingTraceRepository: IRoutingTraceRepository; + let mockAssignmentReasonRepository: Pick; + + function createMockPendingRoutingTraceRepository(): IPendingRoutingTraceRepository { + return { + create: vi.fn(), + findByFormResponseId: vi.fn(), + findByQueuedFormResponseId: vi.fn(), + }; + } + + function createMockRoutingTraceRepository(): IRoutingTraceRepository { + return { + create: vi.fn(), + }; + } + + function createMockAssignmentReasonRepository(): Pick { + return { + createAssignmentReason: vi.fn(), + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + mockPendingRoutingTraceRepository = createMockPendingRoutingTraceRepository(); + mockRoutingTraceRepository = createMockRoutingTraceRepository(); + mockAssignmentReasonRepository = createMockAssignmentReasonRepository(); + service = new RoutingTraceService({ + pendingRoutingTraceRepository: mockPendingRoutingTraceRepository, + routingTraceRepository: mockRoutingTraceRepository, + assignmentReasonRepository: mockAssignmentReasonRepository as AssignmentReasonRepository, + }); + }); + + describe("addStep", () => { + it("should add a step to the routing trace", () => { + service.addStep({ + domain: "routing_form", + step: "attribute-logic-evaluated", + data: { routeIsFallback: false }, + }); + + expect(service.getStepsCount()).toBe(1); + }); + + it("should add multiple steps to the routing trace", () => { + service.addStep({ + domain: "routing_form", + step: "attribute-logic-evaluated", + data: { routeIsFallback: false }, + }); + + service.addStep({ + domain: "salesforce", + step: "salesforce_assignment", + data: { email: "owner@example.com" }, + }); + + expect(service.getStepsCount()).toBe(2); + }); + + it("should add step with empty data by default", () => { + service.addStep({ + domain: "test_domain", + step: "test_step", + }); + + expect(service.getStepsCount()).toBe(1); + }); + }); + + describe("getStepsCount", () => { + it("should return 0 when no steps have been added", () => { + expect(service.getStepsCount()).toBe(0); + }); + + it("should return correct count after adding steps", () => { + service.addStep({ domain: "test", step: "step1" }); + service.addStep({ domain: "test", step: "step2" }); + service.addStep({ domain: "test", step: "step3" }); + + expect(service.getStepsCount()).toBe(3); + }); + }); + + describe("savePendingRoutingTrace", () => { + it("should save pending trace with formResponseId", async () => { + service.addStep({ + domain: "routing_form", + step: "attribute-logic-evaluated", + data: { routeIsFallback: false }, + }); + + await service.savePendingRoutingTrace({ formResponseId: 123 }); + + expect(mockPendingRoutingTraceRepository.create).toHaveBeenCalledWith({ + trace: expect.arrayContaining([ + expect.objectContaining({ + domain: "routing_form", + step: "attribute-logic-evaluated", + data: { routeIsFallback: false }, + }), + ]), + formResponseId: 123, + }); + }); + + it("should save pending trace with queuedFormResponseId", async () => { + service.addStep({ + domain: "routing_form", + step: "attribute-logic-evaluated", + data: { routeIsFallback: true }, + }); + + await service.savePendingRoutingTrace({ queuedFormResponseId: "queued-123" }); + + expect(mockPendingRoutingTraceRepository.create).toHaveBeenCalledWith({ + trace: expect.arrayContaining([ + expect.objectContaining({ + domain: "routing_form", + step: "attribute-logic-evaluated", + data: { routeIsFallback: true }, + }), + ]), + queuedFormResponseId: "queued-123", + }); + }); + }); + + describe("processForBooking", () => { + const baseProcessArgs = { + bookingId: 1, + bookingUid: "booking-uid-123", + organizerEmail: "organizer@example.com", + isRerouting: false, + }; + + it("should return null if no pending trace is found", async () => { + vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(null); + + const result = await service.processForBooking({ + ...baseProcessArgs, + formResponseId: 123, + }); + + expect(result).toBeNull(); + }); + + it("should return null if no pending trace is found for queuedFormResponseId", async () => { + vi.mocked(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).mockResolvedValue(null); + + const result = await service.processForBooking({ + ...baseProcessArgs, + queuedFormResponseId: "queued-123", + }); + + expect(result).toBeNull(); + }); + + describe("routing form assignment", () => { + it("should extract ROUTING_FORM_ROUTING assignment reason", async () => { + const pendingTrace: PendingRoutingTraceRecord = { + id: "pending-1", + createdAt: new Date(), + trace: [ + { + domain: "routing_form", + step: "attribute-logic-evaluated", + timestamp: Date.now(), + data: { routeIsFallback: false }, + }, + ], + formResponseId: 123, + queuedFormResponseId: null, + }; + + vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace); + vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({ + id: 1, + bookingId: 1, + reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING, + reasonString: "", + }); + vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({ + id: "trace-1", + createdAt: new Date(), + trace: pendingTrace.trace, + formResponseId: 123, + queuedFormResponseId: null, + bookingUid: "booking-uid-123", + assignmentReasonId: 1, + }); + + const result = await service.processForBooking({ + ...baseProcessArgs, + formResponseId: 123, + }); + + expect(result).toEqual({ + assignmentReason: { + reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING, + reasonString: "", + }, + }); + expect(mockAssignmentReasonRepository.createAssignmentReason).toHaveBeenCalledWith({ + bookingId: 1, + reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING, + reasonString: "", + }); + }); + + it("should extract ROUTING_FORM_ROUTING_FALLBACK assignment reason when routeIsFallback is true", async () => { + const pendingTrace: PendingRoutingTraceRecord = { + id: "pending-1", + createdAt: new Date(), + trace: [ + { + domain: "routing_form", + step: "attribute-logic-evaluated", + timestamp: Date.now(), + data: { routeIsFallback: true }, + }, + ], + formResponseId: 123, + queuedFormResponseId: null, + }; + + vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace); + vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({ + id: 1, + bookingId: 1, + reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK, + reasonString: "", + }); + vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({ + id: "trace-1", + createdAt: new Date(), + trace: pendingTrace.trace, + formResponseId: 123, + queuedFormResponseId: null, + bookingUid: "booking-uid-123", + assignmentReasonId: 1, + }); + + const result = await service.processForBooking({ + ...baseProcessArgs, + formResponseId: 123, + }); + + expect(result).toEqual({ + assignmentReason: { + reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK, + reasonString: "", + }, + }); + }); + + it("should extract REROUTED assignment reason when isRerouting is true", async () => { + const pendingTrace: PendingRoutingTraceRecord = { + id: "pending-1", + createdAt: new Date(), + trace: [ + { + domain: "routing_form", + step: "attribute-logic-evaluated", + timestamp: Date.now(), + data: { routeIsFallback: false }, + }, + ], + formResponseId: 123, + queuedFormResponseId: null, + }; + + vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace); + vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({ + id: 1, + bookingId: 1, + reasonEnum: AssignmentReasonEnum.REROUTED, + reasonString: "Rerouted by admin@example.com", + }); + vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({ + id: "trace-1", + createdAt: new Date(), + trace: pendingTrace.trace, + formResponseId: 123, + queuedFormResponseId: null, + bookingUid: "booking-uid-123", + assignmentReasonId: 1, + }); + + const result = await service.processForBooking({ + ...baseProcessArgs, + formResponseId: 123, + isRerouting: true, + reroutedByEmail: "admin@example.com", + }); + + expect(result).toEqual({ + assignmentReason: { + reasonEnum: AssignmentReasonEnum.REROUTED, + reasonString: "Rerouted by admin@example.com", + }, + }); + }); + }); + + describe("salesforce assignment", () => { + it("should extract SALESFORCE_ASSIGNMENT when organizer matches CRM contact owner", async () => { + const pendingTrace: PendingRoutingTraceRecord = { + id: "pending-1", + createdAt: new Date(), + trace: [ + { + domain: "salesforce", + step: "salesforce_assignment", + timestamp: Date.now(), + data: { + email: "organizer@example.com", + recordType: "Contact", + recordId: "003ABC123", + }, + }, + ], + formResponseId: 123, + queuedFormResponseId: null, + }; + + vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace); + vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({ + id: 1, + bookingId: 1, + reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT, + reasonString: "Salesforce contact owner: organizer@example.com (Contact ID: 003ABC123)", + }); + vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({ + id: "trace-1", + createdAt: new Date(), + trace: pendingTrace.trace, + formResponseId: 123, + queuedFormResponseId: null, + bookingUid: "booking-uid-123", + assignmentReasonId: 1, + }); + + const result = await service.processForBooking({ + ...baseProcessArgs, + formResponseId: 123, + }); + + expect(result).toEqual({ + assignmentReason: { + reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT, + reasonString: "Salesforce contact owner: organizer@example.com (Contact ID: 003ABC123)", + }, + }); + }); + + it("should use account lookup field reason string when rrSkipToAccountLookupField is true", async () => { + const pendingTrace: PendingRoutingTraceRecord = { + id: "pending-1", + createdAt: new Date(), + trace: [ + { + domain: "salesforce", + step: "salesforce_assignment", + timestamp: Date.now(), + data: { + email: "organizer@example.com", + recordType: "Account", + recordId: "001ABC123", + rrSkipToAccountLookupField: true, + rrSKipToAccountLookupFieldName: "Account_Owner__c", + }, + }, + ], + formResponseId: 123, + queuedFormResponseId: null, + }; + + vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace); + vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({ + id: 1, + bookingId: 1, + reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT, + reasonString: + "Salesforce account lookup field: Account_Owner__c - organizer@example.com (Account ID: 001ABC123)", + }); + vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({ + id: "trace-1", + createdAt: new Date(), + trace: pendingTrace.trace, + formResponseId: 123, + queuedFormResponseId: null, + bookingUid: "booking-uid-123", + assignmentReasonId: 1, + }); + + const result = await service.processForBooking({ + ...baseProcessArgs, + formResponseId: 123, + }); + + expect(result).toEqual({ + assignmentReason: { + reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT, + reasonString: + "Salesforce account lookup field: Account_Owner__c - organizer@example.com (Account ID: 001ABC123)", + }, + }); + }); + + it("should fall back to routing form assignment when organizer does not match CRM contact owner", async () => { + const pendingTrace: PendingRoutingTraceRecord = { + id: "pending-1", + createdAt: new Date(), + trace: [ + { + domain: "salesforce", + step: "salesforce_assignment", + timestamp: Date.now(), + data: { + email: "different-owner@example.com", + recordType: "Contact", + recordId: "003ABC123", + }, + }, + { + domain: "routing_form", + step: "attribute-logic-evaluated", + timestamp: Date.now(), + data: { routeIsFallback: false }, + }, + ], + formResponseId: 123, + queuedFormResponseId: null, + }; + + vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace); + vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({ + id: 1, + bookingId: 1, + reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING, + reasonString: "", + }); + vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({ + id: "trace-1", + createdAt: new Date(), + trace: pendingTrace.trace, + formResponseId: 123, + queuedFormResponseId: null, + bookingUid: "booking-uid-123", + assignmentReasonId: 1, + }); + + const result = await service.processForBooking({ + ...baseProcessArgs, + formResponseId: 123, + }); + + expect(result).toEqual({ + assignmentReason: { + reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING, + reasonString: "", + }, + }); + }); + + it("should handle case-insensitive email matching for CRM assignment", async () => { + const pendingTrace: PendingRoutingTraceRecord = { + id: "pending-1", + createdAt: new Date(), + trace: [ + { + domain: "salesforce", + step: "salesforce_assignment", + timestamp: Date.now(), + data: { + email: "ORGANIZER@EXAMPLE.COM", + recordType: "Contact", + recordId: "003ABC123", + }, + }, + ], + formResponseId: 123, + queuedFormResponseId: null, + }; + + vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace); + vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({ + id: 1, + bookingId: 1, + reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT, + reasonString: "Salesforce contact owner: ORGANIZER@EXAMPLE.COM (Contact ID: 003ABC123)", + }); + vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({ + id: "trace-1", + createdAt: new Date(), + trace: pendingTrace.trace, + formResponseId: 123, + queuedFormResponseId: null, + bookingUid: "booking-uid-123", + assignmentReasonId: 1, + }); + + const result = await service.processForBooking({ + ...baseProcessArgs, + formResponseId: 123, + }); + + expect(result?.assignmentReason?.reasonEnum).toBe(AssignmentReasonEnum.SALESFORCE_ASSIGNMENT); + }); + }); + + it("should create permanent routing trace with assignment reason", async () => { + const pendingTrace: PendingRoutingTraceRecord = { + id: "pending-1", + createdAt: new Date(), + trace: [ + { + domain: "routing_form", + step: "attribute-logic-evaluated", + timestamp: Date.now(), + data: { routeIsFallback: false }, + }, + ], + formResponseId: 123, + queuedFormResponseId: null, + }; + + vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace); + vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({ + id: 42, + bookingId: 1, + reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING, + reasonString: "", + }); + vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({ + id: "trace-1", + createdAt: new Date(), + trace: pendingTrace.trace, + formResponseId: 123, + queuedFormResponseId: null, + bookingUid: "booking-uid-123", + assignmentReasonId: 42, + }); + + await service.processForBooking({ + ...baseProcessArgs, + formResponseId: 123, + }); + + expect(mockRoutingTraceRepository.create).toHaveBeenCalledWith({ + trace: pendingTrace.trace, + formResponseId: 123, + queuedFormResponseId: undefined, + bookingUid: "booking-uid-123", + assignmentReasonId: 42, + }); + }); + + it("should handle pending trace without assignment reason data", async () => { + const pendingTrace: PendingRoutingTraceRecord = { + id: "pending-1", + createdAt: new Date(), + trace: [ + { + domain: "other_domain", + step: "some_step", + timestamp: Date.now(), + data: {}, + }, + ], + formResponseId: 123, + queuedFormResponseId: null, + }; + + vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace); + vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({ + id: "trace-1", + createdAt: new Date(), + trace: pendingTrace.trace, + formResponseId: 123, + queuedFormResponseId: null, + bookingUid: "booking-uid-123", + assignmentReasonId: null, + }); + + const result = await service.processForBooking({ + ...baseProcessArgs, + formResponseId: 123, + }); + + expect(result).toEqual({ + assignmentReason: undefined, + }); + expect(mockAssignmentReasonRepository.createAssignmentReason).not.toHaveBeenCalled(); + expect(mockRoutingTraceRepository.create).toHaveBeenCalledWith({ + trace: pendingTrace.trace, + formResponseId: 123, + queuedFormResponseId: undefined, + bookingUid: "booking-uid-123", + assignmentReasonId: undefined, + }); + }); + + it("should process pending trace found by queuedFormResponseId", async () => { + const pendingTrace: PendingRoutingTraceRecord = { + id: "pending-1", + createdAt: new Date(), + trace: [ + { + domain: "routing_form", + step: "attribute-logic-evaluated", + timestamp: Date.now(), + data: { routeIsFallback: false }, + }, + ], + formResponseId: null, + queuedFormResponseId: "queued-123", + }; + + vi.mocked(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).mockResolvedValue(pendingTrace); + vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({ + id: 1, + bookingId: 1, + reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING, + reasonString: "", + }); + vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({ + id: "trace-1", + createdAt: new Date(), + trace: pendingTrace.trace, + formResponseId: null, + queuedFormResponseId: "queued-123", + bookingUid: "booking-uid-123", + assignmentReasonId: 1, + }); + + const result = await service.processForBooking({ + ...baseProcessArgs, + queuedFormResponseId: "queued-123", + }); + + expect(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).toHaveBeenCalledWith("queued-123"); + expect(result?.assignmentReason?.reasonEnum).toBe(AssignmentReasonEnum.ROUTING_FORM_ROUTING); + }); + }); +}); diff --git a/packages/features/routing-trace/services/RoutingTraceService.ts b/packages/features/routing-trace/services/RoutingTraceService.ts new file mode 100644 index 0000000000..b502400cbe --- /dev/null +++ b/packages/features/routing-trace/services/RoutingTraceService.ts @@ -0,0 +1,269 @@ +import type { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository"; +import logger from "@calcom/lib/logger"; +import { AssignmentReasonEnum } from "@calcom/prisma/enums"; + +import type { IPendingRoutingTraceRepository } from "../repositories/PendingRoutingTraceRepository.interface"; +import type { + IRoutingTraceRepository, + RoutingStep, + RoutingTrace, +} from "../repositories/RoutingTraceRepository.interface"; + +export const ROUTING_TRACE_DOMAINS = { + SALESFORCE: "salesforce", + ROUTING_FORM: "routing_form", +} as const; + +export const ROUTING_TRACE_STEPS = { + SALESFORCE_ASSIGNMENT: "salesforce_assignment", + ATTRIBUTE_LOGIC_EVALUATED: "attribute-logic-evaluated", +} as const; + +interface IRoutingTraceServiceDeps { + pendingRoutingTraceRepository: IPendingRoutingTraceRepository; + routingTraceRepository: IRoutingTraceRepository; + assignmentReasonRepository: AssignmentReasonRepository; +} + +export interface ProcessRoutingTraceResult { + assignmentReason?: { + reasonEnum: AssignmentReasonEnum; + reasonString: string; + }; +} + +export class RoutingTraceService { + private routingTraceSteps: RoutingStep[] = []; + + constructor(private readonly deps: IRoutingTraceServiceDeps) {} + + /** For debugging - get the number of steps recorded */ + getStepsCount(): number { + return this.routingTraceSteps.length; + } + + /** To be called by the domain specific routing trace services */ + addStep({ + domain, + step, + data = {}, + }: { + domain: string; + step: string; + data?: Record; + }) { + this.routingTraceSteps.push({ domain, step, timestamp: Date.now(), data }); + } + + /** Save pending trace when routing form is submitted (before booking is created) */ + async savePendingRoutingTrace( + args: { formResponseId: number } | { queuedFormResponseId: string } + ) { + await this.deps.pendingRoutingTraceRepository.create({ + trace: this.routingTraceSteps, + ...args, + }); + } + + /** + * Process pending routing trace for a booking. + * Looks up the pending trace, extracts assignment reason, creates permanent trace. + */ + async processForBooking(args: { + formResponseId?: number; + queuedFormResponseId?: string; + bookingId: number; + bookingUid: string; + organizerEmail: string; + isRerouting: boolean; + reroutedByEmail?: string | null; + }): Promise { + const { formResponseId, queuedFormResponseId, bookingId, bookingUid, organizerEmail, isRerouting, reroutedByEmail } = args; + + // 1. Look up pending trace by formResponseId or queuedFormResponseId + let pendingTrace = null; + if (formResponseId) { + pendingTrace = await this.deps.pendingRoutingTraceRepository.findByFormResponseId(formResponseId); + } else if (queuedFormResponseId) { + pendingTrace = await this.deps.pendingRoutingTraceRepository.findByQueuedFormResponseId(queuedFormResponseId); + } + + if (!pendingTrace) { + logger.warn("Could not find pending routing trace for form response", { formResponseId, queuedFormResponseId, bookingId }); + return null; + } + + // 2. Extract assignment reason from trace + const assignmentReasonData = this.extractAssignmentReasonFromTrace( + pendingTrace.trace, + { + organizerEmail, + isRerouting, + reroutedByEmail, + } + ); + + let assignmentReasonId: number | undefined; + + // 3. Create assignment reason record if we have trace-based data + if (assignmentReasonData) { + const createdReason = await this.deps.assignmentReasonRepository.createAssignmentReason({ + bookingId, + reasonEnum: assignmentReasonData.reasonEnum, + reasonString: assignmentReasonData.reasonString, + }); + assignmentReasonId = createdReason.id; + } + + // 4. Create permanent routing trace + await this.deps.routingTraceRepository.create({ + trace: pendingTrace.trace, + formResponseId: pendingTrace.formResponseId ?? undefined, + queuedFormResponseId: pendingTrace.queuedFormResponseId ?? undefined, + bookingUid, + assignmentReasonId, + }); + + return { + assignmentReason: assignmentReasonData + ? { + reasonEnum: assignmentReasonData.reasonEnum, + reasonString: assignmentReasonData.reasonString, + } + : undefined, + }; + } + + /** + * Extract assignment reason from trace steps. + * Priority: CRM (Salesforce) > Routing Form + * CRM assignment is only used if the booking organizer matches the CRM contact owner. + */ + private extractAssignmentReasonFromTrace( + trace: RoutingTrace, + context: { + organizerEmail: string; + isRerouting: boolean; + reroutedByEmail?: string | null; + } + ): { reasonEnum: AssignmentReasonEnum; reasonString: string } | null { + const crmAssignmentStep = trace.find( + (step) => + step.domain === ROUTING_TRACE_DOMAINS.SALESFORCE && + step.step === ROUTING_TRACE_STEPS.SALESFORCE_ASSIGNMENT + ); + + if (crmAssignmentStep && crmAssignmentStep.data) { + const { + email, + recordType, + recordId, + rrSkipToAccountLookupField, + rrSKipToAccountLookupFieldName, + } = crmAssignmentStep.data as { + email?: string; + recordType?: string | null; + recordId?: string; + rrSkipToAccountLookupField?: boolean; + rrSKipToAccountLookupFieldName?: string | null; + }; + + // Only use CRM assignment if the booking organizer matches the contact owner + if ( + email && + email.toLowerCase() === context.organizerEmail.toLowerCase() + ) { + return { + reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT, + reasonString: this.buildSalesforceReasonString({ + email, + recordType: recordType ?? "Contact", // Default to Contact if not specified + recordId, + rrSkipToAccountLookupField, + rrSKipToAccountLookupFieldName, + }), + }; + } + } + + const routingFormStep = trace.find( + (step) => + step.domain === ROUTING_TRACE_DOMAINS.ROUTING_FORM && + step.step === ROUTING_TRACE_STEPS.ATTRIBUTE_LOGIC_EVALUATED + ); + + if (routingFormStep && routingFormStep.data) { + const { routeIsFallback, attributeRoutingDetails } = routingFormStep.data as { + routeIsFallback?: boolean; + attributeRoutingDetails?: Array<{ attributeName: string; attributeValue: string }>; + }; + + const reasonEnum = context.isRerouting + ? AssignmentReasonEnum.REROUTED + : routeIsFallback + ? AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK + : AssignmentReasonEnum.ROUTING_FORM_ROUTING; + + return { + reasonEnum, + reasonString: this.buildRoutingFormReasonString({ + isRerouting: context.isRerouting, + reroutedByEmail: context.reroutedByEmail, + attributeRoutingDetails, + }), + }; + } + + logger.warn("Could not extract assignment reason from routing trace - no matching steps found", { + traceSteps: trace.map((s) => `${s.domain}:${s.step}`), + }); + return null; + } + + private buildRoutingFormReasonString(data: { + isRerouting: boolean; + reroutedByEmail?: string | null; + attributeRoutingDetails?: Array<{ attributeName: string; attributeValue: string }>; + }): string { + const { isRerouting, reroutedByEmail, attributeRoutingDetails } = data; + + const reroutingPart = isRerouting && reroutedByEmail ? `Rerouted by ${reroutedByEmail}` : ""; + + const attributesPart = + attributeRoutingDetails && attributeRoutingDetails.length > 0 + ? attributeRoutingDetails + .map(({ attributeName, attributeValue }) => `${attributeName}: ${attributeValue}`) + .join(", ") + : ""; + + // Combine both parts, matching main branch behavior + return `${reroutingPart} ${attributesPart}`.trim(); + } + + private buildSalesforceReasonString(data: { + email: string; + recordType: string; + recordId?: string; + rrSkipToAccountLookupField?: boolean; + rrSKipToAccountLookupFieldName?: string | null; + }): string { + const { + email, + recordType, + recordId, + rrSkipToAccountLookupField, + rrSKipToAccountLookupFieldName, + } = data; + + if (rrSkipToAccountLookupField && rrSKipToAccountLookupFieldName) { + return `Salesforce account lookup field: ${rrSKipToAccountLookupFieldName} - ${email}${ + recordId ? ` (Account ID: ${recordId})` : "" + }`; + } + + const recordLabel = recordType.toLowerCase(); + return `Salesforce ${recordLabel} owner: ${email}${ + recordId ? ` (${recordType} ID: ${recordId})` : "" + }`; + } +} diff --git a/packages/prisma/migrations/20260127035022_add_routing_trace_tables/migration.sql b/packages/prisma/migrations/20260127035022_add_routing_trace_tables/migration.sql new file mode 100644 index 0000000000..bf1eb7b937 --- /dev/null +++ b/packages/prisma/migrations/20260127035022_add_routing_trace_tables/migration.sql @@ -0,0 +1,65 @@ +-- CreateTable +CREATE TABLE "public"."PendingRoutingTrace" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "trace" JSONB NOT NULL, + "formResponseId" INTEGER, + "queuedFormResponseId" TEXT, + + CONSTRAINT "PendingRoutingTrace_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."RoutingTrace" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "trace" JSONB NOT NULL, + "formResponseId" INTEGER, + "queuedFormResponseId" TEXT, + "bookingUid" TEXT, + "assignmentReasonId" INTEGER, + + CONSTRAINT "RoutingTrace_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PendingRoutingTrace_formResponseId_key" ON "public"."PendingRoutingTrace"("formResponseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PendingRoutingTrace_queuedFormResponseId_key" ON "public"."PendingRoutingTrace"("queuedFormResponseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RoutingTrace_formResponseId_key" ON "public"."RoutingTrace"("formResponseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RoutingTrace_queuedFormResponseId_key" ON "public"."RoutingTrace"("queuedFormResponseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RoutingTrace_bookingUid_key" ON "public"."RoutingTrace"("bookingUid"); + +-- CreateIndex +CREATE UNIQUE INDEX "RoutingTrace_assignmentReasonId_key" ON "public"."RoutingTrace"("assignmentReasonId"); + +-- AddForeignKey +ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_formResponseId_fkey" FOREIGN KEY ("formResponseId") REFERENCES "public"."App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_queuedFormResponseId_fkey" FOREIGN KEY ("queuedFormResponseId") REFERENCES "public"."App_RoutingForms_QueuedFormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_formResponseId_fkey" FOREIGN KEY ("formResponseId") REFERENCES "public"."App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_queuedFormResponseId_fkey" FOREIGN KEY ("queuedFormResponseId") REFERENCES "public"."App_RoutingForms_QueuedFormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "public"."Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_assignmentReasonId_fkey" FOREIGN KEY ("assignmentReasonId") REFERENCES "public"."AssignmentReason"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddCheckConstraint: Ensure at least one of formResponseId or queuedFormResponseId is set +ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_at_least_one_response_id" CHECK ("formResponseId" IS NOT NULL OR "queuedFormResponseId" IS NOT NULL); + +-- AddCheckConstraint: Ensure at least one of formResponseId or queuedFormResponseId is set +ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_at_least_one_response_id" CHECK ("formResponseId" IS NOT NULL OR "queuedFormResponseId" IS NOT NULL); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 63445723ab..4497fd8071 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -920,6 +920,7 @@ model Booking { routingFormResponses RoutingFormResponseDenormalized[] expenseLogs CreditExpenseLog[] report BookingReport? + routingTrace RoutingTrace? // @@partial_index([reassignById]) @@ -1349,6 +1350,8 @@ model App_RoutingForms_FormResponse { routingFormResponseFields RoutingFormResponseField[] routingFormResponses RoutingFormResponseDenormalized[] queuedFormResponse App_RoutingForms_QueuedFormResponse? + pendingRoutingTrace PendingRoutingTrace? + routingTrace RoutingTrace? @@unique([formFillerId, formId]) @@index([formFillerId]) @@ -1357,15 +1360,17 @@ model App_RoutingForms_FormResponse { } model App_RoutingForms_QueuedFormResponse { - id String @id @default(cuid()) - form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade) - formId String - response Json - chosenRouteId String? - createdAt DateTime @default(now()) - updatedAt DateTime? @updatedAt - actualResponseId Int? @unique - actualResponse App_RoutingForms_FormResponse? @relation(fields: [actualResponseId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId String + response Json + chosenRouteId String? + createdAt DateTime @default(now()) + updatedAt DateTime? @updatedAt + actualResponseId Int? @unique + actualResponse App_RoutingForms_FormResponse? @relation(fields: [actualResponseId], references: [id], onDelete: Cascade) + pendingRoutingTrace PendingRoutingTrace? + routingTrace RoutingTrace? } model RoutingFormResponseField { @@ -2334,6 +2339,7 @@ model AssignmentReason { booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) reasonEnum AssignmentReasonEnum reasonString String + routingTrace RoutingTrace? @@index([bookingId]) } @@ -3207,3 +3213,33 @@ model AttributeSyncFieldMapping { @@unique([integrationAttributeSyncId, attributeId]) @@index([integrationAttributeSyncId]) } + +model PendingRoutingTrace { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + trace Json + + formResponseId Int? @unique + formResponse App_RoutingForms_FormResponse? @relation(fields: [formResponseId], references: [id], onDelete: Cascade) + + queuedFormResponseId String? @unique + queuedFormResponse App_RoutingForms_QueuedFormResponse? @relation(fields: [queuedFormResponseId], references: [id], onDelete: Cascade) +} + +model RoutingTrace { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + trace Json + + formResponseId Int? @unique + formResponse App_RoutingForms_FormResponse? @relation(fields: [formResponseId], references: [id], onDelete: Cascade) + + queuedFormResponseId String? @unique + queuedFormResponse App_RoutingForms_QueuedFormResponse? @relation(fields: [queuedFormResponseId], references: [id], onDelete: Cascade) + + bookingUid String? @unique + booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade) + + assignmentReasonId Int? @unique + assignmentReason AssignmentReason? @relation(fields: [assignmentReasonId], references: [id], onDelete: Cascade) +} diff --git a/packages/trpc/server/routers/viewer/routing-forms/response.handler.ts b/packages/trpc/server/routers/viewer/routing-forms/response.handler.ts index b89c9dfa4c..39dd9d46ee 100644 --- a/packages/trpc/server/routers/viewer/routing-forms/response.handler.ts +++ b/packages/trpc/server/routers/viewer/routing-forms/response.handler.ts @@ -1,5 +1,6 @@ import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm"; import { handleResponse } from "@calcom/features/routing-forms/lib/handleResponse"; +import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container"; import type { PrismaClient } from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; @@ -46,14 +47,32 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) => form, }); - return handleResponse({ + // Initialize trace service for tracking routing decisions + const traceService = isPreview ? undefined : getRoutingTraceService(); + + const result = await handleResponse({ response, identifierKeyedResponse: null, form: serializableForm, formFillerId, chosenRouteId, isPreview, + traceService, }); + + // Save the pending trace + if (traceService) { + const formResponseId = result.formResponse?.id; + const queuedFormResponseId = result.queuedFormResponse?.id; + + if (formResponseId) { + await traceService.savePendingRoutingTrace({ formResponseId }); + } else if (queuedFormResponseId) { + await traceService.savePendingRoutingTrace({ queuedFormResponseId }); + } + } + + return result; }; export default responseHandler;