* 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>
292 lines
8.9 KiB
TypeScript
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})` : ""
|
|
}`;
|
|
}
|
|
}
|