* feat(salesforce): add field rules for round robin skip - Add rrSkipFieldRules schema to appDataSchema in zod.ts - Implement applyFieldRules method in CrmService.ts for post-query filtering - Integrate field rules into getContacts method when forRoundRobinSkip=true - Add UI component for configuring field rules in EventTypeAppCardInterface - Add translations for new UI strings - Add comprehensive tests for field rules functionality Field rules allow users to specify conditions based on Salesforce record fields with 'ignore' or 'must_include' actions. Rules are evaluated with AND logic and gracefully handle missing fields by skipping those rules. Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add children prop to Section.SubSectionHeader for field rules Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat(salesforce): add edit button for field rules Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat(salesforce): apply field rules to GraphQL results Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat(salesforce): add Redis caching for field validation Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix(salesforce): remove Redis caching to fix ERR_INVALID_THIS error - Removed Redis caching for field validation that was causing ERR_INVALID_THIS errors - Simplified field rules filtering to try query directly and skip filtering if it fails - Restored ensureFieldsExistOnObject method for other uses (write to record) - Field rules now gracefully handle invalid fields by skipping filtering Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * chore: reformat getAttributes.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(salesforce): dynamically build GraphQL query for field rules with multi-edge iteration - Add buildDynamicAccountQuery to inject field rule fields into GraphQL query - Add passesFieldRules to evaluate ignore/must_include rules against UIAPI nodes - Validate field rules via getObjectFieldNames (in-memory + Redis cache) before branching - Remove applyFieldRulesToGraphQLRecords (used jsforce conn in GraphQL path) - Iterate all edges in Tiers 1 and 2 instead of only the first result - Rank Tier 3 accounts by contact count and fallback to next on field rule failure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(salesforce): add field rule routing trace steps Add trace calls alongside field rule info logs for routing visibility: - fieldRulesValidated: records validation result in CrmService - fieldRuleFilteredRecord: records when account is filtered at each tier - fieldRuleEvaluated: records individual rule evaluation details - allRecordsFilteredByFieldRules: records when SOQL records are all filtered Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(salesforce): remove duplicate field validation and fix disabled Select options applyFieldRules now receives pre-validated rules from the early validation block instead of re-validating internally. Also adds the missing options prop to the disabled Select in the field rules UI. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(salesforce): include failed rule details in field rule trace steps Rename passesFieldRules to getFailingFieldRule so the caller receives the specific rule that caused filtering. The fieldRuleFilteredRecord trace step now includes failedRule with field, value, and action. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(salesforce): add GraphQL path tests for field rules Cover field rule filtering across all three GraphQL resolution tiers (contact, account, related contacts) with ignore and must_include actions, multi-edge fallback, case-insensitivity, and dynamic query selection. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Abstract new field rules settings * Reduce extra SOQL call * fix(salesforce): fix type errors in CrmService and GraphQL client - Add missing import for getRedisService from @calcom/features/di/containers/Redis - Fix ensureFieldsExistOnObject to properly return Field[] instead of incomplete function - Use Array.from() instead of spread operator for Set and Map iterators to fix downlevelIteration errors 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>
517 lines
17 KiB
TypeScript
517 lines
17 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";
|
|
import type { RRSkipFieldRule } from "../../zod";
|
|
import { RRSkipFieldRuleActionEnum } from "../../zod";
|
|
|
|
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,
|
|
fieldRules?: RRSkipFieldRule[]
|
|
): 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 hasFieldRules = fieldRules && fieldRules.length > 0;
|
|
const fieldRuleFields = hasFieldRules
|
|
? Array.from(new Set(fieldRules.map((r) => r.field)))
|
|
: [];
|
|
log.info(`fieldRuleFields`, fieldRuleFields);
|
|
|
|
if (hasFieldRules) {
|
|
log.info(
|
|
"Using dynamic query with field rule fields",
|
|
safeStringify({ fieldRuleFields, fieldRules })
|
|
);
|
|
}
|
|
|
|
// Use dynamic query if field rules require extra fields, otherwise use static typed query
|
|
const queryDoc = hasFieldRules
|
|
? this.buildDynamicAccountQuery(fieldRuleFields)
|
|
: GetAccountRecordsForRRSkip;
|
|
|
|
const query = await this.client.query(queryDoc, {
|
|
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 contactEdges = queryData.uiapi.query.Contact?.edges ?? [];
|
|
|
|
for (const edge of contactEdges) {
|
|
const contact = edge?.node;
|
|
if (!contact) continue;
|
|
|
|
const accountNode = contact.Account;
|
|
|
|
// Check field rules against the contact's account
|
|
if (hasFieldRules && accountNode) {
|
|
const failedRule = this.getFailingFieldRule(accountNode, fieldRules);
|
|
if (failedRule) {
|
|
log.info(
|
|
`Contact ${contact.Id}'s account filtered out by field rules, checking next contact`
|
|
);
|
|
SalesforceRoutingTraceService.fieldRuleFilteredRecord({
|
|
tier: "contact",
|
|
recordId: contact.Id,
|
|
reason: "Contact's account filtered out by field rules",
|
|
failedRule: { field: failedRule.field, value: failedRule.value, action: failedRule.action },
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
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 accountEdges = queryData.uiapi.query.Account?.edges ?? [];
|
|
|
|
for (const edge of accountEdges) {
|
|
const account = edge?.node;
|
|
if (!account) continue;
|
|
|
|
// Check field rules against the account
|
|
if (hasFieldRules) {
|
|
const failedRule = this.getFailingFieldRule(account, fieldRules);
|
|
if (failedRule) {
|
|
log.info(
|
|
`Account ${account.Id} filtered out by field rules, checking next account`
|
|
);
|
|
SalesforceRoutingTraceService.fieldRuleFilteredRecord({
|
|
tier: "account",
|
|
recordId: account.Id,
|
|
reason: "Account filtered out by field rules",
|
|
failedRule: { field: failedRule.field, value: failedRule.value, action: failedRule.action },
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
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 [];
|
|
|
|
// Collect all valid related contacts and store their Account nodes for field rule checks
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const accountNodesByAccountId = new Map<string, Record<string, any>>();
|
|
// 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;
|
|
}
|
|
|
|
// Store the Account node for field rule evaluation later
|
|
if (!accountNodesByAccountId.has(node.AccountId.value)) {
|
|
accountNodesByAccountId.set(node.AccountId.value, node.Account);
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
if (relatedContacts.length === 0) {
|
|
SalesforceRoutingTraceService.graphqlNoAccountFound({
|
|
email,
|
|
reason: "No valid related contacts found",
|
|
});
|
|
return [];
|
|
}
|
|
|
|
// Rank accounts by contact count (most contacts first)
|
|
const accountCounts = new Map<string, number>();
|
|
for (const contact of relatedContacts) {
|
|
accountCounts.set(contact.AccountId, (accountCounts.get(contact.AccountId) || 0) + 1);
|
|
}
|
|
const rankedAccountIds = Array.from(accountCounts.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([accountId]) => accountId);
|
|
|
|
// Try each account in order of dominance, applying field rules
|
|
for (const accountId of rankedAccountIds) {
|
|
// Check field rules against the account node
|
|
if (hasFieldRules) {
|
|
const accountNode = accountNodesByAccountId.get(accountId);
|
|
if (accountNode) {
|
|
const failedRule = this.getFailingFieldRule(accountNode, fieldRules);
|
|
if (failedRule) {
|
|
log.info(
|
|
`Dominant account ${accountId} filtered out by field rules, trying next account`
|
|
);
|
|
SalesforceRoutingTraceService.fieldRuleFilteredRecord({
|
|
tier: "related_contacts",
|
|
recordId: accountId,
|
|
reason: "Dominant account filtered out by field rules",
|
|
failedRule: { field: failedRule.field, value: failedRule.value, action: failedRule.action },
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 === accountId
|
|
);
|
|
if (!contactUnderAccount) {
|
|
log.error(
|
|
`Could not find a contact under account id ${accountId}`,
|
|
safeStringify({ relatedContacts })
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Trace account selection
|
|
const contactsUnderAccount = relatedContacts.filter(
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
(contact) => contact.AccountId === accountId
|
|
);
|
|
SalesforceRoutingTraceService.graphqlDominantAccountSelected({
|
|
accountId,
|
|
contactCount: contactsUnderAccount.length,
|
|
ownerEmail: contactUnderAccount.ownerEmail,
|
|
});
|
|
|
|
log.info(
|
|
`Account found via related contacts with account id ${accountId}`
|
|
);
|
|
return [
|
|
{
|
|
id: accountId,
|
|
email: "",
|
|
ownerId: contactUnderAccount.ownerId,
|
|
ownerEmail: contactUnderAccount.ownerEmail,
|
|
recordType: SalesforceRecordEnum.ACCOUNT,
|
|
},
|
|
];
|
|
}
|
|
|
|
log.info("All related contact accounts filtered out by field rules");
|
|
SalesforceRoutingTraceService.graphqlNoAccountFound({
|
|
email,
|
|
reason: "All accounts filtered out by field rules",
|
|
});
|
|
return [];
|
|
}
|
|
|
|
log.info("No account found for attendee");
|
|
SalesforceRoutingTraceService.graphqlNoAccountFound({
|
|
email,
|
|
reason: "No account found via any tier",
|
|
});
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Checks whether an Account node passes all configured field rules.
|
|
* Account nodes use the UIAPI pattern where field values are wrapped in { value }.
|
|
*
|
|
* @returns `null` if all rules pass, or the first failing rule if filtered.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
private getFailingFieldRule(
|
|
accountNode: Record<string, any>,
|
|
fieldRules: RRSkipFieldRule[]
|
|
): RRSkipFieldRule | null {
|
|
const log = logger.getSubLogger({ prefix: [`[getFailingFieldRule]`] });
|
|
|
|
for (const rule of fieldRules) {
|
|
const fieldData = accountNode[rule.field];
|
|
const actualValue = String(fieldData?.value ?? "").toLowerCase();
|
|
const ruleValue = rule.value.toLowerCase();
|
|
const matches = actualValue === ruleValue;
|
|
|
|
log.info(
|
|
`Evaluating rule: field="${rule.field}" action="${
|
|
rule.action
|
|
}" ruleValue="${ruleValue}" actualValue="${actualValue}" rawFieldData=${safeStringify(
|
|
fieldData
|
|
)} matches=${matches}`
|
|
);
|
|
|
|
if (rule.action === RRSkipFieldRuleActionEnum.IGNORE && matches) {
|
|
log.info(
|
|
`FILTERED: ignore rule matched — field "${rule.field}" equals "${ruleValue}"`
|
|
);
|
|
SalesforceRoutingTraceService.fieldRuleEvaluated({
|
|
field: rule.field,
|
|
action: rule.action,
|
|
ruleValue,
|
|
actualValue,
|
|
matches,
|
|
result: "filtered",
|
|
});
|
|
return rule;
|
|
}
|
|
|
|
if (rule.action === RRSkipFieldRuleActionEnum.MUST_INCLUDE && !matches) {
|
|
log.info(
|
|
`FILTERED: must_include rule failed — field "${rule.field}" is "${actualValue}", expected "${ruleValue}"`
|
|
);
|
|
SalesforceRoutingTraceService.fieldRuleEvaluated({
|
|
field: rule.field,
|
|
action: rule.action,
|
|
ruleValue,
|
|
actualValue,
|
|
matches,
|
|
result: "filtered",
|
|
});
|
|
return rule;
|
|
}
|
|
}
|
|
log.info("All field rules passed");
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Builds a GraphQL query string that includes additional Account fields
|
|
* for field rule evaluation. Mirrors the static GetAccountRecordsForRRSkip query
|
|
* but injects extra fields into all Account selections.
|
|
*/
|
|
private buildDynamicAccountQuery(fieldRuleFields: string[]): string {
|
|
const extraFields = fieldRuleFields
|
|
.map((field) => `${field} { value }`)
|
|
.join("\n ");
|
|
|
|
return `
|
|
query GetAccountRecordsForRRSkipWithFields($email: Email!, $websites: [Url!]!, $emailDomain: Email!) {
|
|
uiapi {
|
|
query {
|
|
Contact(where: { Email: { eq: $email } }) {
|
|
edges {
|
|
node {
|
|
Id
|
|
Email { value }
|
|
OwnerId { value }
|
|
AccountId { value }
|
|
Account {
|
|
Website { value }
|
|
Owner {
|
|
Id
|
|
Email { value }
|
|
}
|
|
${extraFields}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Account(where: { Website: { in: $websites } }) {
|
|
edges {
|
|
node {
|
|
Id
|
|
Website { value }
|
|
Owner {
|
|
Id
|
|
Email { value }
|
|
}
|
|
${extraFields}
|
|
}
|
|
}
|
|
}
|
|
relatedContacts: Contact(
|
|
where: {
|
|
and: [
|
|
{ Email: { like: $emailDomain } }
|
|
{ Email: { ne: $email } }
|
|
{ AccountId: { ne: null } }
|
|
]
|
|
}
|
|
) {
|
|
edges {
|
|
node {
|
|
Id
|
|
Email { value }
|
|
AccountId { value }
|
|
Account {
|
|
Id
|
|
Owner {
|
|
Id
|
|
Email { value }
|
|
}
|
|
${extraFields}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
}
|
|
|
|
private getAllPossibleAccountWebsiteFromEmailDomain(emailDomain: string) {
|
|
return getAllPossibleWebsiteValuesFromEmailDomain(emailDomain);
|
|
}
|
|
}
|