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:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Claude Opus 4.5
Volnei Munhoz
parent
f536100224
commit
034fbd63f1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user