From 034fbd63f1d9fd65e734a35ac94eec31147e4909 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:28:35 -0500 Subject: [PATCH] feat: Add `CrmRoutingTraceService` and `SalesforceRoutingTraceService` (#27318) * 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 * feat: add CRM routing trace service Add CrmRoutingTraceService as a reusable wrapper around RoutingTraceService for CRM-specific tracing. Also adds CrmRoutingTraceServiceInterface type to support passing trace services through the CRM call chain. Co-Authored-By: Claude Opus 4.5 * feat: add Salesforce routing trace infrastructure Add SalesforceRoutingTrace static class with 19 trace methods covering: - Account resolution (SOQL path): searching by website, contact domain - Lookup field queries - Owner lookups (contact, lead, account) - Validation and skip scenarios - GraphQL three-tier resolution Co-Authored-By: Claude Opus 4.5 * feat: wire CRM trace service through call chain Pass crmTrace parameter through the CRM call chain: - routerGetCrmContactOwnerEmail creates CrmRoutingTraceService - Passes to app booking form handlers and CRM round robin skip - CrmManager.getContacts accepts and forwards crmTrace - All handlers accept optional crmTrace parameter Co-Authored-By: Claude Opus 4.5 * feat: add trace instrumentation to Salesforce CRM service Instrument Salesforce CRM methods with routing trace steps: - getContacts: trace owner lookups for contact/lead/account - getAccountIdBasedOnEmailDomainOfContacts: trace website and domain searches - findUserEmailFromLookupField: trace lookup field queries - GetAccountRecordsForRRSkip (GraphQL): trace three-tier resolution Co-Authored-By: Claude Opus 4.5 * refactor: rename SalesforceRoutingTrace to SalesforceRoutingTraceService Consistent naming with CrmRoutingTraceService. Co-Authored-By: Claude Opus 4.5 * test: add unit tests for CrmRoutingTraceService and SalesforceRoutingTraceService Co-Authored-By: joe@cal.com * refactor: use AsyncLocalStorage for CRM routing trace Replace explicit crmTrace parameter passing with AsyncLocalStorage: - Add AsyncLocalStorage to RoutingTraceService with getCurrent() and runAsync() - Update SalesforceRoutingTraceService to auto-resolve trace from AsyncLocalStorage - Remove CrmRoutingTraceService wrapper (no longer needed) - Remove crmTrace parameter from all CRM method signatures - Wrap CRM operations in routingTraceService.runAsync() context This is cleaner than threading crmTrace through 5+ function layers. The trace context is available within the withReporting wrapper. Co-Authored-By: Claude Opus 4.5 * Use async local storage for `CrmRoutingTraceService` * fix: correct template literal syntax in RoutingTraceService Co-Authored-By: joe@cal.com * fix: use narrowed eventTypeId variable in nested async function Co-Authored-By: joe@cal.com * fix: update SalesforceRoutingTraceService tests to use AsyncLocalStorage API Co-Authored-By: joe@cal.com * refactor: rename crmTrace to crmRoutingTraceService for consistency Co-Authored-By: joe@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 Co-authored-by: Volnei Munhoz --- .../routerGetCrmContactOwnerEmail.ts | 73 ++- .../app-store/salesforce/lib/CrmService.ts | 76 ++- .../lib/graphql/SalesforceGraphQLClient.ts | 42 ++ .../SalesforceRoutingTraceService.test.ts | 545 ++++++++++++++++++ .../tracing/SalesforceRoutingTraceService.ts | 179 ++++++ .../app-store/salesforce/lib/tracing/index.ts | 1 + .../services/CrmRoutingTraceService.test.ts | 95 +++ .../services/CrmRoutingTraceService.ts | 46 ++ .../services/RoutingTraceService.ts | 36 +- 9 files changed, 1052 insertions(+), 41 deletions(-) create mode 100644 packages/app-store/salesforce/lib/tracing/SalesforceRoutingTraceService.test.ts create mode 100644 packages/app-store/salesforce/lib/tracing/SalesforceRoutingTraceService.ts create mode 100644 packages/app-store/salesforce/lib/tracing/index.ts create mode 100644 packages/features/routing-trace/services/CrmRoutingTraceService.test.ts create mode 100644 packages/features/routing-trace/services/CrmRoutingTraceService.ts diff --git a/packages/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail.ts b/packages/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail.ts index 4df620448c..79385c9a9c 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 { CrmRoutingTraceService } from "@calcom/features/routing-trace/services/CrmRoutingTraceService"; import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService"; import { prisma } from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -18,9 +19,8 @@ export default async function routerGetCrmContactOwnerEmail({ action: LocalRoute["action"]; routingTraceService?: RoutingTraceService; }) { - // Check if route is skipping CRM contact check if (attributeRoutingConfig?.skipContactOwner) return null; - // Check if email is present + let prospectEmail: string | null = null; if (identifierKeyedResponse) { for (const identifier of Object.keys(identifierKeyedResponse)) { @@ -34,10 +34,11 @@ 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; + const eventTypeId = action.eventTypeId; + const eventTypeRepo = new EventTypeRepository(prisma); const eventType = await eventTypeRepo.findByIdIncludeHostsAndTeam({ id: action.eventTypeId, @@ -59,43 +60,53 @@ export default async function routerGetCrmContactOwnerEmail({ crmAppSlug: null, recordId: null, }; - // 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]; - 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 runCrmOperations = async () => { + for (const appSlug of enabledAppSlugs) { + const routingOptions = + attributeRoutingConfig?.[appSlug as keyof typeof attributeRoutingConfig]; - const ownerQuery = await appHandler( - prospectEmail, - attributeRoutingConfig, - action.eventTypeId - ); + if (!routingOptions) continue; - if (ownerQuery?.email) { - contactOwner = { ...ownerQuery, crmAppSlug: appSlug }; - break; + 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 ownerQuery = await appHandler( + prospectEmail, + attributeRoutingConfig, + eventTypeId + ); + + if (ownerQuery?.email) { + contactOwner = { ...ownerQuery, crmAppSlug: appSlug }; + break; + } } } + + if (!contactOwner || (!contactOwner.email && !contactOwner.recordType)) { + const ownerQuery = await getCRMContactOwnerForRRLeadSkip( + prospectEmail, + eventTypeMetadata + ); + if (ownerQuery?.email) contactOwner = ownerQuery; + } + }; + + const crmRoutingTraceService = CrmRoutingTraceService.create(routingTraceService); + + if (crmRoutingTraceService) { + await crmRoutingTraceService.runAsync(runCrmOperations); + } else { + await runCrmOperations(); } - if (!contactOwner || (!contactOwner.email && !contactOwner.recordType)) { - 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; diff --git a/packages/app-store/salesforce/lib/CrmService.ts b/packages/app-store/salesforce/lib/CrmService.ts index 3c725d7e17..320dbd1bd1 100644 --- a/packages/app-store/salesforce/lib/CrmService.ts +++ b/packages/app-store/salesforce/lib/CrmService.ts @@ -20,6 +20,7 @@ import type { CRM, Contact, CrmEvent } from "@calcom/types/CrmService"; import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse"; +import { SalesforceRoutingTraceService } from "./tracing"; import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse"; import { findFieldValueByIdentifier } from "../../routing-forms/lib/findFieldValueByIdentifier"; import { default as appMeta } from "../config.json"; @@ -636,9 +637,15 @@ class SalesforceCRMService implements CRM { } } - const includeOwnerData = - (includeOwner || forRoundRobinSkip) && - !(await this.shouldSkipAttendeeIfFreeEmailDomain(emailArray[0])); + const isFreeEmailDomain = await this.shouldSkipAttendeeIfFreeEmailDomain(emailArray[0]); + const includeOwnerData = (includeOwner || forRoundRobinSkip) && !isFreeEmailDomain; + + if (isFreeEmailDomain && (includeOwner || forRoundRobinSkip)) { + SalesforceRoutingTraceService.ownerLookupSkipped({ + reason: "Free email domain", + email: emailArray[0], + }); + } const includeAccountRecordType = forRoundRobinSkip && recordToSearch === SalesforceRecordEnum.ACCOUNT; return records.map((record) => { @@ -649,6 +656,33 @@ class SalesforceCRMService implements CRM { ? record?.Account?.Owner?.Email : record?.Owner?.Email; + // Trace owner lookup based on record type + if (includeOwnerData && record?.Id && record?.OwnerId) { + const recordType = record?.attributes?.type; + if (recordType === SalesforceRecordEnum.CONTACT) { + SalesforceRoutingTraceService.contactOwnerLookup({ + contactId: record.Id, + ownerEmail: ownerEmail ?? null, + ownerId: record.OwnerId ?? null, + }); + } else if (recordType === SalesforceRecordEnum.LEAD) { + SalesforceRoutingTraceService.leadOwnerLookup({ + leadId: record.Id, + ownerEmail: ownerEmail ?? null, + ownerId: record.OwnerId ?? null, + }); + } else if ( + recordType === SalesforceRecordEnum.ACCOUNT || + (includeAccountRecordType && record?.AccountId) + ) { + SalesforceRoutingTraceService.accountOwnerLookup({ + accountId: record.AccountId || record.Id, + ownerEmail: ownerEmail ?? null, + ownerId: record.OwnerId ?? null, + }); + } + } + return { id: includeAccountRecordType ? record?.AccountId || "" : record?.Id || "", email: record?.Email || "", @@ -1102,6 +1136,11 @@ class SalesforceCRMService implements CRM { const emailDomain = email.split("@")[1]; const log = logger.getSubLogger({ prefix: [`[getAccountIdBasedOnEmailDomainOfContacts]:${email}`] }); log.info("getAccountIdBasedOnEmailDomainOfContacts", safeStringify({ email, emailDomain })); + + SalesforceRoutingTraceService.searchingByWebsiteValue({ + emailDomain, + }); + // First check if an account has the same website as the email domain of the attendee const accountQuery = await conn.query( `SELECT Id, Website FROM Account WHERE Website IN (${this.getAllPossibleAccountWebsiteFromEmailDomain( @@ -1114,6 +1153,10 @@ class SalesforceCRMService implements CRM { "Found account based on email domain", safeStringify({ emailDomain, accountWebsite: account.Website, accountId: account.Id }) ); + SalesforceRoutingTraceService.accountFoundByWebsite({ + accountId: account.Id, + website: account.Website, + }); return account.Id; } @@ -1122,12 +1165,28 @@ class SalesforceCRMService implements CRM { `SELECT Id, Email, AccountId FROM Contact WHERE Email LIKE '%@${emailDomain}' AND AccountId != null` ); + SalesforceRoutingTraceService.searchingByContactEmailDomain({ + emailDomain, + contactCount: response.records.length, + }); + const accountId = this.getDominantAccountId(response.records as { AccountId: string }[]); if (accountId) { log.info("Found account based on other contacts", safeStringify({ accountId })); + const contactsUnderAccount = (response.records as { AccountId: string }[]).filter( + (r) => r.AccountId === accountId + ); + SalesforceRoutingTraceService.accountSelectedByMostContacts({ + accountId, + contactCount: contactsUnderAccount.length, + }); } else { log.info("No account found"); + SalesforceRoutingTraceService.noAccountFound({ + email, + reason: "No account found by website or contact domain", + }); } return accountId; @@ -1669,6 +1728,12 @@ class SalesforceCRMService implements CRM { if (salesforceObject === SalesforceRecordEnum.ACCOUNT) { const accountId = await this.getAccountIdBasedOnEmailDomainOfContacts(attendeeEmail); + SalesforceRoutingTraceService.lookupFieldQuery({ + fieldName, + salesforceObject, + accountId: accountId ?? null, + }); + if (!accountId) return; const accountQuery = (await conn.query( @@ -1691,6 +1756,11 @@ class SalesforceCRMService implements CRM { const user = userQuery.records[0] as { Email: string }; + SalesforceRoutingTraceService.userQueryFromLookupField({ + lookupFieldUserId, + userEmail: user.Email, + }); + return { email: user.Email, recordType: RoutingReasons.ACCOUNT_LOOKUP_FIELD }; } } diff --git a/packages/app-store/salesforce/lib/graphql/SalesforceGraphQLClient.ts b/packages/app-store/salesforce/lib/graphql/SalesforceGraphQLClient.ts index 077a070d37..b4a83cfeb9 100644 --- a/packages/app-store/salesforce/lib/graphql/SalesforceGraphQLClient.ts +++ b/packages/app-store/salesforce/lib/graphql/SalesforceGraphQLClient.ts @@ -6,6 +6,7 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import type { Contact } from "@calcom/types/CrmService"; import { SalesforceRecordEnum } from "../enums"; +import { SalesforceRoutingTraceService } from "../tracing"; import getAllPossibleWebsiteValuesFromEmailDomain from "../utils/getAllPossibleWebsiteValuesFromEmailDomain"; import getDominantAccountId from "../utils/getDominantAccountId"; import { GetAccountRecordsForRRSkip } from "./documents/queries"; @@ -60,6 +61,12 @@ export class SalesforceGraphQLClient { const emailDomain = email.split("@")[1]; const websites = this.getAllPossibleAccountWebsiteFromEmailDomain(emailDomain); + // Trace query initiation + SalesforceRoutingTraceService.graphqlQueryInitiated({ + email, + emailDomain, + }); + log.info(`Query against email and email domain of ${emailDomain}`); const query = await this.client.query(GetAccountRecordsForRRSkip, { @@ -91,6 +98,11 @@ export class SalesforceGraphQLClient { if (contact) { log.info(`Existing contact found with id ${contact.Id}`); + SalesforceRoutingTraceService.graphqlExistingContactFound({ + contactId: contact.Id, + accountId: contact.AccountId?.value || contact.Id, + ownerEmail: contact.Account?.Owner?.Email?.value || "", + }); return [ { id: contact.AccountId?.value || contact.Id, @@ -110,6 +122,10 @@ export class SalesforceGraphQLClient { log.info( `Existing account with website that matches email domain of ${emailDomain} found with id ${account.Id}` ); + SalesforceRoutingTraceService.graphqlAccountFoundByWebsite({ + accountId: account.Id, + ownerEmail: account.Owner?.Email?.value || "", + }); return [ { id: account.Id, @@ -158,6 +174,12 @@ export class SalesforceGraphQLClient { return contacts; }, [] as { id: string; AccountId: string; ownerId: string; ownerEmail: string }[]); + // Trace searching by contact domain + SalesforceRoutingTraceService.graphqlSearchingByContactDomain({ + emailDomain, + contactCount: relatedContacts.length, + }); + const dominantAccountId = getDominantAccountId(relatedContacts); if (!dominantAccountId) { @@ -165,6 +187,10 @@ export class SalesforceGraphQLClient { "Could not find dominant account id with the following contacts", safeStringify({ relatedContacts }) ); + SalesforceRoutingTraceService.graphqlNoAccountFound({ + email, + reason: "Could not find dominant account from related contacts", + }); return []; } @@ -180,6 +206,18 @@ export class SalesforceGraphQLClient { return []; } + // Trace dominant account selection + const contactsUnderDominantAccount = relatedContacts.filter( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + (contact) => contact.AccountId === dominantAccountId + ); + SalesforceRoutingTraceService.graphqlDominantAccountSelected({ + accountId: dominantAccountId, + contactCount: contactsUnderDominantAccount.length, + ownerEmail: contactUnderAccount.ownerEmail, + }); + log.info(`Account found via related contacts with account id ${dominantAccountId}`); return [ { @@ -193,6 +231,10 @@ export class SalesforceGraphQLClient { } log.info("No account found for attendee"); + SalesforceRoutingTraceService.graphqlNoAccountFound({ + email, + reason: "No account found via any tier", + }); return []; } diff --git a/packages/app-store/salesforce/lib/tracing/SalesforceRoutingTraceService.test.ts b/packages/app-store/salesforce/lib/tracing/SalesforceRoutingTraceService.test.ts new file mode 100644 index 0000000000..ee8329d4cf --- /dev/null +++ b/packages/app-store/salesforce/lib/tracing/SalesforceRoutingTraceService.test.ts @@ -0,0 +1,545 @@ +import { CrmRoutingTraceService } from "@calcom/features/routing-trace/services/CrmRoutingTraceService"; +import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SalesforceRoutingTraceService } from "./SalesforceRoutingTraceService"; + +describe("SalesforceRoutingTraceService", () => { + let mockParentTraceService: RoutingTraceService; + let addStepSpy: ReturnType; + + function createMockParentTraceService(): RoutingTraceService { + return { + addStep: vi.fn(), + getTrace: vi.fn().mockReturnValue([]), + } as unknown as RoutingTraceService; + } + + /** + * Helper to run a function within the CRM trace context. + * This sets up AsyncLocalStorage so SalesforceRoutingTraceService methods work. + */ + async function runWithTraceContext(fn: () => T): Promise { + const crmTrace = CrmRoutingTraceService.create(mockParentTraceService); + if (!crmTrace) throw new Error("Failed to create CRM trace service"); + return crmTrace.runAsync(async () => fn()); + } + + beforeEach(() => { + vi.clearAllMocks(); + mockParentTraceService = createMockParentTraceService(); + addStepSpy = mockParentTraceService.addStep as ReturnType; + }); + + describe("when called outside trace context", () => { + it("should not throw when called outside trace context", () => { + expect(() => { + SalesforceRoutingTraceService.searchingByWebsiteValue({ emailDomain: "example.com" }); + }).not.toThrow(); + }); + + it("should handle all methods gracefully when called outside trace context", () => { + expect(() => { + SalesforceRoutingTraceService.accountFoundByWebsite({ + accountId: "123", + website: "example.com", + }); + SalesforceRoutingTraceService.searchingByContactEmailDomain({ + emailDomain: "example.com", + contactCount: 5, + }); + SalesforceRoutingTraceService.noAccountFound({ email: "test@example.com", reason: "test" }); + SalesforceRoutingTraceService.graphqlQueryInitiated({ + email: "test@example.com", + emailDomain: "example.com", + }); + }).not.toThrow(); + }); + }); + + describe("Account Resolution (SOQL path)", () => { + describe("searchingByWebsiteValue", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.searchingByWebsiteValue({ emailDomain: "acme.com" }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "searching_by_website_value", + data: { emailDomain: "acme.com" }, + }); + }); + }); + + describe("accountFoundByWebsite", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.accountFoundByWebsite({ + accountId: "001ABC123", + website: "www.acme.com", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "account_found_by_website", + data: { accountId: "001ABC123", website: "www.acme.com" }, + }); + }); + }); + + describe("searchingByContactEmailDomain", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.searchingByContactEmailDomain({ + emailDomain: "acme.com", + contactCount: 10, + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "searching_by_contact_email_domain", + data: { emailDomain: "acme.com", contactCount: 10 }, + }); + }); + }); + + describe("accountSelectedByMostContacts", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.accountSelectedByMostContacts({ + accountId: "001ABC123", + contactCount: 5, + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "account_selected_by_most_contacts", + data: { accountId: "001ABC123", contactCount: 5 }, + }); + }); + }); + + describe("noAccountFound", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.noAccountFound({ + email: "user@unknown.com", + reason: "No account found by website or contact domain", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "no_account_found", + data: { email: "user@unknown.com", reason: "No account found by website or contact domain" }, + }); + }); + }); + }); + + describe("Lookup Field", () => { + describe("lookupFieldQuery", () => { + it("should add step with correct domain and data when accountId is present", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.lookupFieldQuery({ + fieldName: "Account_Owner__c", + salesforceObject: "Account", + accountId: "001ABC123", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "lookup_field_query", + data: { fieldName: "Account_Owner__c", salesforceObject: "Account", accountId: "001ABC123" }, + }); + }); + + it("should add step with null accountId", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.lookupFieldQuery({ + fieldName: "Account_Owner__c", + salesforceObject: "Account", + accountId: null, + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "lookup_field_query", + data: { fieldName: "Account_Owner__c", salesforceObject: "Account", accountId: null }, + }); + }); + }); + + describe("userQueryFromLookupField", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.userQueryFromLookupField({ + lookupFieldUserId: "005ABC123", + userEmail: "owner@acme.com", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "user_query_from_lookup_field", + data: { lookupFieldUserId: "005ABC123", userEmail: "owner@acme.com" }, + }); + }); + + it("should add step with null userEmail", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.userQueryFromLookupField({ + lookupFieldUserId: "005ABC123", + userEmail: null, + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "user_query_from_lookup_field", + data: { lookupFieldUserId: "005ABC123", userEmail: null }, + }); + }); + }); + }); + + describe("Owner Lookups", () => { + describe("contactOwnerLookup", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.contactOwnerLookup({ + contactId: "003ABC123", + ownerEmail: "owner@acme.com", + ownerId: "005DEF456", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "contact_owner_lookup", + data: { contactId: "003ABC123", ownerEmail: "owner@acme.com", ownerId: "005DEF456" }, + }); + }); + + it("should add step with null owner data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.contactOwnerLookup({ + contactId: "003ABC123", + ownerEmail: null, + ownerId: null, + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "contact_owner_lookup", + data: { contactId: "003ABC123", ownerEmail: null, ownerId: null }, + }); + }); + }); + + describe("leadOwnerLookup", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.leadOwnerLookup({ + leadId: "00QABC123", + ownerEmail: "owner@acme.com", + ownerId: "005DEF456", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "lead_owner_lookup", + data: { leadId: "00QABC123", ownerEmail: "owner@acme.com", ownerId: "005DEF456" }, + }); + }); + + it("should add step with null owner data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.leadOwnerLookup({ + leadId: "00QABC123", + ownerEmail: null, + ownerId: null, + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "lead_owner_lookup", + data: { leadId: "00QABC123", ownerEmail: null, ownerId: null }, + }); + }); + }); + + describe("accountOwnerLookup", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.accountOwnerLookup({ + accountId: "001ABC123", + ownerEmail: "owner@acme.com", + ownerId: "005DEF456", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "account_owner_lookup", + data: { accountId: "001ABC123", ownerEmail: "owner@acme.com", ownerId: "005DEF456" }, + }); + }); + + it("should add step with null owner data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.accountOwnerLookup({ + accountId: "001ABC123", + ownerEmail: null, + ownerId: null, + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "account_owner_lookup", + data: { accountId: "001ABC123", ownerEmail: null, ownerId: null }, + }); + }); + }); + }); + + describe("Validation", () => { + describe("ownerValidated", () => { + it("should add step when owner is a team member", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.ownerValidated({ + ownerEmail: "owner@acme.com", + isTeamMember: true, + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "owner_validated", + data: { ownerEmail: "owner@acme.com", isTeamMember: true }, + }); + }); + + it("should add step when owner is not a team member", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.ownerValidated({ + ownerEmail: "external@other.com", + isTeamMember: false, + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "owner_validated", + data: { ownerEmail: "external@other.com", isTeamMember: false }, + }); + }); + }); + }); + + describe("Skip", () => { + describe("ownerLookupSkipped", () => { + it("should add step with reason and email", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.ownerLookupSkipped({ + reason: "Free email domain", + email: "user@gmail.com", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "owner_lookup_skipped", + data: { reason: "Free email domain", email: "user@gmail.com" }, + }); + }); + + it("should add step with reason only (no email)", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.ownerLookupSkipped({ + reason: "No CRM configured", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "owner_lookup_skipped", + data: { reason: "No CRM configured" }, + }); + }); + }); + + describe("contactOwnerCheckSkipped", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.contactOwnerCheckSkipped({ + reason: "Route config disabled contact owner check", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "contact_owner_check_skipped", + data: { reason: "Route config disabled contact owner check" }, + }); + }); + }); + }); + + describe("GraphQL-specific", () => { + describe("graphqlQueryInitiated", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.graphqlQueryInitiated({ + email: "user@acme.com", + emailDomain: "acme.com", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "graphql_query_initiated", + data: { email: "user@acme.com", emailDomain: "acme.com" }, + }); + }); + }); + + describe("graphqlExistingContactFound", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.graphqlExistingContactFound({ + contactId: "003ABC123", + accountId: "001DEF456", + ownerEmail: "owner@acme.com", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "graphql_existing_contact_found", + data: { contactId: "003ABC123", accountId: "001DEF456", ownerEmail: "owner@acme.com" }, + }); + }); + }); + + describe("graphqlAccountFoundByWebsite", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.graphqlAccountFoundByWebsite({ + accountId: "001ABC123", + ownerEmail: "owner@acme.com", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "graphql_account_found_by_website", + data: { accountId: "001ABC123", ownerEmail: "owner@acme.com" }, + }); + }); + }); + + describe("graphqlSearchingByContactDomain", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.graphqlSearchingByContactDomain({ + emailDomain: "acme.com", + contactCount: 15, + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "graphql_searching_by_contact_domain", + data: { emailDomain: "acme.com", contactCount: 15 }, + }); + }); + }); + + describe("graphqlDominantAccountSelected", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.graphqlDominantAccountSelected({ + accountId: "001ABC123", + contactCount: 8, + ownerEmail: "owner@acme.com", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "graphql_dominant_account_selected", + data: { accountId: "001ABC123", contactCount: 8, ownerEmail: "owner@acme.com" }, + }); + }); + }); + + describe("graphqlNoAccountFound", () => { + it("should add step with correct domain and data", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.graphqlNoAccountFound({ + email: "user@unknown.com", + reason: "No account found via any tier", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "graphql_no_account_found", + data: { email: "user@unknown.com", reason: "No account found via any tier" }, + }); + }); + + it("should add step with different reason", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.graphqlNoAccountFound({ + email: "user@example.com", + reason: "Could not find dominant account from related contacts", + }); + }); + + expect(addStepSpy).toHaveBeenCalledWith({ + domain: "salesforce", + step: "graphql_no_account_found", + data: { email: "user@example.com", reason: "Could not find dominant account from related contacts" }, + }); + }); + }); + }); + + describe("domain constant", () => { + it("should use 'salesforce' as the domain for all methods", async () => { + await runWithTraceContext(() => { + SalesforceRoutingTraceService.searchingByWebsiteValue({ emailDomain: "test.com" }); + SalesforceRoutingTraceService.accountFoundByWebsite({ + accountId: "123", + website: "test.com", + }); + SalesforceRoutingTraceService.contactOwnerLookup({ + contactId: "456", + ownerEmail: "test@test.com", + ownerId: "789", + }); + SalesforceRoutingTraceService.graphqlQueryInitiated({ + email: "test@test.com", + emailDomain: "test.com", + }); + }); + + const calls = addStepSpy.mock.calls; + calls.forEach((call: [{ domain: string; step: string; data: Record }]) => { + expect(call[0].domain).toBe("salesforce"); + }); + }); + }); +}); diff --git a/packages/app-store/salesforce/lib/tracing/SalesforceRoutingTraceService.ts b/packages/app-store/salesforce/lib/tracing/SalesforceRoutingTraceService.ts new file mode 100644 index 0000000000..10c02108aa --- /dev/null +++ b/packages/app-store/salesforce/lib/tracing/SalesforceRoutingTraceService.ts @@ -0,0 +1,179 @@ +import { CrmRoutingTraceService } from "@calcom/features/routing-trace/services/CrmRoutingTraceService"; + +const DOMAIN = "salesforce"; + +/** + * Static class providing trace methods for Salesforce CRM routing operations. + * Uses AsyncLocalStorage to automatically get the current trace context. + * Methods are no-ops if called outside a trace context. + */ +export class SalesforceRoutingTraceService { + private static addStep(step: string, data: Record = {}): void { + CrmRoutingTraceService.getCurrent()?.addStep(DOMAIN, step, data); + } + + // ===== Account Resolution (SOQL path) ===== + + /** + * Record when searching for account by website field (matching email domain). + */ + static searchingByWebsiteValue(data: { emailDomain: string }): void { + SalesforceRoutingTraceService.addStep("searching_by_website_value", data); + } + + /** + * Record when an account is found by website match. + */ + static accountFoundByWebsite(data: { accountId: string; website: string }): void { + SalesforceRoutingTraceService.addStep("account_found_by_website", data); + } + + /** + * Record when searching for account via contacts with same email domain. + */ + static searchingByContactEmailDomain(data: { emailDomain: string; contactCount: number }): void { + SalesforceRoutingTraceService.addStep("searching_by_contact_email_domain", data); + } + + /** + * Record when account is selected based on having most contacts. + */ + static accountSelectedByMostContacts(data: { accountId: string; contactCount: number }): void { + SalesforceRoutingTraceService.addStep("account_selected_by_most_contacts", data); + } + + /** + * Record when no account could be found. + */ + static noAccountFound(data: { email: string; reason: string }): void { + SalesforceRoutingTraceService.addStep("no_account_found", data); + } + + // ===== Lookup Field ===== + + /** + * Record lookup field query execution. + */ + static lookupFieldQuery(data: { + fieldName: string; + salesforceObject: string; + accountId: string | null; + }): void { + SalesforceRoutingTraceService.addStep("lookup_field_query", data); + } + + /** + * Record user query from lookup field result. + */ + static userQueryFromLookupField(data: { lookupFieldUserId: string; userEmail: string | null }): void { + SalesforceRoutingTraceService.addStep("user_query_from_lookup_field", data); + } + + // ===== Owner Lookups ===== + + /** + * Record contact owner lookup. + */ + static contactOwnerLookup(data: { + contactId: string; + ownerEmail: string | null; + ownerId: string | null; + }): void { + SalesforceRoutingTraceService.addStep("contact_owner_lookup", data); + } + + /** + * Record lead owner lookup. + */ + static leadOwnerLookup(data: { leadId: string; ownerEmail: string | null; ownerId: string | null }): void { + SalesforceRoutingTraceService.addStep("lead_owner_lookup", data); + } + + /** + * Record account owner lookup. + */ + static accountOwnerLookup(data: { + accountId: string; + ownerEmail: string | null; + ownerId: string | null; + }): void { + SalesforceRoutingTraceService.addStep("account_owner_lookup", data); + } + + // ===== Validation ===== + + /** + * Record when owner is validated as a team member. + */ + static ownerValidated(data: { ownerEmail: string; isTeamMember: boolean }): void { + SalesforceRoutingTraceService.addStep("owner_validated", data); + } + + // ===== Skip ===== + + /** + * Record when owner lookup is skipped (e.g., free email domain). + */ + static ownerLookupSkipped(data: { reason: string; email?: string }): void { + SalesforceRoutingTraceService.addStep("owner_lookup_skipped", data); + } + + /** + * Record when contact owner check is skipped due to route config. + */ + static contactOwnerCheckSkipped(data: { reason: string }): void { + SalesforceRoutingTraceService.addStep("contact_owner_check_skipped", data); + } + + // ===== GraphQL-specific (resolution in GetAccountRecordsForRRSkip) ===== + + /** + * Record when GraphQL query is initiated for account resolution. + */ + static graphqlQueryInitiated(data: { email: string; emailDomain: string }): void { + SalesforceRoutingTraceService.addStep("graphql_query_initiated", data); + } + + /** + * Record when existing contact is found and its account is used. + */ + static graphqlExistingContactFound(data: { + contactId: string; + accountId: string; + ownerEmail: string; + }): void { + SalesforceRoutingTraceService.addStep("graphql_existing_contact_found", data); + } + + /** + * Record when account found by website match. + */ + static graphqlAccountFoundByWebsite(data: { accountId: string; ownerEmail: string }): void { + SalesforceRoutingTraceService.addStep("graphql_account_found_by_website", data); + } + + /** + * Record when searching related contacts by email domain. + */ + static graphqlSearchingByContactDomain(data: { emailDomain: string; contactCount: number }): void { + SalesforceRoutingTraceService.addStep("graphql_searching_by_contact_domain", data); + } + + /** + * Record when dominant account is selected from related contacts. + */ + static graphqlDominantAccountSelected(data: { + accountId: string; + contactCount: number; + ownerEmail: string; + }): void { + SalesforceRoutingTraceService.addStep("graphql_dominant_account_selected", data); + } + + /** + * Record when no account is found via GraphQL. + */ + static graphqlNoAccountFound(data: { email: string; reason: string }): void { + SalesforceRoutingTraceService.addStep("graphql_no_account_found", data); + } +} diff --git a/packages/app-store/salesforce/lib/tracing/index.ts b/packages/app-store/salesforce/lib/tracing/index.ts new file mode 100644 index 0000000000..2cf06e2a0f --- /dev/null +++ b/packages/app-store/salesforce/lib/tracing/index.ts @@ -0,0 +1 @@ +export { SalesforceRoutingTraceService } from "./SalesforceRoutingTraceService"; diff --git a/packages/features/routing-trace/services/CrmRoutingTraceService.test.ts b/packages/features/routing-trace/services/CrmRoutingTraceService.test.ts new file mode 100644 index 0000000000..5e220e4031 --- /dev/null +++ b/packages/features/routing-trace/services/CrmRoutingTraceService.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { CrmRoutingTraceService } from "./CrmRoutingTraceService"; +import type { RoutingTraceService } from "./RoutingTraceService"; + +describe("CrmRoutingTraceService", () => { + let mockParentTraceService: Pick; + + function createMockRoutingTraceService(): Pick { + return { + addStep: vi.fn(), + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + mockParentTraceService = createMockRoutingTraceService(); + }); + + describe("create", () => { + it("should return undefined when parent is undefined", () => { + const result = CrmRoutingTraceService.create(undefined); + + expect(result).toBeUndefined(); + }); + + it("should return a CrmRoutingTraceService instance when parent is provided", () => { + const result = CrmRoutingTraceService.create(mockParentTraceService as RoutingTraceService); + + expect(result).toBeInstanceOf(CrmRoutingTraceService); + }); + }); + + describe("addStep", () => { + it("should delegate to parent trace service with correct parameters", () => { + const crmTrace = new CrmRoutingTraceService(mockParentTraceService as RoutingTraceService); + + crmTrace.addStep("salesforce", "account_lookup", { accountId: "123" }); + + expect(mockParentTraceService.addStep).toHaveBeenCalledWith({ + domain: "salesforce", + step: "account_lookup", + data: { accountId: "123" }, + }); + }); + + it("should use empty object as default data when not provided", () => { + const crmTrace = new CrmRoutingTraceService(mockParentTraceService as RoutingTraceService); + + crmTrace.addStep("hubspot", "contact_search"); + + expect(mockParentTraceService.addStep).toHaveBeenCalledWith({ + domain: "hubspot", + step: "contact_search", + data: {}, + }); + }); + + it("should pass complex data objects correctly", () => { + const crmTrace = new CrmRoutingTraceService(mockParentTraceService as RoutingTraceService); + const complexData = { + email: "test@example.com", + recordType: "Contact", + recordId: "003ABC123", + nested: { key: "value" }, + }; + + crmTrace.addStep("salesforce", "contact_owner_lookup", complexData); + + expect(mockParentTraceService.addStep).toHaveBeenCalledWith({ + domain: "salesforce", + step: "contact_owner_lookup", + data: complexData, + }); + }); + + it("should allow multiple steps to be added", () => { + const crmTrace = new CrmRoutingTraceService(mockParentTraceService as RoutingTraceService); + + crmTrace.addStep("salesforce", "step1", { data: 1 }); + crmTrace.addStep("salesforce", "step2", { data: 2 }); + crmTrace.addStep("salesforce", "step3", { data: 3 }); + + expect(mockParentTraceService.addStep).toHaveBeenCalledTimes(3); + }); + }); + + describe("constructor", () => { + it("should create instance with parent trace service", () => { + const crmTrace = new CrmRoutingTraceService(mockParentTraceService as RoutingTraceService); + + expect(crmTrace).toBeInstanceOf(CrmRoutingTraceService); + }); + }); +}); diff --git a/packages/features/routing-trace/services/CrmRoutingTraceService.ts b/packages/features/routing-trace/services/CrmRoutingTraceService.ts new file mode 100644 index 0000000000..066675d7cb --- /dev/null +++ b/packages/features/routing-trace/services/CrmRoutingTraceService.ts @@ -0,0 +1,46 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import type { RoutingTraceService } from "./RoutingTraceService"; + +/** + * CRM-specific wrapper around RoutingTraceService. + * Uses AsyncLocalStorage to make the trace service available to CRM-specific + * trace services (e.g., SalesforceRoutingTraceService) without explicit parameter passing. + */ +export class CrmRoutingTraceService { + private static als = new AsyncLocalStorage(); + + constructor(private parentTraceService: RoutingTraceService) {} + + /** + * Get the current CrmRoutingTraceService from AsyncLocalStorage. + * Returns undefined if not within a CRM trace context. + */ + static getCurrent(): CrmRoutingTraceService | undefined { + return CrmRoutingTraceService.als.getStore(); + } + + /** + * Factory method to create a CrmRoutingTraceService if parent exists. + * Returns undefined if no parent trace service is provided. + */ + static create(parent: RoutingTraceService | undefined): CrmRoutingTraceService | undefined { + if (!parent) return undefined; + return new CrmRoutingTraceService(parent); + } + + /** + * Run an async function within this CRM trace service's context. + * Any code within the callback can access this trace service via getCurrent(). + */ + runAsync(fn: () => Promise): Promise { + return CrmRoutingTraceService.als.run(this, fn); + } + + /** + * Add a trace step to the parent service. + */ + addStep(domain: string, step: string, data: Record = {}): void { + this.parentTraceService.addStep({ domain, step, data }); + } +} diff --git a/packages/features/routing-trace/services/RoutingTraceService.ts b/packages/features/routing-trace/services/RoutingTraceService.ts index b502400cbe..aeb912f7b6 100644 --- a/packages/features/routing-trace/services/RoutingTraceService.ts +++ b/packages/features/routing-trace/services/RoutingTraceService.ts @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + import type { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository"; import logger from "@calcom/lib/logger"; import { AssignmentReasonEnum } from "@calcom/prisma/enums"; @@ -33,6 +35,32 @@ export interface ProcessRoutingTraceResult { } export class RoutingTraceService { + private static als = new AsyncLocalStorage(); + + /** + * Get the current RoutingTraceService from AsyncLocalStorage. + * Returns undefined if not within a trace context. + */ + static getCurrent(): RoutingTraceService | undefined { + return this.als.getStore(); + } + + /** + * Run a function within this trace service's context. + * Any code within the callback can access this trace service via getCurrent(). + */ + run(fn: () => T): T { + return RoutingTraceService.als.run(this, fn); + } + + /** + * Run an async function within this trace service's context. + * Any code within the callback can access this trace service via getCurrent(). + */ + runAsync(fn: () => Promise): Promise { + return RoutingTraceService.als.run(this, fn); + } + private routingTraceSteps: RoutingStep[] = []; constructor(private readonly deps: IRoutingTraceServiceDeps) {} @@ -80,7 +108,6 @@ export class RoutingTraceService { }): 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); @@ -93,7 +120,6 @@ export class RoutingTraceService { return null; } - // 2. Extract assignment reason from trace const assignmentReasonData = this.extractAssignmentReasonFromTrace( pendingTrace.trace, { @@ -105,7 +131,6 @@ export class RoutingTraceService { 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, @@ -115,7 +140,6 @@ export class RoutingTraceService { assignmentReasonId = createdReason.id; } - // 4. Create permanent routing trace await this.deps.routingTraceRepository.create({ trace: pendingTrace.trace, formResponseId: pendingTrace.formResponseId ?? undefined, @@ -168,7 +192,6 @@ export class RoutingTraceService { rrSKipToAccountLookupFieldName?: string | null; }; - // Only use CRM assignment if the booking organizer matches the contact owner if ( email && email.toLowerCase() === context.organizerEmail.toLowerCase() @@ -177,7 +200,7 @@ export class RoutingTraceService { reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT, reasonString: this.buildSalesforceReasonString({ email, - recordType: recordType ?? "Contact", // Default to Contact if not specified + recordType: recordType ?? "Contact", recordId, rrSkipToAccountLookupField, rrSKipToAccountLookupFieldName, @@ -236,7 +259,6 @@ export class RoutingTraceService { .join(", ") : ""; - // Combine both parts, matching main branch behavior return `${reroutingPart} ${attributesPart}`.trim(); }