* feat: Cal.diy — community-driven MIT-licensed fork of Cal.com This squashed commit contains all Cal.diy changes applied on top of calcom/cal.com main: - Rebrand Cal.com to Cal.diy across the entire codebase - Remove Enterprise Edition (EE) features, license checks, and AGPL restrictions - Switch license from AGPL-3.0 to MIT - Remove docs/ directory (migrated to Nextra at cal.diy) - Remove dead code: org tests, EE tips, platform nav, premium username, SAML/SSO, etc. - Clean up .env.example for self-hosted Cal.diy - Update Docker image references to calcom/cal.diy - Update README, CONTRIBUTING.md, and issue templates for Cal.diy community fork - Add PR welcome bot for Cal.diy contributors - Fix API v2 breaking changes oasdiff ignore entries - Replace Blacksmith CI runners with default GitHub Actions 3893 files changed, 20789 insertions(+), 411020 deletions(-) Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: remove org-specific /organizations/:orgId endpoints from API v2 atoms controllers (#1701) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: revert Cal.diy Inc to Cal.com, Inc. in license files, copyright notices, and package metadata (#1702) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * rip out org related comments in api v2 --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
import process from "node:process";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import type { Contact } from "@calcom/types/CrmService";
|
|
import { Client, cacheExchange, fetchExchange } from "@urql/core";
|
|
import { retryExchange } from "@urql/exchange-retry";
|
|
import type { RRSkipFieldRule } from "../../zod";
|
|
import { RRSkipFieldRuleActionEnum } from "../../zod";
|
|
import { SalesforceRecordEnum } from "../enums";
|
|
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, fieldRules?: RRSkipFieldRule[]): Promise<Contact[]> {
|
|
const log = logger.getSubLogger({
|
|
prefix: [`[getAccountRecordsForRRSkip]:${email}`],
|
|
});
|
|
const emailDomain = email.split("@")[1];
|
|
const websites = this.getAllPossibleAccountWebsiteFromEmailDomain(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`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
log.info(`Existing contact found with id ${contact.Id}`);
|
|
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`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
log.info(
|
|
`Existing account with website that matches email domain of ${emailDomain} found with id ${account.Id}`
|
|
);
|
|
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>>();
|
|
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;
|
|
}[]
|
|
);
|
|
|
|
if (relatedContacts.length === 0) {
|
|
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`);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
const contactUnderAccount = relatedContacts.find((contact) => contact.AccountId === accountId);
|
|
if (!contactUnderAccount) {
|
|
log.error(
|
|
`Could not find a contact under account id ${accountId}`,
|
|
safeStringify({ relatedContacts })
|
|
);
|
|
continue;
|
|
}
|
|
|
|
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");
|
|
return [];
|
|
}
|
|
|
|
log.info("No account found for attendee");
|
|
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}"`);
|
|
return rule;
|
|
}
|
|
|
|
if (rule.action === RRSkipFieldRuleActionEnum.MUST_INCLUDE && !matches) {
|
|
log.info(
|
|
`FILTERED: must_include rule failed — field "${rule.field}" is "${actualValue}", expected "${ruleValue}"`
|
|
);
|
|
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);
|
|
}
|
|
}
|