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 <j.auyeung419@gmail.com>

* test: Add missing mock for RoutingTraceService in getRoutedUrl tests

Also fix pre-existing lint issues in the test file:
- Add explicit types to mockForm and mockSerializableForm variables
- Add explicit type to url parameter in mockContext
- Replace 'as any' with 'as unknown as InstanceType<typeof UserRepository>'

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Link pending form submission to routing trace

* Clean up

* Add lookup field assignment reasons

* Rename to PendingRoutingTrace

* Add migration file

* fix: Update RoutingTraceService tests to use assignmentReasonRepository mock

- Add getStepsCount() method back to RoutingTraceService
- Add queuedFormResponseId support to processForBooking method
- Update tests to use mockAssignmentReasonRepository instead of prisma mock
- Remove test for missing routingTraceRepository (all deps now required)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Remove PII (organizer email) from log payload in RoutingTraceService

Addresses Cubic AI review feedback with confidence 9/10.
Logging PII violates sensitive information logging rules.

Co-Authored-By: unknown <>

* Write field values at the time of routing

* Write attributes used to route

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* refactor: rename SalesforceRoutingTrace to SalesforceRoutingTraceService

Consistent naming with CrmRoutingTraceService.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add unit tests for CrmRoutingTraceService and SalesforceRoutingTraceService

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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 <noreply@anthropic.com>

* Use async local storage for `CrmRoutingTraceService`

* fix: correct template literal syntax in RoutingTraceService

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use narrowed eventTypeId variable in nested async function

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: update SalesforceRoutingTraceService tests to use AsyncLocalStorage API

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: rename crmTrace to crmRoutingTraceService for consistency

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
This commit is contained in:
Joe Au-Yeung
2026-01-28 17:28:35 -03:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Claude Opus 4.5 Volnei Munhoz
parent f536100224
commit 034fbd63f1
9 changed files with 1052 additions and 41 deletions
@@ -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;
@@ -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 };
}
}
@@ -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 [];
}
@@ -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<typeof vi.fn>;
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<T>(fn: () => T): Promise<T> {
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<typeof vi.fn>;
});
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<string, unknown> }]) => {
expect(call[0].domain).toBe("salesforce");
});
});
});
});
@@ -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<string, unknown> = {}): 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);
}
}
@@ -0,0 +1 @@
export { SalesforceRoutingTraceService } from "./SalesforceRoutingTraceService";
@@ -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<RoutingTraceService, "addStep">;
function createMockRoutingTraceService(): Pick<RoutingTraceService, "addStep"> {
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);
});
});
});
@@ -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<CrmRoutingTraceService>();
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<T>(fn: () => Promise<T>): Promise<T> {
return CrmRoutingTraceService.als.run(this, fn);
}
/**
* Add a trace step to the parent service.
*/
addStep(domain: string, step: string, data: Record<string, unknown> = {}): void {
this.parentTraceService.addStep({ domain, step, data });
}
}
@@ -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<RoutingTraceService>();
/**
* 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<T>(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<T>(fn: () => Promise<T>): Promise<T> {
return RoutingTraceService.als.run(this, fn);
}
private routingTraceSteps: RoutingStep[] = [];
constructor(private readonly deps: IRoutingTraceServiceDeps) {}
@@ -80,7 +108,6 @@ export class RoutingTraceService {
}): Promise<ProcessRoutingTraceResult | null> {
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();
}