Files
cal.diy/packages/features/routing-trace/services/RoutingTraceService.ts
T
034fbd63f1 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>
2026-01-28 17:28:35 -03:00

292 lines
8.9 KiB
TypeScript

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";
import type { IPendingRoutingTraceRepository } from "../repositories/PendingRoutingTraceRepository.interface";
import type {
IRoutingTraceRepository,
RoutingStep,
RoutingTrace,
} from "../repositories/RoutingTraceRepository.interface";
export const ROUTING_TRACE_DOMAINS = {
SALESFORCE: "salesforce",
ROUTING_FORM: "routing_form",
} as const;
export const ROUTING_TRACE_STEPS = {
SALESFORCE_ASSIGNMENT: "salesforce_assignment",
ATTRIBUTE_LOGIC_EVALUATED: "attribute-logic-evaluated",
} as const;
interface IRoutingTraceServiceDeps {
pendingRoutingTraceRepository: IPendingRoutingTraceRepository;
routingTraceRepository: IRoutingTraceRepository;
assignmentReasonRepository: AssignmentReasonRepository;
}
export interface ProcessRoutingTraceResult {
assignmentReason?: {
reasonEnum: AssignmentReasonEnum;
reasonString: string;
};
}
export class RoutingTraceService {
private 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) {}
/** For debugging - get the number of steps recorded */
getStepsCount(): number {
return this.routingTraceSteps.length;
}
/** To be called by the domain specific routing trace services */
addStep({
domain,
step,
data = {},
}: {
domain: string;
step: string;
data?: Record<string, unknown>;
}) {
this.routingTraceSteps.push({ domain, step, timestamp: Date.now(), data });
}
/** Save pending trace when routing form is submitted (before booking is created) */
async savePendingRoutingTrace(
args: { formResponseId: number } | { queuedFormResponseId: string }
) {
await this.deps.pendingRoutingTraceRepository.create({
trace: this.routingTraceSteps,
...args,
});
}
/**
* Process pending routing trace for a booking.
* Looks up the pending trace, extracts assignment reason, creates permanent trace.
*/
async processForBooking(args: {
formResponseId?: number;
queuedFormResponseId?: string;
bookingId: number;
bookingUid: string;
organizerEmail: string;
isRerouting: boolean;
reroutedByEmail?: string | null;
}): Promise<ProcessRoutingTraceResult | null> {
const { formResponseId, queuedFormResponseId, bookingId, bookingUid, organizerEmail, isRerouting, reroutedByEmail } = args;
let pendingTrace = null;
if (formResponseId) {
pendingTrace = await this.deps.pendingRoutingTraceRepository.findByFormResponseId(formResponseId);
} else if (queuedFormResponseId) {
pendingTrace = await this.deps.pendingRoutingTraceRepository.findByQueuedFormResponseId(queuedFormResponseId);
}
if (!pendingTrace) {
logger.warn("Could not find pending routing trace for form response", { formResponseId, queuedFormResponseId, bookingId });
return null;
}
const assignmentReasonData = this.extractAssignmentReasonFromTrace(
pendingTrace.trace,
{
organizerEmail,
isRerouting,
reroutedByEmail,
}
);
let assignmentReasonId: number | undefined;
if (assignmentReasonData) {
const createdReason = await this.deps.assignmentReasonRepository.createAssignmentReason({
bookingId,
reasonEnum: assignmentReasonData.reasonEnum,
reasonString: assignmentReasonData.reasonString,
});
assignmentReasonId = createdReason.id;
}
await this.deps.routingTraceRepository.create({
trace: pendingTrace.trace,
formResponseId: pendingTrace.formResponseId ?? undefined,
queuedFormResponseId: pendingTrace.queuedFormResponseId ?? undefined,
bookingUid,
assignmentReasonId,
});
return {
assignmentReason: assignmentReasonData
? {
reasonEnum: assignmentReasonData.reasonEnum,
reasonString: assignmentReasonData.reasonString,
}
: undefined,
};
}
/**
* Extract assignment reason from trace steps.
* Priority: CRM (Salesforce) > Routing Form
* CRM assignment is only used if the booking organizer matches the CRM contact owner.
*/
private extractAssignmentReasonFromTrace(
trace: RoutingTrace,
context: {
organizerEmail: string;
isRerouting: boolean;
reroutedByEmail?: string | null;
}
): { reasonEnum: AssignmentReasonEnum; reasonString: string } | null {
const crmAssignmentStep = trace.find(
(step) =>
step.domain === ROUTING_TRACE_DOMAINS.SALESFORCE &&
step.step === ROUTING_TRACE_STEPS.SALESFORCE_ASSIGNMENT
);
if (crmAssignmentStep && crmAssignmentStep.data) {
const {
email,
recordType,
recordId,
rrSkipToAccountLookupField,
rrSKipToAccountLookupFieldName,
} = crmAssignmentStep.data as {
email?: string;
recordType?: string | null;
recordId?: string;
rrSkipToAccountLookupField?: boolean;
rrSKipToAccountLookupFieldName?: string | null;
};
if (
email &&
email.toLowerCase() === context.organizerEmail.toLowerCase()
) {
return {
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString: this.buildSalesforceReasonString({
email,
recordType: recordType ?? "Contact",
recordId,
rrSkipToAccountLookupField,
rrSKipToAccountLookupFieldName,
}),
};
}
}
const routingFormStep = trace.find(
(step) =>
step.domain === ROUTING_TRACE_DOMAINS.ROUTING_FORM &&
step.step === ROUTING_TRACE_STEPS.ATTRIBUTE_LOGIC_EVALUATED
);
if (routingFormStep && routingFormStep.data) {
const { routeIsFallback, attributeRoutingDetails } = routingFormStep.data as {
routeIsFallback?: boolean;
attributeRoutingDetails?: Array<{ attributeName: string; attributeValue: string }>;
};
const reasonEnum = context.isRerouting
? AssignmentReasonEnum.REROUTED
: routeIsFallback
? AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK
: AssignmentReasonEnum.ROUTING_FORM_ROUTING;
return {
reasonEnum,
reasonString: this.buildRoutingFormReasonString({
isRerouting: context.isRerouting,
reroutedByEmail: context.reroutedByEmail,
attributeRoutingDetails,
}),
};
}
logger.warn("Could not extract assignment reason from routing trace - no matching steps found", {
traceSteps: trace.map((s) => `${s.domain}:${s.step}`),
});
return null;
}
private buildRoutingFormReasonString(data: {
isRerouting: boolean;
reroutedByEmail?: string | null;
attributeRoutingDetails?: Array<{ attributeName: string; attributeValue: string }>;
}): string {
const { isRerouting, reroutedByEmail, attributeRoutingDetails } = data;
const reroutingPart = isRerouting && reroutedByEmail ? `Rerouted by ${reroutedByEmail}` : "";
const attributesPart =
attributeRoutingDetails && attributeRoutingDetails.length > 0
? attributeRoutingDetails
.map(({ attributeName, attributeValue }) => `${attributeName}: ${attributeValue}`)
.join(", ")
: "";
return `${reroutingPart} ${attributesPart}`.trim();
}
private buildSalesforceReasonString(data: {
email: string;
recordType: string;
recordId?: string;
rrSkipToAccountLookupField?: boolean;
rrSKipToAccountLookupFieldName?: string | null;
}): string {
const {
email,
recordType,
recordId,
rrSkipToAccountLookupField,
rrSKipToAccountLookupFieldName,
} = data;
if (rrSkipToAccountLookupField && rrSKipToAccountLookupFieldName) {
return `Salesforce account lookup field: ${rrSKipToAccountLookupFieldName} - ${email}${
recordId ? ` (Account ID: ${recordId})` : ""
}`;
}
const recordLabel = recordType.toLowerCase();
return `Salesforce ${recordLabel} owner: ${email}${
recordId ? ` (${recordType} ID: ${recordId})` : ""
}`;
}
}