refactor: getContacts (#18137)
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
|
||||
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
import SalesforceCRMService from "./CrmService";
|
||||
import { SalesforceRecordEnum } from "./enums";
|
||||
|
||||
describe("SalesforceCRMService", () => {
|
||||
let service: SalesforceCRMService;
|
||||
let mockConnection: { query: any };
|
||||
|
||||
setupAndTeardown();
|
||||
|
||||
beforeEach(() => {
|
||||
mockConnection = {
|
||||
query: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCredential: CredentialPayload = {
|
||||
id: 1,
|
||||
type: "salesforce_crm",
|
||||
key: {
|
||||
access_token: "mock_token",
|
||||
refresh_token: "mock_refresh",
|
||||
instance_url: "https://mock.salesforce.com",
|
||||
},
|
||||
userId: 1,
|
||||
appId: "salesforce",
|
||||
teamId: null,
|
||||
invalid: false,
|
||||
user: {
|
||||
email: "test-user@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
service = new SalesforceCRMService(mockCredential, {}, true);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
service.conn = Promise.resolve(mockConnection);
|
||||
});
|
||||
|
||||
describe("getContacts", () => {
|
||||
it("should return empty array when no records found", async () => {
|
||||
// Setup mock response
|
||||
mockConnection.query.mockResolvedValueOnce({ records: [] });
|
||||
|
||||
const result = await service.getContacts({ emails: "test@example.com" });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockConnection.query).toHaveBeenCalledWith(
|
||||
"SELECT Id, Email, OwnerId FROM Contact WHERE Email IN ('test@example.com')"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle single email string input", async () => {
|
||||
// Setup mock response with descriptive name
|
||||
const contactQueryResponse = {
|
||||
records: [
|
||||
{
|
||||
Id: "001",
|
||||
Email: "test@example.com",
|
||||
OwnerId: "owner001",
|
||||
attributes: { type: "Contact" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Setup query spy
|
||||
const querySpy = vi.spyOn(mockConnection, "query");
|
||||
querySpy.mockResolvedValueOnce(contactQueryResponse);
|
||||
|
||||
const result = await service.getContacts({ emails: "test@example.com" });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "001",
|
||||
email: "test@example.com",
|
||||
recordType: "Contact",
|
||||
},
|
||||
]);
|
||||
|
||||
// Verify the exact query made
|
||||
expect(querySpy).toHaveBeenCalledWith(
|
||||
"SELECT Id, Email, OwnerId FROM Contact WHERE Email IN ('test@example.com')"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle array of emails input", async () => {
|
||||
const contactsQueryResponse = {
|
||||
records: [
|
||||
{
|
||||
Id: "001",
|
||||
Email: "test1@example.com",
|
||||
OwnerId: "owner001",
|
||||
attributes: { type: "Contact" },
|
||||
},
|
||||
{
|
||||
Id: "002",
|
||||
Email: "test2@example.com",
|
||||
OwnerId: "owner002",
|
||||
attributes: { type: "Contact" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const querySpy = vi.spyOn(mockConnection, "query");
|
||||
querySpy.mockResolvedValueOnce(contactsQueryResponse);
|
||||
|
||||
const result = await service.getContacts({
|
||||
emails: ["test1@example.com", "test2@example.com"],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "001",
|
||||
email: "test1@example.com",
|
||||
recordType: "Contact",
|
||||
},
|
||||
{
|
||||
id: "002",
|
||||
email: "test2@example.com",
|
||||
recordType: "Contact",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(querySpy).toHaveBeenCalledWith(
|
||||
"SELECT Id, Email, OwnerId FROM Contact WHERE Email IN ('test1@example.com','test2@example.com')"
|
||||
);
|
||||
});
|
||||
|
||||
it("should include owner information when includeOwner is true", async () => {
|
||||
const contactQueryResponse = {
|
||||
records: [
|
||||
{
|
||||
Id: "001",
|
||||
Email: "test@example.com",
|
||||
OwnerId: "owner001",
|
||||
attributes: { type: "Contact" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ownerQueryResponse = {
|
||||
records: [
|
||||
{
|
||||
Id: "owner001",
|
||||
Email: "owner@example.com",
|
||||
Name: "Test Owner",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Setup query spy with specific responses
|
||||
const querySpy = vi.spyOn(mockConnection, "query");
|
||||
querySpy
|
||||
.mockResolvedValueOnce(contactQueryResponse) // Contact query
|
||||
.mockResolvedValueOnce(ownerQueryResponse); // Owner query
|
||||
|
||||
const result = await service.getContacts({
|
||||
emails: "test@example.com",
|
||||
includeOwner: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "001",
|
||||
email: "test@example.com",
|
||||
ownerId: "owner001",
|
||||
ownerEmail: "owner@example.com",
|
||||
recordType: "Contact",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(querySpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"SELECT Id, Email, OwnerId FROM Contact WHERE Email IN ('test@example.com')"
|
||||
);
|
||||
expect(querySpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining("SELECT Id, Email, Name FROM User WHERE Id = 'owner001'")
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle account record type with round robin skip", async () => {
|
||||
service = new SalesforceCRMService(
|
||||
{} as CredentialPayload,
|
||||
{
|
||||
createEventOn: SalesforceRecordEnum.ACCOUNT,
|
||||
roundRobinSkipCheckRecordOn: SalesforceRecordEnum.ACCOUNT,
|
||||
},
|
||||
true
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
service.conn = Promise.resolve(mockConnection);
|
||||
|
||||
const initialContactQueryResponse = {
|
||||
records: [
|
||||
{
|
||||
Id: "001",
|
||||
Email: "test@example.com",
|
||||
AccountId: "acc001",
|
||||
OwnerId: "owner001",
|
||||
attributes: { type: "Contact" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const accountQueryResponse = {
|
||||
records: [
|
||||
{
|
||||
Id: "acc001",
|
||||
OwnerId: "owner001",
|
||||
Email: "test@example.com",
|
||||
attributes: { type: "Account" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ownerQueryResponse = {
|
||||
records: [
|
||||
{
|
||||
Id: "owner001",
|
||||
Email: "owner@example.com",
|
||||
Name: "Test Owner",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const querySpy = vi.spyOn(mockConnection, "query");
|
||||
querySpy
|
||||
.mockResolvedValueOnce(initialContactQueryResponse)
|
||||
.mockResolvedValueOnce(accountQueryResponse)
|
||||
.mockResolvedValueOnce(ownerQueryResponse);
|
||||
|
||||
const result = await service.getContacts({
|
||||
emails: "test@example.com",
|
||||
forRoundRobinSkip: true,
|
||||
includeOwner: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "acc001",
|
||||
email: "test@example.com",
|
||||
ownerId: "owner001",
|
||||
ownerEmail: "owner@example.com",
|
||||
recordType: "Account",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(querySpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"SELECT Id, Email, OwnerId, AccountId FROM Contact WHERE Email = 'test@example.com' AND AccountId != null"
|
||||
);
|
||||
expect(querySpy).toHaveBeenNthCalledWith(2, "SELECT Id, OwnerId FROM Account WHERE Id = 'acc001'");
|
||||
expect(querySpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.stringContaining("SELECT Id, Email, Name FROM User WHERE Id = 'owner001'")
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { getLocation } from "@calcom/lib/CalEventParser";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { CalendarEvent, CalEventResponses } from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
@@ -62,16 +63,18 @@ const salesforceTokenSchema = z.object({
|
||||
|
||||
export default class SalesforceCRMService implements CRM {
|
||||
private integrationName = "";
|
||||
private conn: Promise<jsforce.Connection>;
|
||||
private conn!: Promise<jsforce.Connection>;
|
||||
private log: typeof logger;
|
||||
private calWarnings: string[] = [];
|
||||
private appOptions: any;
|
||||
private doNotCreateEvent = false;
|
||||
private fallbackToContact = false;
|
||||
|
||||
constructor(credential: CredentialPayload, appOptions: any) {
|
||||
constructor(credential: CredentialPayload, appOptions: any, testMode = false) {
|
||||
this.integrationName = "salesforce_other_calendar";
|
||||
this.conn = this.getClient(credential).then((c) => c);
|
||||
if (!testMode) {
|
||||
this.conn = this.getClient(credential).then((c) => c);
|
||||
}
|
||||
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
||||
this.appOptions = appOptions;
|
||||
}
|
||||
@@ -323,108 +326,196 @@ export default class SalesforceCRMService implements CRM {
|
||||
includeOwner?: boolean;
|
||||
forRoundRobinSkip?: boolean;
|
||||
}) {
|
||||
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;
|
||||
let accountOwnerId = "";
|
||||
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];
|
||||
soql = `SELECT Id, Email, OwnerId, AccountId FROM Contact WHERE Email = '${attendeeEmail}' AND AccountId != null`;
|
||||
const log = logger.getSubLogger({ prefix: [`[getContacts]:${emails}`] });
|
||||
try {
|
||||
const conn = await this.conn;
|
||||
const emailArray = Array.isArray(emails) ? emails : [emails];
|
||||
|
||||
// If this is for a round robin skip then we need to return the account record
|
||||
if (forRoundRobinSkip) {
|
||||
const results = await conn.query(soql);
|
||||
if (results.records.length) {
|
||||
const contact = results.records[0] as { AccountId: string };
|
||||
if (contact) {
|
||||
soql = `SELECT Id, OwnerId FROM Account WHERE Id = '${contact.AccountId}'`;
|
||||
}
|
||||
} else {
|
||||
// If we can't find the exact contact, then we need to search for an account where the contacts share the same email domain
|
||||
const accountId = await this.getAccountIdBasedOnEmailDomainOfContacts(attendeeEmail);
|
||||
if (accountId) {
|
||||
soql = `SELECT Id, OwnerId FROM Account WHERE Id = '${accountId}'`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If creating events on contacts or leads
|
||||
} else {
|
||||
soql = `SELECT Id, Email, OwnerId FROM ${recordToSearch} WHERE Email IN ('${emailArray.join("','")}')`;
|
||||
}
|
||||
const results = await conn.query(soql);
|
||||
const appOptions = this.getAppOptions();
|
||||
const recordToSearch =
|
||||
(forRoundRobinSkip ? appOptions?.roundRobinSkipCheckRecordOn : appOptions?.createEventOn) ??
|
||||
SalesforceRecordEnum.CONTACT;
|
||||
|
||||
// If we're checking against the contact, the ownerId should take precedence
|
||||
if (recordToSearch === SalesforceRecordEnum.ACCOUNT && results.records.length) {
|
||||
const account = results.records[0] as ContactRecord;
|
||||
accountOwnerId = account.OwnerId;
|
||||
}
|
||||
let soql: string;
|
||||
let accountOwnerId = "";
|
||||
|
||||
let records: ContactRecord[] = [];
|
||||
|
||||
// If falling back to contacts, check for the contact before returning the leads or empty array
|
||||
if (
|
||||
appOptions.createEventOn === SalesforceRecordEnum.LEAD &&
|
||||
appOptions.createEventOnLeadCheckForContact
|
||||
) {
|
||||
// Get any matching contacts
|
||||
const contactSearch = await conn.query(
|
||||
`SELECT Id, Email, OwnerId FROM ${SalesforceRecordEnum.CONTACT} WHERE Email IN ('${emailArray.join(
|
||||
"','"
|
||||
)}')`
|
||||
log.info(
|
||||
"Getting contacts for emails",
|
||||
safeStringify({
|
||||
emailArray,
|
||||
includeOwner,
|
||||
forRoundRobinSkip,
|
||||
recordToSearch,
|
||||
appOptions,
|
||||
})
|
||||
);
|
||||
|
||||
if (contactSearch && contactSearch.records.length > 0) {
|
||||
records = contactSearch.records as ContactRecord[];
|
||||
this.setFallbackToContact(true);
|
||||
// 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("Searching account for email", safeStringify({ attendeeEmail }));
|
||||
|
||||
soql = `SELECT Id, Email, OwnerId, AccountId FROM Contact WHERE Email = '${attendeeEmail}' AND AccountId != null`;
|
||||
|
||||
// If this is for a round robin skip then we need to return the account record
|
||||
if (forRoundRobinSkip) {
|
||||
const results = await conn.query(soql);
|
||||
log.info("Account contact search results", safeStringify({ resultCount: results.records.length }));
|
||||
|
||||
if (results.records?.length) {
|
||||
const contact = results.records[0] as { AccountId?: string };
|
||||
if (contact?.AccountId) {
|
||||
soql = `SELECT Id, OwnerId FROM Account WHERE Id = '${contact.AccountId}'`;
|
||||
}
|
||||
} else {
|
||||
// If we can't find the exact contact, then we need to search for an account where the contacts share the same email domain
|
||||
const accountId = await this.getAccountIdBasedOnEmailDomainOfContacts(attendeeEmail);
|
||||
if (accountId) {
|
||||
soql = `SELECT Id, OwnerId FROM Account WHERE Id = '${accountId}'`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If creating events on contacts or leads
|
||||
} else {
|
||||
// Handle Contact/Lead record types
|
||||
soql = `SELECT Id, Email, OwnerId FROM ${recordToSearch} WHERE Email IN ('${emailArray.join(
|
||||
"','"
|
||||
)}')`;
|
||||
}
|
||||
} else if (!results || !results.records.length) {
|
||||
|
||||
const results = await conn.query(soql);
|
||||
log.info("Query results", safeStringify({ recordCount: results.records?.length }));
|
||||
|
||||
// If we're checking against the contact, the ownerId should take precedence
|
||||
if (recordToSearch === SalesforceRecordEnum.ACCOUNT && results.records?.length) {
|
||||
const account = results.records[0] as ContactRecord;
|
||||
if (account?.OwnerId) {
|
||||
accountOwnerId = account.OwnerId;
|
||||
}
|
||||
}
|
||||
|
||||
let records: ContactRecord[] = [];
|
||||
|
||||
// If falling back to contacts, check for the contact before returning the leads or empty array
|
||||
if (
|
||||
appOptions.createEventOn === SalesforceRecordEnum.LEAD &&
|
||||
appOptions.createEventOnLeadCheckForContact
|
||||
) {
|
||||
// Get any matching contacts
|
||||
const contactSearch = await conn.query(
|
||||
`SELECT Id, Email, OwnerId FROM ${SalesforceRecordEnum.CONTACT} WHERE Email IN ('${emailArray.join(
|
||||
"','"
|
||||
)}')`
|
||||
);
|
||||
|
||||
if (contactSearch?.records?.length > 0) {
|
||||
records = contactSearch.records as ContactRecord[];
|
||||
this.setFallbackToContact(true);
|
||||
log.info(
|
||||
"Found matching contacts, falling back to contact",
|
||||
safeStringify({
|
||||
contactCount: records.length,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!records.length && results?.records?.length) {
|
||||
records = results.records as ContactRecord[];
|
||||
}
|
||||
|
||||
if (!records.length) {
|
||||
log.info("No records found");
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle owner information
|
||||
if (includeOwner || forRoundRobinSkip) {
|
||||
const ownerIds: Set<string> = new Set();
|
||||
|
||||
if (accountOwnerId) {
|
||||
ownerIds.add(accountOwnerId);
|
||||
} else {
|
||||
records.forEach((record) => {
|
||||
if (record?.OwnerId) {
|
||||
ownerIds.add(record.OwnerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (ownerIds.size === 0) {
|
||||
log.warn("No owner IDs found for records");
|
||||
return [];
|
||||
}
|
||||
|
||||
const ownersQuery = await Promise.all(
|
||||
Array.from(ownerIds).map(async (ownerId) => {
|
||||
const result = await this.getSalesforceUserFromUserId(ownerId);
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out any undefined results and ensure records exist
|
||||
const validOwnersQuery = ownersQuery.filter((query): query is jsforce.QueryResult<ContactRecord> => {
|
||||
const firstRecord = query?.records?.[0];
|
||||
if (!firstRecord) return false;
|
||||
|
||||
return (
|
||||
typeof firstRecord === "object" && "Email" in firstRecord && typeof firstRecord.Email === "string"
|
||||
);
|
||||
});
|
||||
|
||||
if (validOwnersQuery.length === 0) {
|
||||
log.warn("No valid owner records found");
|
||||
return [];
|
||||
}
|
||||
|
||||
return records
|
||||
.map((record) => {
|
||||
if (!record?.Id || !record?.OwnerId) {
|
||||
log.warn("Invalid record data", { record });
|
||||
return null;
|
||||
}
|
||||
|
||||
const ownerEmail = accountOwnerId
|
||||
? validOwnersQuery[0]?.records[0]?.Email
|
||||
: validOwnersQuery.find((user) => user.records[0]?.Id === record.OwnerId)?.records[0]?.Email;
|
||||
|
||||
if (!ownerEmail) {
|
||||
log.warn("Could not find owner email", { recordId: record.Id, ownerId: record.OwnerId });
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.Id,
|
||||
email: record.Email,
|
||||
ownerId: record.OwnerId,
|
||||
ownerEmail,
|
||||
recordType: accountOwnerId ? SalesforceRecordEnum.ACCOUNT : record.attributes?.type,
|
||||
};
|
||||
})
|
||||
.filter((record): record is NonNullable<typeof record> => record !== null);
|
||||
}
|
||||
|
||||
return records
|
||||
.map((record) => {
|
||||
if (!record?.Id || !record?.Email || !record?.attributes?.type) {
|
||||
log.warn("Invalid record data for basic mapping", { record });
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.Id,
|
||||
email: record.Email,
|
||||
recordType: record.attributes.type,
|
||||
};
|
||||
})
|
||||
.filter((record): record is NonNullable<typeof record> => record !== null);
|
||||
} catch (error) {
|
||||
log.error("Error in getContacts", safeStringify({ error }));
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!records.length) records = results.records as ContactRecord[];
|
||||
|
||||
if (includeOwner || forRoundRobinSkip) {
|
||||
const ownerIds: Set<string> = new Set();
|
||||
if (accountOwnerId) {
|
||||
ownerIds.add(accountOwnerId);
|
||||
} else {
|
||||
records.forEach((record) => {
|
||||
ownerIds.add(record.OwnerId);
|
||||
});
|
||||
}
|
||||
|
||||
const ownersQuery = (await Promise.all(
|
||||
Array.from(ownerIds).map(async (ownerId) => {
|
||||
return await this.getSalesforceUserFromUserId(ownerId);
|
||||
})
|
||||
)) as { records: ContactRecord[] }[];
|
||||
const contactsWithOwners = records.map((record) => {
|
||||
const ownerEmail = accountOwnerId
|
||||
? ownersQuery[0].records[0].Email
|
||||
: ownersQuery.find((user) => user.records[0]?.Id === record.OwnerId)?.records[0].Email;
|
||||
return {
|
||||
id: record.Id,
|
||||
email: record.Email,
|
||||
ownerId: record.OwnerId,
|
||||
ownerEmail,
|
||||
recordType: accountOwnerId ? SalesforceRecordEnum.ACCOUNT : record.attributes.type,
|
||||
};
|
||||
});
|
||||
return contactsWithOwners;
|
||||
}
|
||||
|
||||
return records
|
||||
? records.map((record) => ({
|
||||
id: record.Id,
|
||||
email: record.Email,
|
||||
recordType: record.attributes.type,
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
async createContacts(
|
||||
|
||||
Reference in New Issue
Block a user