Files
Benny JooGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ab21c7f805 refactor: Cal.diy (#28903)
* 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: [email protected] <[email protected]>

* 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>
2026-04-15 09:52:36 -03:00

1957 lines
67 KiB
TypeScript

import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { getRedisService } from "@calcom/features/di/containers/Redis";
import { checkIfFreeEmailDomain } from "@calcom/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
import { getLocation } from "@calcom/lib/CalEventParser";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { RetryableError } from "@calcom/lib/crmManager/errors";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";
import type { CalEventResponses, CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { Contact, CRM, CrmEvent } from "@calcom/types/CrmService";
import type { Connection, Field, TokenResponse } from "@jsforce/jsforce-node";
import jsforce from "@jsforce/jsforce-node";
import { RRule } from "rrule";
import { z } from "zod";
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import { default as appMeta } from "../config.json";
import type { appDataSchema, RRSkipFieldRule, writeToBookingEntry, writeToRecordDataSchema } from "../zod";
import { RRSkipFieldRuleActionEnum } from "../zod";
import {
DateFieldTypeData,
RoutingReasons,
SalesforceFieldType,
SalesforceRecordEnum,
WhenToWriteToRecord,
} from "./enums";
import { PrismaAssignmentReasonRepository } from "./repositories/PrismaAssignmentReasonRepository";
/**
* Extended CRM interface with Salesforce-specific methods.
*/
export interface SalesforceCRM extends CRM {
findUserEmailFromLookupField(
attendeeEmail: string,
fieldName: string,
salesforceObject: SalesforceRecordEnum
): Promise<{ email: string; recordType: RoutingReasons } | undefined>;
incompleteBookingWriteToRecord(
email: string,
writeToRecordObject: z.infer<typeof writeToRecordDataSchema>
): Promise<void>;
getAllPossibleAccountWebsiteFromEmailDomain(emailDomain: string): string;
}
import { getSalesforceAppKeys } from "./getSalesforceAppKeys";
import { getSalesforceTokenLifetime } from "./getSalesforceTokenLifetime";
import { SalesforceGraphQLClient } from "./graphql/SalesforceGraphQLClient";
import getAllPossibleWebsiteValuesFromEmailDomain from "./utils/getAllPossibleWebsiteValuesFromEmailDomain";
import type { GetDominantAccountIdInput } from "./utils/getDominantAccountId";
import getDominantAccountId from "./utils/getDominantAccountId";
class SFObjectToUpdateNotFoundError extends RetryableError {
constructor(message: string) {
super(message);
}
}
type ExtendedTokenResponse = TokenResponse & {
instance_url: string;
};
const sfApiErrors = {
INVALID_EVENTWHOIDS: "INVALID_FIELD: No such column 'EventWhoIds' on sobject of type Event",
};
type ContactRecord = {
Id?: string;
Email?: string;
OwnerId?: string;
AccountId?: string;
attributes?: {
type?: string;
};
Account?: {
Owner?: {
Email?: string;
};
Website?: string;
};
Owner?: {
Email?: string;
Name?: string;
};
};
type SalesforceDuplicateError = {
name?: string;
duplicateResult?: {
matchResults?: Array<{
matchRecords?: Array<{
record: { Id: string };
}>;
}>;
};
};
type Attendee = { email: string; name: string };
const salesforceTokenSchema = z.object({
id: z.string(),
issued_at: z.string(), // Salesforce returns this in milliseconds as a string
instance_url: z.string(),
signature: z.string(),
access_token: z.string(),
scope: z.string(),
token_type: z.string(),
token_lifetime: z.number().optional(), // Token lifetime in seconds (from introspection)
});
class SalesforceCRMService implements CRM {
private integrationName = "";
private conn!: Promise<Connection>;
private log: typeof logger;
private calWarnings: string[] = [];
private appOptions: z.infer<typeof appDataSchema>;
private doNotCreateEvent = false;
private fallbackToContact = false;
private accessToken: string;
private instanceUrl: string;
private hasAttemptedRefresh = false;
private credentialId: number;
private describeCache = new Map<string, Set<string>>();
/**
* Escapes a string value for safe interpolation into SOQL queries.
* Prevents SOQL injection by escaping backslashes and single quotes.
*/
private sanitizeSoqlValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
constructor(credential: CredentialPayload, appOptions: z.infer<typeof appDataSchema>, testMode = false) {
this.integrationName = "salesforce_other_calendar";
this.credentialId = credential.id;
if (!testMode) {
this.conn = this.getClient(credential).then((c) => c);
}
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.appOptions = appOptions;
const credentialKey = credential.key as unknown as ExtendedTokenResponse;
this.accessToken = credentialKey.access_token;
this.instanceUrl = credentialKey.instance_url;
}
public getAppOptions() {
return this.appOptions;
}
/**
* Refreshes the Salesforce access token and optionally introspects to get/update token_lifetime.
* @param forceIntrospection - If true, always introspect to recalibrate token_lifetime (e.g., after unexpected expiry)
* @param existingTokenLifetime - The current token_lifetime to reuse if not forcing introspection
*/
private refreshAccessToken = async ({
refreshToken,
forceIntrospection,
existingTokenLifetime,
}: {
refreshToken: string;
forceIntrospection: boolean;
existingTokenLifetime?: number;
}) => {
const { consumer_key, consumer_secret } = await getSalesforceAppKeys();
const response = await fetch("https://login.salesforce.com/services/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: consumer_key,
client_secret: consumer_secret,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const message = `${response.statusText}: ${JSON.stringify(await response.json())}`;
throw new Error(message);
}
const accessTokenJson = await response.json();
const accessTokenParsed = parseRefreshTokenResponse(accessTokenJson, salesforceTokenSchema);
// Introspect if forced or if we don't have a token_lifetime yet
let tokenLifetime = existingTokenLifetime;
if (forceIntrospection || !tokenLifetime) {
tokenLifetime = await getSalesforceTokenLifetime({
accessToken: accessTokenParsed.access_token,
instanceUrl: accessTokenParsed.instance_url,
});
}
// Update credential in database
const updatedKey = {
...accessTokenParsed,
refresh_token: refreshToken,
token_lifetime: tokenLifetime,
};
await CredentialRepository.updateWhereId({
id: this.credentialId,
data: { key: updatedKey },
});
return {
accessToken: accessTokenParsed.access_token,
instanceUrl: accessTokenParsed.instance_url,
issuedAt: accessTokenParsed.issued_at,
tokenLifetime,
};
};
private getClient = async (credential: CredentialPayload) => {
const { consumer_key, consumer_secret } = await getSalesforceAppKeys();
const credentialKey = credential.key as unknown as ExtendedTokenResponse & { token_lifetime?: number };
if (!credentialKey.refresh_token)
throw new Error(`Refresh token is missing for credential ${credential.id}`);
const refreshToken = credentialKey.refresh_token;
// Check if token is still valid
// issued_at is in milliseconds (string), token_lifetime is in seconds
const BUFFER_MS = 5 * 60 * 1000; // 5 minutes buffer before expiry
const issuedAt = parseInt(credentialKey.issued_at, 10);
const tokenLifetimeMs = (credentialKey.token_lifetime || 0) * 1000;
const expiryTime = issuedAt + tokenLifetimeMs;
const isTokenValid = credentialKey.token_lifetime && Date.now() < expiryTime - BUFFER_MS;
if (!isTokenValid) {
try {
const result = await this.refreshAccessToken({
refreshToken,
forceIntrospection: false,
existingTokenLifetime: credentialKey.token_lifetime,
});
// Update instance variables and credentialKey for the connection
this.accessToken = result.accessToken;
this.instanceUrl = result.instanceUrl;
credentialKey.access_token = result.accessToken;
credentialKey.issued_at = result.issuedAt;
credentialKey.token_lifetime = result.tokenLifetime;
} catch (err: unknown) {
console.error(err); // log but proceed
}
}
return new jsforce.Connection({
oauth2: {
clientId: consumer_key,
clientSecret: consumer_secret,
redirectUri: `${WEBAPP_URL}/api/integrations/salesforce/callback`,
},
instanceUrl: credentialKey.instance_url,
accessToken: credentialKey.access_token,
refreshToken: credentialKey.refresh_token,
refreshFn: async (conn, callback) => {
// Only attempt refresh once to avoid infinite loops
if (this.hasAttemptedRefresh) {
return callback(new Error("Token refresh already attempted"));
}
this.hasAttemptedRefresh = true;
try {
// Force introspection to recalibrate token_lifetime after unexpected expiry
const result = await this.refreshAccessToken({
refreshToken,
forceIntrospection: true,
});
this.accessToken = result.accessToken;
this.instanceUrl = result.instanceUrl;
callback(null, result.accessToken);
} catch (err) {
callback(err instanceof Error ? err : new Error(String(err)));
}
},
});
};
private getSalesforceUserIdFromEmail = async (email: string) => {
const conn = await this.conn;
const query = await conn.query(
`SELECT Id, Email FROM User WHERE Email = '${this.sanitizeSoqlValue(email)}' AND IsActive = true LIMIT 1`
);
if (query.records.length > 0) {
return (query.records[0] as { Email: string; Id: string }).Id;
}
};
private getSalesforceUserFromUserId = async (userId: string) => {
const conn = await this.conn;
return await conn.query(`SELECT Id, Email, Name FROM User WHERE Id = '${this.sanitizeSoqlValue(userId)}' AND IsActive = true`);
};
private getSalesforceEventBody = (event: CalendarEvent): string => {
return `${event.organizer.language.translate("invitee_timezone")}: ${
event.attendees[0].timeZone
} \r\n\r\n ${event.organizer.language.translate("share_additional_notes")}\r\n${
event.additionalNotes || "-"
}`;
};
private salesforceCreateEventApiCall = async (
event: CalendarEvent,
options: { [key: string]: unknown }
) => {
const conn = await this.conn;
return await conn.sobject("Event").create({
StartDateTime: new Date(event.startTime).toISOString(),
EndDateTime: new Date(event.endTime).toISOString(),
Subject: event.title,
Description: this.getSalesforceEventBody(event),
Location: getLocation({
videoCallData: event.videoCallData,
additionalInformation: event.additionalInformation,
location: event.location,
uid: event.uid,
}),
...options,
...(event.recurringEvent && {
IsRecurrence2: true,
Recurrence2PatternText: new RRule(event.recurringEvent).toString(),
}),
});
};
private salesforceCreateEvent = async (event: CalendarEvent, contacts: Contact[]) => {
const log = logger.getSubLogger({ prefix: [`[salesforceCreateEvent]:${event.uid}`] });
const appOptions = this.getAppOptions();
const writeToEventRecord = await this.generateWriteToEventBody(event);
log.info(`Writing to event fields: ${Object.keys(writeToEventRecord)} `);
let ownerId: string | undefined;
if (event?.organizer?.email) {
ownerId = await this.getSalesforceUserIdFromEmail(event.organizer.email);
} else {
log.warn("No organizer email found for event", event?.organizer);
}
log.info(`Organizer found with Salesforce id ${ownerId}`);
/**
* Current code assume that contacts is not empty.
**/
const [firstContact] = contacts;
if (!firstContact?.id) {
log.error("No contacts found for event", { contacts });
throw new SFObjectToUpdateNotFoundError("No contacts found for event");
}
const eventWhoIds = contacts.reduce((contactIds, contact) => {
if (contact?.id) {
contactIds.push(contact.id);
}
return contactIds;
}, [] as string[]);
if (eventWhoIds.length !== contacts.length) {
log.warn(`Not all contacts contain ids ${contacts}`);
}
const createdEvent = await this.salesforceCreateEventApiCall(event, {
EventWhoIds: eventWhoIds,
...writeToEventRecord,
...(ownerId && { OwnerId: ownerId }),
}).catch(async (reason) => {
if (reason === sfApiErrors.INVALID_EVENTWHOIDS) {
this.calWarnings.push(
`Please enable option "Allow Users to Relate Multiple Contacts to Tasks and Events" under
"Setup > Feature Settings > Sales > Activity Settings" to be able to create events with
multiple contact attendees.`
);
// User has not configured "Allow Users to Relate Multiple Contacts to Tasks and Events"
// proceeding to create the event using just the first attendee as the primary WhoId
return await this.salesforceCreateEventApiCall(event, {
WhoId: firstContact.id,
}).catch((reason) => Promise.reject(reason));
}
log.error(`Error creating event: ${JSON.stringify(reason)}`);
// Try creating a simple object without additional records
return await this.salesforceCreateEventApiCall(event, {
WhoId: firstContact.id,
...(ownerId && { OwnerId: ownerId }),
}).catch((reason) => {
log.error(`Error creating simple event: ${JSON.stringify(reason)}`);
return Promise.reject(reason);
});
});
// Check to see if we also need to change the record owner
if (appOptions?.onBookingChangeRecordOwner && appOptions?.onBookingChangeRecordOwnerName) {
if (ownerId) {
// TODO: firstContact id is assumed to not be undefined. But current code doesn't check for it.
await this.checkRecordOwnerNameFromRecordId(firstContact.id, ownerId);
} else {
log.warn(
`Could not find owner with email ${event.organizer.email} to change record ${firstContact.id} ownership to`
);
}
}
if (appOptions?.onBookingWriteToRecord && appOptions?.onBookingWriteToRecordFields) {
await this.writeToRecord({
// TODO: firstContact id is assumed to not be undefined. But current code doesn't check for it.
recordId: firstContact.id,
fieldsToWriteTo: appOptions.onBookingWriteToRecordFields,
startTime: event.startTime,
organizerEmail: event.organizer?.email,
calEventResponses: event.responses,
bookingUid: event?.uid,
});
}
return createdEvent;
};
private salesforceUpdateEvent = async (uid: string, event: CalendarEvent) => {
const conn = await this.conn;
return await conn.sobject("Event").update({
Id: uid,
StartDateTime: new Date(event.startTime).toISOString(),
EndDateTime: new Date(event.endTime).toISOString(),
Subject: event.title,
Description: this.getSalesforceEventBody(event),
Location: getLocation({
videoCallData: event.videoCallData,
additionalInformation: event.additionalInformation,
location: event.location,
uid: event.uid,
}),
...(event.recurringEvent && {
IsRecurrence2: true,
Recurrence2PatternText: new RRule(event.recurringEvent).toString(),
}),
});
};
private salesforceDeleteEvent = async (uid: string, event: CalendarEvent) => {
const appOptions = this.getAppOptions();
const conn = await this.conn;
if (appOptions?.onCancelWriteToEventRecord) {
const fieldsToWriteTo = appOptions?.onCancelWriteToEventRecordFields;
// If the option is enabled then don't delete the event record
if (!fieldsToWriteTo || !Object.keys(fieldsToWriteTo)) {
return Promise.resolve();
}
return await this.writeToRecord({
recordId: uid,
fieldsToWriteTo,
startTime: event.startTime,
organizerEmail: event.organizer?.email,
calEventResponses: event.responses,
bookingUid: event?.uid,
});
}
return await conn.sobject("Event").delete(uid);
};
async handleEventCreation(event: CalendarEvent, contacts: Contact[]) {
const sfEvent = await this.salesforceCreateEvent(event, contacts);
if (sfEvent.success) {
this.log.info("event:creation:ok", { sfEvent });
return {
uid: sfEvent.id,
id: sfEvent.id,
type: "salesforce_other_calendar",
password: "",
url: "",
additionalInfo: { contacts, sfEvent, calWarnings: this.calWarnings },
};
}
this.log.info("event:creation:notOk", { event, sfEvent, contacts });
return Promise.reject({
calError: "Something went wrong when creating an event in Salesforce",
});
}
async createEvent(event: CalendarEvent, contacts: Contact[]): Promise<CrmEvent | undefined> {
const skipEventCreation = this.getDoNotCreateEvent();
if (skipEventCreation) return undefined;
const sfEvent = await this.salesforceCreateEvent(event, contacts);
if (sfEvent.success) {
return {
uid: sfEvent.id,
id: sfEvent.id,
type: "salesforce_other_calendar",
password: "",
url: "",
additionalInfo: { contacts, sfEvent, calWarnings: this.calWarnings },
};
}
this.log.debug("event:creation:notOk", { event, sfEvent, contacts });
return Promise.reject("Something went wrong when creating an event in Salesforce");
}
async updateEvent(uid: string, event: CalendarEvent): Promise<CrmEvent> {
const updatedEvent = await this.salesforceUpdateEvent(uid, event);
if (updatedEvent.success) {
return {
uid: updatedEvent.id,
id: updatedEvent.id,
type: "salesforce_other_calendar",
password: "",
url: "",
additionalInfo: { calWarnings: this.calWarnings },
};
} else {
return Promise.reject({ calError: "Something went wrong when updating the event in Salesforce" });
}
}
public async deleteEvent(uid: string, event: CalendarEvent) {
await this.salesforceDeleteEvent(uid, event)
.then(() => {
return Promise.resolve();
})
.catch((error) => {
this.log.error(`Error canceling event ${uid} with error ${error}`);
return Promise.reject({ calError: "Something went wrong when deleting the event in Salesforce" });
});
}
async getContacts({
emails,
includeOwner,
forRoundRobinSkip,
}: {
emails: string | string[];
includeOwner?: boolean;
forRoundRobinSkip?: boolean;
}) {
const log = logger.getSubLogger({ prefix: [`[getContacts]:${emails}`] });
try {
const conn = await this.conn;
const emailArray = Array.isArray(emails) ? emails : [emails];
const appOptions = this.getAppOptions();
const recordToSearch =
(forRoundRobinSkip ? appOptions?.roundRobinSkipCheckRecordOn : appOptions?.createEventOn) ??
SalesforceRecordEnum.CONTACT;
let soql: string;
log.info(
"Getting contacts for emails",
safeStringify({
emailArray,
includeOwner,
forRoundRobinSkip,
recordToSearch,
appOptions,
})
);
// Validate field rules early, before branching to GraphQL or SOQL paths
let validatedFieldRules: RRSkipFieldRule[] | undefined;
if (forRoundRobinSkip && appOptions?.rrSkipFieldRules?.length) {
log.info(
"Validating field rules",
safeStringify({
rules: appOptions.rrSkipFieldRules,
recordToSearch,
})
);
const existingFieldNames = await this.getObjectFieldNames(recordToSearch);
log.info(`Found ${existingFieldNames.size} fields on ${recordToSearch}`);
if (existingFieldNames.size > 0) {
const filtered = appOptions.rrSkipFieldRules.filter((r) => existingFieldNames.has(r.field));
if (filtered.length > 0) {
validatedFieldRules = filtered;
log.info("Validated field rules", safeStringify({ validatedFieldRules }));
} else {
log.warn(
"No field rules matched existing fields",
safeStringify({
configuredFields: appOptions.rrSkipFieldRules.map((r) => r.field),
})
);
}
}
}
if (recordToSearch === SalesforceRecordEnum.ACCOUNT && forRoundRobinSkip) {
try {
const client = new SalesforceGraphQLClient({
accessToken: this.accessToken,
instanceUrl: this.instanceUrl,
});
return await client.GetAccountRecordsForRRSkip(emailArray[0], validatedFieldRules);
} catch (error) {
log.error("Error getting account records for round robin skip", safeStringify({ error }));
return [];
}
}
let records: ContactRecord[] = [];
// This combination is for searching for ownership via contacts
if (
recordToSearch === SalesforceRecordEnum.CONTACT &&
appOptions?.roundRobinSkipFallbackToLeadOwner &&
forRoundRobinSkip
) {
const record = await this.getContactOrLeadFromEmail({ email: emailArray[0], conn });
if (record) {
records.push(record);
}
}
// If falling back to contacts, check for the contact before returning the leads or empty array
if (
appOptions.createEventOn === SalesforceRecordEnum.LEAD &&
appOptions.createEventOnLeadCheckForContact &&
!forRoundRobinSkip
) {
const record = await this.getContactOrLeadFromEmail({
email: emailArray[0],
setFallbackToContact: true,
conn,
});
if (record) {
records.push(record);
}
}
if (records.length === 0) {
// Build extra SELECT fields from validated field rules so we don't need a second query
const extraFields = validatedFieldRules?.length
? ", " + validatedFieldRules.map((r) => r.field).join(", ")
: "";
// Handle Account record type
if (recordToSearch === SalesforceRecordEnum.ACCOUNT) {
// For an account let's assume that the first email is the one we should be querying against
const attendeeEmail = emailArray[0];
log.info("[recordToSearch=ACCOUNT] Searching contact for email", safeStringify({ attendeeEmail }));
soql = `SELECT Id, Email, OwnerId, AccountId, Account.OwnerId, Account.Owner.Email, Account.Website${extraFields} FROM ${SalesforceRecordEnum.CONTACT} WHERE Email = '${this.sanitizeSoqlValue(attendeeEmail)}' AND AccountId != null`;
} else {
// Handle Contact/Lead record types
soql = `SELECT Id, Email, OwnerId, Owner.Email${extraFields} FROM ${recordToSearch} WHERE Email IN ('${emailArray.map((e) => this.sanitizeSoqlValue(e)).join(
"','"
)}')`;
}
const results = await conn.query(soql);
log.info("Query results", safeStringify({ recordCount: results.records?.length }));
if (results.records.length === 0) {
log.info("No records found");
return [];
}
if (!records.length && results?.records?.length) {
records = results.records as ContactRecord[];
}
}
// Apply field rules if configured and this is for round robin skip
if (forRoundRobinSkip && validatedFieldRules?.length && records.length > 0) {
records = this.applyFieldRules(records, validatedFieldRules);
if (records.length === 0) {
log.info("All records filtered out by field rules");
return [];
}
}
const isFreeEmailDomain = await this.shouldSkipAttendeeIfFreeEmailDomain(emailArray[0]);
const includeOwnerData = (includeOwner || forRoundRobinSkip) && !isFreeEmailDomain;
const includeAccountRecordType = forRoundRobinSkip && recordToSearch === SalesforceRecordEnum.ACCOUNT;
return records.map((record) => {
// Handle if Account is nested
const ownerEmail =
recordToSearch === SalesforceRecordEnum.ACCOUNT &&
record?.attributes?.type !== SalesforceRecordEnum.ACCOUNT
? record?.Account?.Owner?.Email
: record?.Owner?.Email;
return {
id: includeAccountRecordType ? record?.AccountId || "" : record?.Id || "",
email: record?.Email || "",
recordType: includeAccountRecordType ? SalesforceRecordEnum.ACCOUNT : record?.attributes?.type,
...(includeOwnerData && {
ownerId: record?.OwnerId,
ownerEmail: ownerEmail,
}),
};
});
} catch (error) {
log.error("Error in getContacts", safeStringify(error));
return [];
}
}
private async getContactOrLeadFromEmail({
email,
setFallbackToContact = false,
conn,
}: {
email: string;
setFallbackToContact?: boolean;
conn: Connection;
}) {
// Escape SOSL reserved characters: ? & | ! { } [ ] ( ) ^ ~ * : \ " ' + -
// eslint-disable-next-line no-useless-escape
const escapedEmail = email.replace(/([?&|!{}[\]()^~*:\\"'+-])/g, "\\$1");
const searchResult = await conn.search(
`FIND {${escapedEmail}} IN EMAIL FIELDS RETURNING Lead(Id, Email, OwnerId, Owner.Email), Contact(Id, Email, OwnerId, Owner.Email)`
);
if (searchResult.searchRecords.length === 0) {
return null;
}
// See if a contact was found first
const contactQuery = searchResult.searchRecords.filter(
(record) => record.attributes?.type === SalesforceRecordEnum.CONTACT
);
if (contactQuery.length > 0) {
this.setFallbackToContact(setFallbackToContact);
return contactQuery[0] as ContactRecord;
} else {
// If not fallback to lead
const leadQuery = searchResult.searchRecords.filter(
(record) => record.attributes?.type === SalesforceRecordEnum.LEAD
);
if (leadQuery.length > 0) {
return leadQuery[0] as ContactRecord;
}
}
return null;
}
async createContacts(
contactsToCreate: Attendee[],
organizerEmail?: string,
calEventResponses?: CalEventResponses | null
) {
const log = logger.getSubLogger({ prefix: [`[createContacts]`] });
const conn = await this.conn;
const appOptions = this.getAppOptions();
const createEventOn = appOptions.createEventOn ?? SalesforceRecordEnum.CONTACT;
// See if the organizer exists in the CRM
const organizerId = organizerEmail ? await this.getSalesforceUserIdFromEmail(organizerEmail) : undefined;
const createdContacts: { id: string; email: string }[] = [];
log.info("createContacts", safeStringify({ createEventOn, organizerId, contactsToCreate }));
if (createEventOn === SalesforceRecordEnum.CONTACT) {
await Promise.all(
contactsToCreate.map(async (attendee) => {
return await this.createAttendeeRecord({
attendee,
recordType: SalesforceRecordEnum.CONTACT,
organizerId,
}).then((result) => {
createdContacts.push(...result);
});
})
);
}
if (!contactsToCreate[0]?.email) {
this.log.warn(`createContact: no attendee email found `, contactsToCreate);
return [];
}
if (createEventOn === SalesforceRecordEnum.LEAD) {
// Base this off of the first contact
const attendee = contactsToCreate[0];
if (appOptions.createNewContactUnderAccount) {
// Check for an account
const accountId = await this.getAccountIdBasedOnEmailDomainOfContacts(attendee.email);
if (accountId) {
const createdAccountContacts = await this.createNewContactUnderAnAccount({
attendee,
accountId,
organizerId,
});
if (createdAccountContacts.length > 0) {
createdContacts.push(...createdAccountContacts);
}
} else {
await this.createAttendeeRecord({
attendee,
recordType: SalesforceRecordEnum.LEAD,
organizerId,
calEventResponses,
}).then((result) => {
createdContacts.push(...result);
});
}
} else {
await this.createAttendeeRecord({
attendee,
recordType: SalesforceRecordEnum.LEAD,
organizerId,
calEventResponses,
}).then((result) => {
createdContacts.push(...result);
});
}
}
if (createEventOn === SalesforceRecordEnum.ACCOUNT) {
if (!appOptions.createNewContactUnderAccount && !appOptions.createLeadIfAccountNull) {
this.setDoNotCreateEvent(true);
return [{ id: "Do not create event", email: "placeholder" }];
}
// Base this off of the first contact
const attendee = contactsToCreate[0];
const accountId = await this.getAccountIdBasedOnEmailDomainOfContacts(attendee.email);
let contactCreated = false;
if (accountId && appOptions.createNewContactUnderAccount) {
const createdAccountContacts = await this.createNewContactUnderAnAccount({
attendee,
accountId,
organizerId,
});
if (createdAccountContacts.length > 0) {
createdContacts.push(...createdAccountContacts);
contactCreated = true;
}
}
if (!accountId && appOptions.createLeadIfAccountNull && !contactCreated) {
// Check to see if the lead exists already
const leadQuery = await conn.query(
`SELECT Id, Email FROM Lead WHERE Email = '${this.sanitizeSoqlValue(attendee.email)}' LIMIT 1`
);
if (leadQuery.records.length > 0) {
const contact = leadQuery.records[0] as { Id: string; Email: string };
return [{ id: contact.Id, email: contact.Email }];
}
for (const attendee of contactsToCreate) {
try {
const createBody = await this.generateCreateRecordBody({
attendee,
recordType: SalesforceRecordEnum.LEAD,
organizerId,
calEventResponses,
});
const result = await conn.sobject(SalesforceRecordEnum.LEAD).create(createBody);
if (result.success) {
createdContacts.push({ id: result.id, email: attendee.email });
}
} catch (error: unknown) {
if (error instanceof Error && error.name === "DUPLICATES_DETECTED") {
// we know it's a DuplicateError now (DUPLICATES_DETECTED)
const existingId = this.getExistingIdFromDuplicateError(error as SalesforceDuplicateError);
if (existingId) {
log.info("Using existing record:", existingId);
createdContacts.push({ id: existingId, email: attendee.email });
}
} else {
log.error("Error creating lead:", error);
}
}
}
}
}
if (createdContacts.length === 0) {
// This should never happen
log.error(`No contacts created for these app options ${safeStringify(appOptions)}`);
}
return createdContacts;
}
async handleAttendeeNoShow(bookingUid: string, attendees: { email: string; noShow: boolean }[]) {
const appOptions = this.getAppOptions();
const { sendNoShowAttendeeData = false, sendNoShowAttendeeDataField = {} } = appOptions;
const conn = await this.conn;
// Check that no show is enabled
if (!sendNoShowAttendeeData && !sendNoShowAttendeeDataField) {
this.log.warn(`No show settings not set for bookingUid ${bookingUid}`);
return;
}
// Get all Salesforce events associated with the booking
const salesforceEvents = await prisma.bookingReference.findMany({
where: {
type: appMeta.type,
booking: {
uid: bookingUid,
},
deleted: null,
},
});
const salesforceEntity = await conn.describe("Event");
const fields = salesforceEntity.fields;
const noShowField = fields.find((field) => field.name === sendNoShowAttendeeDataField);
if (!noShowField || (noShowField.type as unknown as string) !== "boolean") {
this.log.warn(
`No show field on Salesforce doesn't exist or is not of type boolean for bookingUid ${bookingUid}`
);
return;
}
for (const event of salesforceEvents) {
const salesforceEvent = (await conn.query(`SELECT WhoId FROM Event WHERE Id = '${this.sanitizeSoqlValue(event.uid)}'`)) as {
records: { WhoId: string }[];
};
let salesforceAttendeeEmail: string | undefined;
// Figure out if the attendee is a contact or lead
const contactQuery = (await conn.query(
`SELECT Email FROM Contact WHERE Id = '${this.sanitizeSoqlValue(salesforceEvent.records[0].WhoId)}'`
)) as { records: { Email: string }[] };
const leadQuery = (await conn.query(
`SELECT Email FROM Lead WHERE Id = '${this.sanitizeSoqlValue(salesforceEvent.records[0].WhoId)}'`
)) as { records: { Email: string }[] };
// Prioritize contacts over leads
if (contactQuery.records.length > 0) {
salesforceAttendeeEmail = contactQuery.records[0].Email;
} else if (leadQuery.records.length > 0) {
salesforceAttendeeEmail = leadQuery.records[0].Email;
} else {
this.log.warn(
`Could not find attendee for bookingUid ${bookingUid} and salesforce event id ${event.uid}`
);
}
if (salesforceAttendeeEmail) {
// Find the attendee no show data
const noShowData = attendees.find((attendee) => attendee.email === salesforceAttendeeEmail);
if (!noShowData) {
this.log.warn(
`No show data could not be found for ${salesforceAttendeeEmail} and bookingUid ${bookingUid}`
);
} else {
// Update the event with the no show data
await conn.sobject("Event").update({
Id: event.uid,
[sendNoShowAttendeeDataField as string]: noShowData.noShow,
});
}
}
}
}
private getExistingIdFromDuplicateError(error: SalesforceDuplicateError): string | null {
if (error.duplicateResult && error.duplicateResult.matchResults) {
for (const matchResult of error.duplicateResult.matchResults) {
if (matchResult.matchRecords && matchResult.matchRecords.length > 0) {
return matchResult.matchRecords[0].record.Id;
}
}
}
return null;
}
private setDoNotCreateEvent(boolean: boolean) {
this.doNotCreateEvent = boolean;
}
private getDoNotCreateEvent() {
return this.doNotCreateEvent;
}
private getDominantAccountId(contacts: GetDominantAccountIdInput) {
return getDominantAccountId(contacts);
}
private async createAttendeeRecord({
attendee,
recordType,
organizerId,
accountId,
calEventResponses,
}: {
attendee: Attendee;
recordType: SalesforceRecordEnum;
organizerId?: string;
accountId?: string;
calEventResponses?: CalEventResponses | null;
}) {
const log = logger.getSubLogger({ prefix: [`[createAttendeeRecord]:${attendee.email}`] });
log.info("createAttendeeRecord", safeStringify({ attendee, recordType, organizerId, accountId }));
const conn = await this.conn;
const createBody = await this.generateCreateRecordBody({
attendee,
recordType: recordType,
organizerId,
calEventResponses,
});
return await conn
.sobject(recordType)
.create({
...createBody,
AccountId: accountId,
})
.then((result) => {
if (result.success) {
return [{ id: result.id, email: attendee.email }];
} else {
return [];
}
})
.catch((error) => {
this.log.error(`Error creating Salesforce contact for ${attendee.email} with error ${error}`);
return [];
});
}
private async generateCreateRecordBody({
attendee,
recordType,
organizerId,
calEventResponses,
}: {
attendee: { email: string; name: string };
recordType: SalesforceRecordEnum;
organizerId?: string;
/**Only Leads have the default company field */
calEventResponses?: CalEventResponses | null;
}) {
const [FirstName, LastName] = attendee.name ? attendee.name.split(" ") : [attendee.email, ""];
// Assume that the first part of the email domain is the company title
const company =
(await this.getCompanyNameFromBookingResponse(calEventResponses)) ??
attendee.email.split("@")[1].split(".")[0];
return {
LastName: LastName || "-",
FirstName,
Email: attendee.email,
...(organizerId && { OwnerId: organizerId }),
...(recordType === SalesforceRecordEnum.LEAD && { Company: company }),
};
}
/**
* Gets all field names for a Salesforce object.
* Uses a two-level cache: in-memory (per request) and Redis (cross-request, 1hr TTL).
*/
private async getObjectFieldNames(sobject: string): Promise<Set<string>> {
const log = logger.getSubLogger({ prefix: [`[getObjectFieldNames]`] });
// Level 1: in-memory cache (same request)
const memoryCached = this.describeCache.get(sobject);
if (memoryCached) {
return memoryCached;
}
// Level 2: Redis cache (cross-request)
const cacheKey = `salesforce:describe:${this.instanceUrl}:${sobject}`;
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
try {
const redis = getRedisService();
const redisCached = await redis.get<string[]>(cacheKey);
if (redisCached) {
log.info(`Redis cache hit for ${sobject} field names`);
const fieldNames = new Set(redisCached);
this.describeCache.set(sobject, fieldNames);
return fieldNames;
}
} catch (cacheError) {
log.warn(`Redis cache unavailable, falling back to API`, safeStringify({ error: cacheError }));
}
// Level 3: Salesforce API
const conn = await this.conn;
try {
const salesforceEntity = await conn.describe(sobject);
const fieldNamesList = salesforceEntity.fields.map((f) => f.name);
const fieldNames = new Set(fieldNamesList);
this.describeCache.set(sobject, fieldNames);
// Persist to Redis for other requests
try {
const redis = getRedisService();
await redis.set(cacheKey, fieldNamesList, { ttl: CACHE_TTL_MS });
log.info(`Cached ${sobject} field names in Redis (${fieldNamesList.length} fields)`);
} catch (cacheError) {
log.warn(`Failed to cache field names in Redis`, safeStringify({ error: cacheError }));
}
return fieldNames;
} catch (e) {
log.error(`Error fetching field names for ${sobject}`, safeStringify({ error: e }));
return new Set();
}
}
private async ensureFieldsExistOnObject(fieldsToTest: string[], sobject: string): Promise<Field[]> {
const log = logger.getSubLogger({ prefix: [`[ensureFieldsExistOnObject]`] });
const conn = await this.conn;
const fieldSet = new Set(fieldsToTest);
const foundFields: Field[] = [];
try {
const salesforceEntity = await conn.describe(sobject);
const fields = salesforceEntity.fields;
for (const field of fields) {
if (foundFields.length === fieldSet.size) break;
if (fieldSet.has(field.name)) {
foundFields.push(field);
}
}
return foundFields;
} catch (e) {
log.error(`Error ensuring fields ${fieldsToTest} exist on object ${sobject} with error ${e}`);
return [];
}
}
/**
* Filters records in-memory based on pre-validated field rules.
* Field rule values are expected to already be present on the records
* (included in the original SOQL SELECT).
* Records without the field value (e.g. from SOSL path) are kept.
*
* @param records - The records to filter (must already contain field rule field values)
* @param validRules - Array of pre-validated rules (fields already confirmed to exist on the object)
* @returns Filtered records that pass all rules (AND logic)
*/
private applyFieldRules(records: ContactRecord[], validRules: RRSkipFieldRule[]): ContactRecord[] {
const log = logger.getSubLogger({ prefix: [`[applyFieldRules]`] });
if (!validRules.length || !records.length) {
return records;
}
log.info("Applying field rules", {
ruleCount: validRules.length,
recordCount: records.length,
});
return records.filter((record) => {
for (const rule of validRules) {
// Access dynamic field via bracket notation — value is on the record from SOQL
const rawRecord = record as unknown as Record<string, unknown>;
const actualValue = String(rawRecord[rule.field] ?? "").toLowerCase();
const ruleValue = rule.value.toLowerCase();
const matches = actualValue === ruleValue;
// If field value is missing (empty string from nullish coalescing), skip the rule.
// This handles records from SOSL path that don't have field rule fields.
if (rawRecord[rule.field] === undefined || rawRecord[rule.field] === null) {
log.info(`Record ${record.Id} missing field "${rule.field}", skipping rule`);
continue;
}
if (rule.action === RRSkipFieldRuleActionEnum.IGNORE && matches) {
log.info(`Record ${record.Id} filtered out by ignore rule`, {
field: rule.field,
value: rule.value,
});
return false;
}
if (rule.action === RRSkipFieldRuleActionEnum.MUST_INCLUDE && !matches) {
log.info(`Record ${record.Id} filtered out by must_include rule`, {
field: rule.field,
expectedValue: rule.value,
actualValue,
});
return false;
}
}
return true;
});
}
private async checkRecordOwnerNameFromRecordId(id: string, newOwnerId: string) {
const conn = await this.conn;
const appOptions = this.getAppOptions();
this.log.info(`Changing owner for record ${id}`);
const recordType = this.determineRecordTypeById(id);
if (!appOptions?.onBookingChangeRecordOwnerName) {
this.log.warn(
`No appOptions.onBookingChangeRecordOwnerName found for ${this.integrationName} on checkRecordOwnerNameFromRecordId`
);
return;
}
// Get the associated record that the event was created on
const recordQuery = (await conn.query(
`SELECT OwnerId, Owner.Name FROM ${recordType} WHERE Id = '${this.sanitizeSoqlValue(id)}'`
)) as { records: { OwnerId: string; Owner: { Name: string } }[] };
if (!recordQuery || !recordQuery.records.length) {
this.log.warn(`Could not find record for id ${id} and type ${recordType}`);
}
const owner = recordQuery.records[0].Owner;
if (!appOptions.onBookingChangeRecordOwnerName.includes(owner?.Name)) {
this.log.warn(
`Current owner name ${owner?.Name} for record ${id} does not match the option ${appOptions.onBookingChangeRecordOwnerName}`
);
}
await conn
.sobject(recordType)
.update({
// First field is there WHERE statement
Id: id,
OwnerId: newOwnerId,
})
.catch((error) => {
this.log.warn(
`Error changing record ${id} of type ${recordType} owner to ${newOwnerId} with error ${JSON.stringify(
error
)}`
);
});
}
public getAllPossibleAccountWebsiteFromEmailDomain(emailDomain: string) {
const websites = getAllPossibleWebsiteValuesFromEmailDomain(emailDomain);
// Format for SOQL query
return websites.map((website) => `'${this.sanitizeSoqlValue(website)}'`).join(", ");
}
private async getAccountIdBasedOnEmailDomainOfContacts(email: string) {
const conn = await this.conn;
const emailDomain = email.split("@")[1];
const log = logger.getSubLogger({ prefix: [`[getAccountIdBasedOnEmailDomainOfContacts]:${email}`] });
log.info("getAccountIdBasedOnEmailDomainOfContacts", safeStringify({ email, emailDomain }));
// First check if an account has the same website as the email domain of the attendee
const accountQuery = await conn.query(
`SELECT Id, Website FROM Account WHERE Website IN (${this.getAllPossibleAccountWebsiteFromEmailDomain(
emailDomain
)}) LIMIT 1`
);
if (accountQuery.records.length > 0) {
const account = accountQuery.records[0] as { Id: string; Website: string };
log.info(
"Found account based on email domain",
safeStringify({ emailDomain, accountWebsite: account.Website, accountId: account.Id })
);
return account.Id;
}
// Fallback to querying which account the majority of contacts are under
const response = await conn.query(
`SELECT Id, Email, AccountId FROM Contact WHERE Email LIKE '%@${this.sanitizeSoqlValue(emailDomain)}' AND AccountId != null`
);
const accountId = this.getDominantAccountId(response.records as { AccountId: string }[]);
if (accountId) {
log.info("Found account based on other contacts", safeStringify({ accountId }));
} else {
log.info("No account found");
}
return accountId;
}
private setFallbackToContact(boolean: boolean) {
this.fallbackToContact = boolean;
}
private getFallbackToContact() {
return this.fallbackToContact;
}
private async writeToRecord({
recordId,
startTime,
fieldsToWriteTo,
organizerEmail,
calEventResponses,
bookingUid,
}: {
recordId: string;
startTime: string;
fieldsToWriteTo: Record<string, z.infer<typeof writeToBookingEntry>>;
organizerEmail?: string;
calEventResponses?: CalEventResponses | null;
bookingUid?: string | null;
}) {
const conn = await this.conn;
// Determine record type (Contact, Lead, etc)
const personRecordType = this.determineRecordTypeById(recordId);
// Search the fields and ensure 1. they exist 2. they're the right type
const fieldsToWriteOn = Object.keys(fieldsToWriteTo);
const existingFields = await this.ensureFieldsExistOnObject(fieldsToWriteOn, personRecordType);
if (!existingFields.length) {
this.log.warn(`No fields found for record type ${personRecordType}`);
return;
}
const personRecord = await this.fetchPersonRecord(recordId, existingFields, personRecordType);
if (!personRecord) {
this.log.warn(`No personRecord found for contactId ${recordId}`);
return;
}
this.log.info(`Writing to recordId ${recordId} on fields ${fieldsToWriteOn}`);
const writeOnRecordBody = await this.buildRecordUpdatePayload({
existingFields,
personRecord,
fieldsToWriteTo,
startTime,
bookingUid,
organizerEmail,
calEventResponses,
recordId,
});
this.log.info(
`Final writeOnRecordBody contains fields ${Object.keys(writeOnRecordBody)} for record ${recordId}`
);
// Update the person record
await conn
.sobject(personRecordType)
.update({
Id: recordId,
...writeOnRecordBody,
})
.catch((e) => {
this.log.error(`Error updating person record for contactId ${recordId}`, e);
});
}
private async buildRecordUpdatePayload({
existingFields,
personRecord,
fieldsToWriteTo,
startTime,
bookingUid,
organizerEmail,
calEventResponses,
recordId,
}: {
existingFields: Field[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
personRecord: Record<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fieldsToWriteTo: Record<string, any>;
startTime?: string;
bookingUid?: string | null;
organizerEmail?: string;
calEventResponses?: CalEventResponses | null;
recordId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}): Promise<Record<string, any>> {
const log = logger.getSubLogger({ prefix: [`[buildRecordUpdatePayload] ${recordId}`] });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const writeOnRecordBody: Record<string, any> = {};
let fieldTypeHandled = false;
for (const field of existingFields) {
const fieldConfig = fieldsToWriteTo[field.name];
if (!fieldConfig) {
log.error(`No field config found for field ${field.name}`);
continue;
}
log.info(
`Processing field ${field.name} with type ${field.type} and config ${JSON.stringify(fieldConfig)}`
);
// Skip if field should only be written when empty and already has a value
if (fieldConfig.whenToWrite === WhenToWriteToRecord.FIELD_EMPTY && personRecord[field.name]) {
log.info(
`Field ${field.name} on contactId ${personRecord?.Id} already exists with value ${
personRecord[field.name]
}`
);
continue;
}
if (fieldConfig.fieldType === SalesforceFieldType.CUSTOM) {
fieldTypeHandled = true;
const extractedValue = await this.getTextFieldValue({
fieldValue: fieldConfig.value,
fieldLength: field.length,
calEventResponses,
bookingUid,
recordId,
fieldName: field.name,
});
if (extractedValue) {
writeOnRecordBody[field.name] = extractedValue;
continue;
}
}
// Handle different field types
if (
field.type === SalesforceFieldType.TEXT ||
field.type === SalesforceFieldType.TEXTAREA ||
field.type === SalesforceFieldType.PHONE
) {
fieldTypeHandled = true;
const extractedText = await this.getTextFieldValue({
fieldValue: fieldConfig.value,
fieldLength: field.length,
calEventResponses,
bookingUid,
recordId,
fieldName: field.name,
});
if (extractedText) {
writeOnRecordBody[field.name] = extractedText;
continue;
}
} else if (
(field.type === SalesforceFieldType.DATE || field.type === SalesforceFieldType.DATETIME) &&
startTime &&
organizerEmail
) {
fieldTypeHandled = true;
const dateValue = await this.getDateFieldValue(
fieldConfig.value,
startTime,
bookingUid,
organizerEmail
);
if (dateValue) {
writeOnRecordBody[field.name] = dateValue;
continue;
}
} else if (field.type === SalesforceFieldType.PICKLIST) {
fieldTypeHandled = true;
const picklistValue = await this.getPicklistFieldValue({
fieldConfigValue: fieldConfig.value,
salesforceField: field,
calEventResponses,
bookingUid,
recordId,
});
if (picklistValue) {
writeOnRecordBody[field.name] = picklistValue;
continue;
}
} else if (field.type === SalesforceFieldType.CHECKBOX) {
fieldTypeHandled = true;
// If the checkbox field value is not a boolean for some reason, default to if it's a falsely value
const checkboxValue = !!fieldConfig.value;
writeOnRecordBody[field.name] = checkboxValue;
continue;
}
if (!fieldTypeHandled) {
log.error(`Salesforce field type ${field.type} not handled for fieldConfig ${fieldConfig}`);
}
log.error(
`No value found for field ${field.name} with value ${
personRecord[field.name]
}, field config ${JSON.stringify(fieldConfig)} and Salesforce config ${JSON.stringify(field)}`
);
}
return writeOnRecordBody;
}
private async generateWriteToEventBody(event: CalendarEvent) {
const appOptions = this.getAppOptions();
const customFieldInputsEnabled =
appOptions?.onBookingWriteToEventObject && appOptions?.onBookingWriteToEventObjectMap;
if (!customFieldInputsEnabled) return {};
if (!appOptions?.onBookingWriteToEventObjectMap) return {};
const customFieldInputs = customFieldInputsEnabled
? await this.ensureFieldsExistOnObject(Object.keys(appOptions.onBookingWriteToEventObjectMap), "Event")
: [];
const confirmedCustomFieldInputs: {
// This is unique to each instance so we don't know the type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
} = {};
for (const field of customFieldInputs) {
confirmedCustomFieldInputs[field.name] = await this.getTextFieldValue({
fieldValue: appOptions.onBookingWriteToEventObjectMap[field.name],
fieldLength: field.length,
calEventResponses: event.responses,
bookingUid: event?.uid,
recordId: event?.uid ?? "Cal booking",
fieldName: field.name,
});
}
return confirmedCustomFieldInputs;
}
private async getTextFieldValue({
fieldValue,
fieldLength,
calEventResponses,
bookingUid,
recordId,
fieldName,
}: {
fieldValue: string;
fieldLength?: number;
calEventResponses?: CalEventResponses | null;
bookingUid?: string | null;
recordId: string;
fieldName: string;
}) {
const log = logger.getSubLogger({ prefix: [`[getTextFieldValue]: ${recordId} - ${fieldName}`] });
// If no {} then indicates we're passing a static value
if (!fieldValue.startsWith("{") && !fieldValue.endsWith("}")) {
log.info("Returning static value");
return fieldValue;
}
let valueToWrite = fieldValue;
if (fieldValue.startsWith("{utm:")) {
if (!bookingUid) {
log.error(`BookingUid not passed. Cannot get tracking values without it`);
return;
}
valueToWrite = await this.getTextValueFromBookingTracking(fieldValue, bookingUid);
} else if (fieldValue === "{assignmentReason}") {
if (!bookingUid) {
log.error(`BookingUid not passed. Cannot get assignment reason without it`);
return;
}
valueToWrite = await this.getAssignmentReason(bookingUid);
if (!valueToWrite) {
log.error(`No assignment reason found for bookingUid ${bookingUid}`);
return;
}
} else {
// Get the value from the booking response
if (!calEventResponses) {
log.error(`CalEventResponses not passed. Cannot get booking form responses`);
return;
}
valueToWrite = this.getTextValueFromBookingResponse(fieldValue, calEventResponses);
}
// If a value wasn't found in the responses. Don't return the field name
if (valueToWrite === fieldValue) {
log.error("No responses found returning nothing");
return;
}
// Trim incase the replacement values increased the length
return fieldLength ? valueToWrite.substring(0, fieldLength) : valueToWrite;
}
private async getTextValueFromBookingTracking(fieldValue: string, bookingUid: string) {
const log = logger.getSubLogger({
prefix: [`[getTextValueFromBookingTracking]: ${bookingUid}`],
});
const tracking = await prisma.tracking.findFirst({
where: {
booking: {
uid: bookingUid,
},
},
});
if (!tracking) {
log.warn(`No tracking found for bookingUid ${bookingUid}`);
return "";
}
// Remove the {utm: and trailing } from the field value
const utmParam = fieldValue.split(":")[1].slice(0, -1);
return tracking[`utm_${utmParam}` as keyof typeof tracking]?.toString() ?? "";
}
private async getAssignmentReason(bookingId: string) {
const assignmentReason = await PrismaAssignmentReasonRepository.findLatestReasonFromBookingUid(bookingId);
return assignmentReason?.reasonString ?? "";
}
private getTextValueFromBookingResponse(fieldValue: string, calEventResponses: CalEventResponses) {
const regexValueToReplace = /\{(.*?)\}/g;
return fieldValue.replace(regexValueToReplace, (match, captured) => {
return calEventResponses[captured]?.value ? calEventResponses[captured].value.toString() : match;
});
}
private async getDateFieldValue(
fieldValue: string,
startTime: string,
bookingUid?: string | null,
organizerEmail?: string
): Promise<string | null> {
if (fieldValue === DateFieldTypeData.BOOKING_START_DATE) {
return new Date(startTime).toISOString();
}
if (fieldValue === DateFieldTypeData.BOOKING_CREATED_DATE && bookingUid) {
if (!organizerEmail) {
this.log.warn(`No organizer email found for bookingUid ${bookingUid}`);
}
const booking = await prisma.booking.findUnique({
where: { uid: bookingUid },
select: { createdAt: true },
});
if (!booking) {
this.log.warn(`No booking found for ${bookingUid}`);
return null;
}
return new Date(booking.createdAt).toISOString();
}
if (!bookingUid) {
this.log.warn(`No uid for booking with organizer ${organizerEmail}`);
}
if (fieldValue === DateFieldTypeData.BOOKING_CANCEL_DATE) {
return new Date().toISOString();
}
return null;
}
private async getPicklistFieldValue({
fieldConfigValue,
salesforceField,
calEventResponses,
bookingUid,
recordId,
}: {
fieldConfigValue: string;
salesforceField: Field;
calEventResponses?: CalEventResponses | null;
bookingUid?: string | null;
recordId: string;
}) {
const log = logger.getSubLogger({ prefix: [`[getPicklistFieldValue] ${recordId}`] });
const picklistOptions = salesforceField.picklistValues;
if (!picklistOptions || !picklistOptions.length) {
log.warn(`No picklist values found for field ${salesforceField.name}`);
return null;
}
// Get the text value from the field
const fieldTextValue = await this.getTextFieldValue({
fieldValue: fieldConfigValue,
fieldLength: salesforceField.length,
calEventResponses,
bookingUid,
recordId,
fieldName: salesforceField.name,
});
if (!fieldTextValue) {
log.warn(`No text value found for field ${salesforceField.name}`);
return null;
}
// Get the picklist value from the field
const picklistValue = picklistOptions.find((option) => option.active && option.value === fieldTextValue);
if (!picklistValue) {
log.warn(`No picklist value found for field ${salesforceField.name} and value ${fieldTextValue}`);
return null;
}
return picklistValue.value;
}
private async fetchPersonRecord(
contactId: string,
existingFields: Field[],
personRecordType: SalesforceRecordEnum
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<Record<string, any> | null> {
const conn = await this.conn;
const existingFieldNames = existingFields.map((field) => field.name);
const query = await conn.query(
`SELECT Id, ${existingFieldNames.join(", ")} FROM ${personRecordType} WHERE Id = '${this.sanitizeSoqlValue(contactId)}'`
);
if (!query.records.length) {
this.log.warn(`Could not find person record with id ${contactId}`);
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return query.records[0] as Record<string, any>;
}
private async createNewContactUnderAnAccount({
attendee,
accountId,
organizerId,
}: {
attendee: Attendee;
accountId: string;
organizerId?: string;
}) {
const log = logger.getSubLogger({ prefix: [`[createNewContactUnderAnAccount]:${attendee.email}`] });
log.info("createNewContactUnderAnAccount", safeStringify({ attendee, accountId, organizerId }));
const conn = await this.conn;
// First see if the contact already exists and connect it to the account
const userQuery = await conn.query(
`SELECT Id, Email FROM Contact WHERE Email = '${this.sanitizeSoqlValue(attendee.email)}' LIMIT 1`
);
if (userQuery.records.length > 0) {
const contact = userQuery.records[0] as { Id: string; Email: string };
await conn.sobject(SalesforceRecordEnum.CONTACT).update({
// The first argument is the WHERE clause
Id: contact.Id,
AccountId: accountId,
});
return [{ id: contact.Id, email: contact.Email }];
}
return await this.createAttendeeRecord({
attendee,
recordType: SalesforceRecordEnum.CONTACT,
accountId,
organizerId,
});
}
async findUserEmailFromLookupField(
attendeeEmail: string,
fieldName: string,
salesforceObject: SalesforceRecordEnum
) {
const conn = await this.conn;
// Ensure the field exists on the record
const existingFields = await this.ensureFieldsExistOnObject([fieldName], salesforceObject);
if (!existingFields.length) return;
const lookupField = existingFields[0];
if (salesforceObject === SalesforceRecordEnum.ACCOUNT) {
const accountId = await this.getAccountIdBasedOnEmailDomainOfContacts(attendeeEmail);
if (!accountId) return;
const accountQuery = (await conn.query(
`SELECT ${lookupField.name} FROM ${SalesforceRecordEnum.ACCOUNT} WHERE Id = '${this.sanitizeSoqlValue(accountId)}'`
)) as {
// We do not know what fields are included in the account since it's unqiue to each instance
// eslint-disable-next-line @typescript-eslint/no-explicit-any
records: { [key: string]: any };
};
if (!accountQuery.records.length) return;
const lookupFieldUserId = accountQuery.records[0][lookupField.name];
if (!lookupFieldUserId) return;
const userQuery = await this.getSalesforceUserFromUserId(lookupFieldUserId);
if (!userQuery.records.length) return;
const user = userQuery.records[0] as { Email: string };
return { email: user.Email, recordType: RoutingReasons.ACCOUNT_LOOKUP_FIELD };
}
}
/** Search the booking questions for the Company field value rather than relying on the email domain */
private async getCompanyNameFromBookingResponse(calEventResponses?: CalEventResponses | null) {
const appOptions = this.getAppOptions();
const companyFieldName = "Company";
const defaultTextValueLength = 225;
const { onBookingWriteToRecordFields = undefined } = appOptions;
if (!onBookingWriteToRecordFields || !calEventResponses) return;
// Check that we're writing to the Company field
if (!(companyFieldName in onBookingWriteToRecordFields)) return;
const companyValue = await this.getTextFieldValue({
fieldValue: onBookingWriteToRecordFields[companyFieldName].value as string,
fieldLength: defaultTextValueLength,
calEventResponses,
recordId: "New lead",
fieldName: companyFieldName,
});
if (companyValue && companyValue === onBookingWriteToRecordFields[companyFieldName].value) return;
return companyValue;
}
private async shouldSkipAttendeeIfFreeEmailDomain(attendeeEmail: string) {
const appOptions = this.getAppOptions();
if (!appOptions.ifFreeEmailDomainSkipOwnerCheck) return false;
const response = await checkIfFreeEmailDomain({ email: attendeeEmail });
return response;
}
async incompleteBookingWriteToRecord(
email: string,
writeToRecordObject: z.infer<typeof writeToRecordDataSchema>
) {
const conn = await this.conn;
// Prioritize contacts over leads
const personRecord = await this.findProspectByEmail(email);
if (!personRecord) {
this.log.info(`No contact or lead found for email ${email}`);
// No salesforce entity to update, skip and report success (unrecoverable)
return;
}
const recordType = this.determineRecordTypeById(personRecord.Id);
// Ensure the fields exist on the record
const existingFields = await this.ensureFieldsExistOnObject(Object.keys(writeToRecordObject), recordType);
const writeOnRecordBody = await this.buildRecordUpdatePayload({
existingFields,
personRecord,
fieldsToWriteTo: writeToRecordObject,
recordId: personRecord.Id,
});
await conn
.sobject(recordType)
.update({
Id: personRecord.Id,
...writeOnRecordBody,
})
.catch((e) => {
const contactId = personRecord?.Id || "unknown";
// catch the error and throw a new one with a more descriptive message
const errorMessage = `Error updating person record for contactId '${contactId}': ${
e instanceof Error ? e.message : String(e)
}`;
throw new Error(errorMessage);
});
}
/** All salesforce ids have a 3 character prefix associated with the record type
* https://help.salesforce.com/s/articleView?id=000385203&type=1
*/
private determineRecordTypeById(id: string) {
switch (id.substring(0, 3)) {
case "003":
return SalesforceRecordEnum.CONTACT;
case "001":
return SalesforceRecordEnum.ACCOUNT;
case "00Q":
return SalesforceRecordEnum.LEAD;
case "00U":
return SalesforceRecordEnum.EVENT;
default:
this.log.warn(`Unhandled record id type ${id}`);
return SalesforceRecordEnum.CONTACT;
}
}
/** Prioritizes contacts over leads */
private async findProspectByEmail(email: string) {
const contact = await this.findContactByEmail(email);
if (contact) return contact;
const lead = await this.findLeadByEmail(email);
if (lead) return lead;
return null;
}
private async findContactByEmail(email: string) {
const conn = await this.conn;
const contactsQuery = await conn.query(
`SELECT Id, Email FROM ${SalesforceRecordEnum.CONTACT} WHERE Email = '${this.sanitizeSoqlValue(email)}' LIMIT 1`
);
if (contactsQuery.records.length > 0) {
return contactsQuery.records[0] as { Id: string; Email: string };
}
}
private async findLeadByEmail(email: string) {
const conn = await this.conn;
const leadsQuery = await conn.query(
`SELECT Id, Email FROM ${SalesforceRecordEnum.LEAD} WHERE Email = '${this.sanitizeSoqlValue(email)}' LIMIT 1`
);
if (leadsQuery.records.length > 0) {
return leadsQuery.records[0] as { Id: string; Email: string };
}
}
}
/**
* Factory function that creates a Salesforce CRM service instance.
* This is exported instead of the class to prevent SDK types (like jsforce.Connection)
* from leaking into the emitted .d.ts file, which would cause TypeScript to load
* all jsforce SDK declaration files when type-checking dependent packages.
*/
export default function BuildCrmService(
credential: CredentialPayload,
appOptions?: Record<string, unknown>
): CRM {
return new SalesforceCRMService(credential, (appOptions ?? {}) as z.infer<typeof appDataSchema>);
}
/**
* Factory function that creates a Salesforce CRM service instance with the extended SalesforceCRM type.
* This is used by internal Salesforce modules (routing forms, etc.) that need access to
* Salesforce-specific methods beyond the generic CRM interface.
*/
export function createSalesforceCrmServiceWithSalesforceType(
credential: CredentialPayload,
appOptions?: Record<string, unknown>,
testMode = false
): SalesforceCRM {
return new SalesforceCRMService(credential, (appOptions ?? {}) as z.infer<typeof appDataSchema>, testMode);
}