Files
calendar/packages/app-store/salesforce/lib/graphql/SalesforceGraphQLClient.ts
T
Joe Au-YeungGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Claude Opus 4.5Volnei Munhoz
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

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);
}
}