From 423b17a819d44d5c863d37580c80aff91e68c0fc Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Mon, 16 Dec 2024 20:47:59 +0530 Subject: [PATCH] refactor: getContacts (#18137) --- .../salesforce/lib/CrmService.test.ts | 265 ++++++++++++++++ .../app-store/salesforce/lib/CrmService.ts | 285 ++++++++++++------ 2 files changed, 453 insertions(+), 97 deletions(-) create mode 100644 packages/app-store/salesforce/lib/CrmService.test.ts diff --git a/packages/app-store/salesforce/lib/CrmService.test.ts b/packages/app-store/salesforce/lib/CrmService.test.ts new file mode 100644 index 0000000000..e47ca4df23 --- /dev/null +++ b/packages/app-store/salesforce/lib/CrmService.test.ts @@ -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'") + ); + }); + }); +}); diff --git a/packages/app-store/salesforce/lib/CrmService.ts b/packages/app-store/salesforce/lib/CrmService.ts index ca61cd712f..408cb7a832 100644 --- a/packages/app-store/salesforce/lib/CrmService.ts +++ b/packages/app-store/salesforce/lib/CrmService.ts @@ -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; + private conn!: Promise; 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 = 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 => { + 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 => 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 => 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 = 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(