* 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>
245 lines
8.6 KiB
TypeScript
245 lines
8.6 KiB
TypeScript
import { Client, cacheExchange, fetchExchange } from "@urql/core";
|
|
import { retryExchange } from "@urql/exchange-retry";
|
|
|
|
import logger from "@calcom/lib/logger";
|
|
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";
|
|
|
|
export class SalesforceGraphQLClient {
|
|
private log: typeof logger;
|
|
private version = "v63.0";
|
|
private accessToken: string;
|
|
private client: Client;
|
|
|
|
constructor({ accessToken, instanceUrl }: { accessToken: string; instanceUrl: string }) {
|
|
this.accessToken = accessToken;
|
|
|
|
const exchanges = [cacheExchange, fetchExchange];
|
|
|
|
if (
|
|
process.env.SALESFORCE_GRAPHQL_DELAY_MS &&
|
|
process.env.SALESFORCE_GRAPHQL_MAX_DELAY_MS &&
|
|
process.env.SALESFORCE_GRAPHQL_MAX_RETRIES
|
|
) {
|
|
const retryOptions = {
|
|
maxRetries: 3,
|
|
initialDelayMs: Number(process.env.SALESFORCE_GRAPHQL_DELAY_MS),
|
|
maxDelayMs: Number(process.env.SALESFORCE_GRAPHQL_MAX_DELAY_MS),
|
|
randomDelay: true,
|
|
maxNumberAttempts: Number(process.env.SALESFORCE_GRAPHQL_MAX_RETRIES),
|
|
};
|
|
exchanges.push(retryExchange(retryOptions));
|
|
}
|
|
|
|
this.client = new Client({
|
|
url: `${instanceUrl}/services/data/${this.version}/graphql`,
|
|
exchanges,
|
|
fetchOptions: () => {
|
|
return {
|
|
headers: { authorization: `Bearer ${this.accessToken}` },
|
|
"Content-Type": "application/json",
|
|
};
|
|
},
|
|
});
|
|
this.log = logger.getSubLogger({ prefix: ["[SalesforceGraphQLClient]"] });
|
|
}
|
|
|
|
/**
|
|
* Returns the owner of an account. There are three methods we use to the find the account owner
|
|
* 1. If there is a contact with that matches the email, return the account owner
|
|
* 2. If no contact is found, then find an account that is an exact match of the email domain
|
|
* 3. If no account is found, then find contacts that match the email domain and find the account that the majority of contacts are connect to
|
|
*/
|
|
async GetAccountRecordsForRRSkip(email: string): Promise<Contact[]> {
|
|
const log = logger.getSubLogger({ prefix: [`[getAccountRecordsForRRSkip]:${email}`] });
|
|
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, {
|
|
email,
|
|
websites,
|
|
emailDomain: `%@${emailDomain}`,
|
|
});
|
|
|
|
const queryData = query?.data;
|
|
|
|
if (query?.error) {
|
|
const errors = query.error;
|
|
if (errors.graphQLErrors.length) {
|
|
log.error("GraphQL error", errors.graphQLErrors);
|
|
}
|
|
if (errors.networkError) {
|
|
log.error("Network error", errors.networkError);
|
|
}
|
|
}
|
|
|
|
if (!queryData) {
|
|
log.error("No query data found", query?.error);
|
|
return [];
|
|
}
|
|
|
|
// If there is an existing contact, return the owner of the account
|
|
if (queryData.uiapi.query.Contact) {
|
|
const contact = queryData.uiapi.query.Contact?.edges?.[0]?.node;
|
|
|
|
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,
|
|
email: contact.Email?.value,
|
|
ownerId: contact.Account?.Owner?.Id,
|
|
ownerEmail: contact.Account?.Owner?.Email?.value,
|
|
recordType: SalesforceRecordEnum.ACCOUNT,
|
|
},
|
|
];
|
|
}
|
|
}
|
|
|
|
// If no contact is found, query for accounts based on website field
|
|
if (queryData.uiapi.query.Account) {
|
|
const account = queryData.uiapi.query.Account?.edges?.[0]?.node;
|
|
if (account) {
|
|
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,
|
|
email: "",
|
|
ownerId: account.Owner?.Id,
|
|
ownerEmail: account.Owner?.Email?.value,
|
|
recordType: SalesforceRecordEnum.ACCOUNT,
|
|
},
|
|
];
|
|
}
|
|
}
|
|
|
|
// If no account is found, find an account based on existing contacts
|
|
if (queryData.uiapi.query.relatedContacts) {
|
|
const relatedContactsResults = queryData.uiapi.query.relatedContacts?.edges;
|
|
|
|
if (!relatedContactsResults) return [];
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore - in CD/CI pipeline this will have any type
|
|
const relatedContacts = relatedContactsResults.reduce((contacts, edge) => {
|
|
const node = edge?.node;
|
|
if (!node) {
|
|
log.error("A related contact query didn't include a node");
|
|
return contacts;
|
|
}
|
|
if (!node.AccountId?.value) {
|
|
log.error(`A related contact with id ${node.Id} didn't have an account id`);
|
|
return contacts;
|
|
}
|
|
if (!node.Account?.Owner?.Id) {
|
|
log.error(`A related contact with id ${node.Id} didn't have an account owner id`);
|
|
return contacts;
|
|
}
|
|
if (!node.Account?.Owner?.Email?.value) {
|
|
log.error(`A related contact with id ${node.Id} didn't have an account owner email`);
|
|
return contacts;
|
|
}
|
|
|
|
contacts.push({
|
|
id: node.Id,
|
|
AccountId: node.AccountId.value,
|
|
ownerId: node.Account.Owner.Id,
|
|
ownerEmail: node.Account.Owner.Email.value,
|
|
});
|
|
|
|
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) {
|
|
log.error(
|
|
"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 [];
|
|
}
|
|
|
|
// Get a contact from the dominant account
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore - in CD/CI pipeline this will have any type
|
|
const contactUnderAccount = relatedContacts.find((contact) => contact.AccountId === dominantAccountId);
|
|
if (!contactUnderAccount) {
|
|
log.error(
|
|
`Could not find a contact under the dominant account id ${dominantAccountId}`,
|
|
safeStringify({ relatedContacts })
|
|
);
|
|
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 [
|
|
{
|
|
id: dominantAccountId,
|
|
email: "",
|
|
ownerId: contactUnderAccount.ownerId,
|
|
ownerEmail: contactUnderAccount.ownerEmail,
|
|
recordType: SalesforceRecordEnum.ACCOUNT,
|
|
},
|
|
];
|
|
}
|
|
|
|
log.info("No account found for attendee");
|
|
SalesforceRoutingTraceService.graphqlNoAccountFound({
|
|
email,
|
|
reason: "No account found via any tier",
|
|
});
|
|
return [];
|
|
}
|
|
|
|
private getAllPossibleAccountWebsiteFromEmailDomain(emailDomain: string) {
|
|
return getAllPossibleWebsiteValuesFromEmailDomain(emailDomain);
|
|
}
|
|
}
|