From 6c9a2201c86a33ad835237854afef287a800c291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADn=20Alcaraz?= Date: Tue, 17 Sep 2024 15:31:09 -0700 Subject: [PATCH] Add Opportunity2 to support SyncMode (#2266) --- .../salesforce/__tests__/opportunity2.test.ts | 488 ++++++++++++++++++ .../src/destinations/salesforce/index.ts | 4 +- .../opportunity2/generated-types.ts | 80 +++ .../salesforce/opportunity2/index.ts | 113 ++++ 4 files changed, 683 insertions(+), 2 deletions(-) create mode 100644 packages/destination-actions/src/destinations/salesforce/__tests__/opportunity2.test.ts create mode 100644 packages/destination-actions/src/destinations/salesforce/opportunity2/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/salesforce/opportunity2/index.ts diff --git a/packages/destination-actions/src/destinations/salesforce/__tests__/opportunity2.test.ts b/packages/destination-actions/src/destinations/salesforce/__tests__/opportunity2.test.ts new file mode 100644 index 0000000000..cbc087dd95 --- /dev/null +++ b/packages/destination-actions/src/destinations/salesforce/__tests__/opportunity2.test.ts @@ -0,0 +1,488 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../index' +import { API_VERSION } from '../sf-operations' + +const testDestination = createTestIntegration(Destination) + +const settings = { + instanceUrl: 'https://test.salesforce.com/' +} +const auth = { + refreshToken: 'xyz321', + accessToken: 'abc123' +} + +describe('Salesforce', () => { + describe('Opportunity', () => { + it('should create a opportunity record', async () => { + nock(`${settings.instanceUrl}services/data/${API_VERSION}/sobjects`).post('/Opportunity').reply(201, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Create Opportunity', + properties: { + close_date: '2022-02-18T22:26:24.997Z', + name: 'Opportunity Test Name', + stage_name: 'Opportunity stage name' + } + }) + + const responses = await testDestination.testAction('opportunity2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'add', + close_date: { + '@path': '$.properties.close_date' + }, + name: { + '@path': '$.properties.name' + }, + stage_name: { + '@path': '$.properties.stage_name' + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(201) + + expect(responses[0].request.headers).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer abc123", + ], + "content-type": Array [ + "application/json", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + } + `) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"CloseDate\\":\\"2022-02-18T22:26:24.997Z\\",\\"Name\\":\\"Opportunity Test Name\\",\\"StageName\\":\\"Opportunity stage name\\"}"` + ) + }) + + it('should create a opportunity record with custom fields', async () => { + nock(`${settings.instanceUrl}services/data/${API_VERSION}/sobjects`).post('/Opportunity').reply(201, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Create Opportunity w/ custom fields', + properties: { + close_date: '2022-02-18T22:26:24.997Z', + name: 'Opportunity Test Name', + stage_name: 'Opportunity stage name', + description: 'This is a test opportunity description' + } + }) + + const responses = await testDestination.testAction('opportunity2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'add', + close_date: { + '@path': '$.properties.close_date' + }, + name: { + '@path': '$.properties.name' + }, + stage_name: { + '@path': '$.properties.stage_name' + }, + customFields: { + A: '1', + B: '2', + C: '3' + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(201) + + expect(responses[0].request.headers).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer abc123", + ], + "content-type": Array [ + "application/json", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + } + `) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"CloseDate\\":\\"2022-02-18T22:26:24.997Z\\",\\"Name\\":\\"Opportunity Test Name\\",\\"StageName\\":\\"Opportunity stage name\\",\\"A\\":\\"1\\",\\"B\\":\\"2\\",\\"C\\":\\"3\\"}"` + ) + }) + + it('should delete an opportunity record given an Id', async () => { + nock(`${settings.instanceUrl}services/data/${API_VERSION}/sobjects`).delete('/Opportunity/123').reply(204, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Delete', + userId: '123' + }) + + const responses = await testDestination.testAction('opportunity2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'delete', + traits: { + Id: { '@path': '$.userId' } + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(204) + }) + + it('should delete an opportunity record given some lookup traits', async () => { + const query = encodeURIComponent(`SELECT Id FROM Opportunity WHERE Email = 'bob@bobsburgers.net'`) + nock(`${settings.instanceUrl}services/data/${API_VERSION}/query`) + .get(`/?q=${query}`) + .reply(201, { + totalSize: 1, + records: [{ Id: 'abc123' }] + }) + + nock(`${settings.instanceUrl}services/data/${API_VERSION}/sobjects`).delete('/Opportunity/abc123').reply(201, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Delete', + properties: { + email: 'bob@bobsburgers.net' + } + }) + + const responses = await testDestination.testAction('opportunity2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'delete', + traits: { + Email: { '@path': '$.properties.email' } + } + } + }) + + expect(responses.length).toBe(2) + expect(responses[0].status).toBe(201) + expect(responses[1].status).toBe(201) + }) + + it('should update a opportunity record', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Update Opportunity', + properties: { + close_date: '2022-02-18T22:26:24.997Z', + name: 'Opportunity Test Name updated', + stage_name: 'Opportunity stage name', + description: 'This is a test opportunity description' + } + }) + + const query = encodeURIComponent(`SELECT Id FROM Opportunity WHERE name = 'Opportunity Test Name OG'`) + nock(`${settings.instanceUrl}services/data/${API_VERSION}/query`) + .get(`/?q=${query}`) + .reply(201, { + Id: 'abc123', + totalSize: 1, + records: [{ Id: '123456' }] + }) + + nock(`${settings.instanceUrl}services/data/${API_VERSION}/sobjects`).patch('/Opportunity/123456').reply(201, {}) + + const responses = await testDestination.testAction('opportunity2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'update', + close_date: { + '@path': '$.properties.close_date' + }, + name: { + '@path': '$.properties.name' + }, + stage_name: { + '@path': '$.properties.stage_name' + }, + traits: { + name: 'Opportunity Test Name OG' + } + } + }) + + expect(responses.length).toBe(2) + expect(responses[0].status).toBe(201) + expect(responses[1].status).toBe(201) + + expect(responses[0].request.headers).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer abc123", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + } + `) + + expect(responses[1].options.body).toMatchInlineSnapshot( + `"{\\"CloseDate\\":\\"2022-02-18T22:26:24.997Z\\",\\"Name\\":\\"Opportunity Test Name updated\\",\\"StageName\\":\\"Opportunity stage name\\"}"` + ) + }) + + it('should upsert an existing opportunity record', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Upsert existing Opportunity', + properties: { + close_date: '2022-02-18T22:26:24.997Z', + name: 'Opportunity Test Name updated', + stage_name: 'Opportunity stage name', + description: 'This is a test opportunity description' + } + }) + + const query = encodeURIComponent(`SELECT Id FROM Opportunity WHERE name = 'Opportunity Test Name OG'`) + nock(`${settings.instanceUrl}services/data/${API_VERSION}/query`) + .get(`/?q=${query}`) + .reply(201, { + Id: 'abc123', + totalSize: 1, + records: [{ Id: '123456' }] + }) + + nock(`${settings.instanceUrl}services/data/${API_VERSION}/sobjects`).patch('/Opportunity/123456').reply(201, {}) + + const responses = await testDestination.testAction('opportunity2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'upsert', + close_date: { + '@path': '$.properties.close_date' + }, + name: { + '@path': '$.properties.name' + }, + stage_name: { + '@path': '$.properties.stage_name' + }, + traits: { + name: 'Opportunity Test Name OG' + } + } + }) + + expect(responses.length).toBe(2) + expect(responses[0].status).toBe(201) + expect(responses[1].status).toBe(201) + + expect(responses[0].request.headers).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer abc123", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + } + `) + + expect(responses[1].options.body).toMatchInlineSnapshot( + `"{\\"CloseDate\\":\\"2022-02-18T22:26:24.997Z\\",\\"Name\\":\\"Opportunity Test Name updated\\",\\"StageName\\":\\"Opportunity stage name\\"}"` + ) + }) + + it('should upsert a nonexistent opportunity record', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Upsert non-existent Opportunity', + properties: { + close_date: '2022-02-18T22:26:24.997Z', + name: 'Opportunity Test Name updated', + stage_name: 'Opportunity stage name', + description: 'This is a test opportunity description' + } + }) + + const query = encodeURIComponent(`SELECT Id FROM Opportunity WHERE name = 'Opportunity Test Name OG'`) + nock(`${settings.instanceUrl}services/data/${API_VERSION}/query`).get(`/?q=${query}`).reply(201, { + Id: 'abc123', + totalSize: 0 + }) + + nock(`${settings.instanceUrl}services/data/${API_VERSION}/sobjects`).post('/Opportunity').reply(201, {}) + + const responses = await testDestination.testAction('opportunity2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'upsert', + close_date: { + '@path': '$.properties.close_date' + }, + name: { + '@path': '$.properties.name' + }, + stage_name: { + '@path': '$.properties.stage_name' + }, + traits: { + name: 'Opportunity Test Name OG' + } + } + }) + + expect(responses.length).toBe(2) + expect(responses[0].status).toBe(201) + expect(responses[1].status).toBe(201) + + expect(responses[0].request.headers).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer abc123", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + } + `) + + expect(responses[1].options.body).toMatchInlineSnapshot( + `"{\\"CloseDate\\":\\"2022-02-18T22:26:24.997Z\\",\\"Name\\":\\"Opportunity Test Name updated\\",\\"StageName\\":\\"Opportunity stage name\\"}"` + ) + }) + + describe('batching', () => { + it('should fail if delete is set as syncMode', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Create Opportunity', + properties: { + close_date: '2022-02-18T22:26:24.997Z', + name: 'Opportunity Test Name', + stage_name: 'Opportunity stage name' + } + }) + + await expect(async () => { + await testDestination.testBatchAction('opportunity2', { + events: [event], + settings, + mapping: { + enable_batching: true, + __segment_internal_sync_mode: 'delete', + close_date: { + '@path': '$.properties.close_date' + }, + name: { + '@path': '$.properties.name' + }, + stage_name: { + '@path': '$.properties.stage_name' + } + }, + auth + }) + }).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unsupported operation: Bulk API does not support the delete operation"` + ) + }) + }) + + it('should fail if syncMode is undefined', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Create Opportunity', + properties: { + close_date: '2022-02-18T22:26:24.997Z', + name: 'Opportunity Test Name', + stage_name: 'Opportunity stage name' + } + }) + + await expect(async () => { + await testDestination.testBatchAction('opportunity2', { + events: [event], + settings, + mapping: { + enable_batching: true, + close_date: { + '@path': '$.properties.close_date' + }, + name: { + '@path': '$.properties.name' + }, + stage_name: { + '@path': '$.properties.stage_name' + } + }, + auth + }) + }).rejects.toThrowErrorMatchingInlineSnapshot(`"syncMode is required"`) + }) + + it('should fail if the operation does not have name field and sync mode is upsert', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Create Opportunity', + properties: {} + }) + + await expect(async () => { + await testDestination.testBatchAction('opportunity2', { + events: [event], + settings, + mapping: { + enable_batching: true, + __segment_internal_sync_mode: 'upsert', + close_date: { + '@path': '$.properties.close_date' + }, + name: { + '@path': '$.properties.name' + }, + stage_name: { + '@path': '$.properties.stage_name' + } + }, + auth + }) + }).rejects.toThrowErrorMatchingInlineSnapshot(`"Missing close_date, name or stage_name value"`) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/salesforce/index.ts b/packages/destination-actions/src/destinations/salesforce/index.ts index b8c242604c..dd62eb2a45 100644 --- a/packages/destination-actions/src/destinations/salesforce/index.ts +++ b/packages/destination-actions/src/destinations/salesforce/index.ts @@ -12,7 +12,7 @@ import { authenticateWithPassword } from './sf-operations' import lead2 from './lead2' import contact2 from './contact2' import cases2 from './cases2' - +import opportunity2 from './opportunity2' interface RefreshTokenResponse { access_token?: string @@ -125,9 +125,9 @@ const destination: DestinationDefinition = { opportunity, account, lead2, + opportunity2, contact2, cases2 - } } diff --git a/packages/destination-actions/src/destinations/salesforce/opportunity2/generated-types.ts b/packages/destination-actions/src/destinations/salesforce/opportunity2/generated-types.ts new file mode 100644 index 0000000000..c6cdb38cec --- /dev/null +++ b/packages/destination-actions/src/destinations/salesforce/opportunity2/generated-types.ts @@ -0,0 +1,80 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * This field affects how Segment uses the record matchers to query Salesforce records. By default, Segment uses the "OR" operator to query Salesforce for a record. If you would like to query Salesforce records using a combination of multiple record matchers, change this to "AND". + */ + recordMatcherOperator?: string + /** + * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 5000 before sending to Salesforce. + */ + enable_batching?: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number + /** + * The fields used to find Salesforce records for updates. **This is required if the operation is Delete, Update or Upsert.** + * + * Any field can function as a matcher, including Record ID, External IDs, standard fields and custom fields. On the left-hand side, input the Salesforce field API name. On the right-hand side, map the Segment field that contains the value. + * + * If multiple records are found, no changes will be made. **Please use fields that result in unique records.** + * + * --- + * + * + */ + traits?: { + [k: string]: unknown + } + /** + * The external id field name and mapping to use for bulk upsert. + */ + bulkUpsertExternalId?: { + /** + * The external id field name as defined in Salesforce. + */ + externalIdName?: string + /** + * The external id field value to use for bulk upsert. + */ + externalIdValue?: string + } + /** + * The record id value to use for bulk update. + */ + bulkUpdateRecordId?: string + /** + * Date when the opportunity is expected to close. Use yyyy-MM-dd format. **This is required to create an opportunity.** + */ + close_date?: string + /** + * A name for the opportunity. **This is required to create an opportunity.** + */ + name?: string + /** + * Current stage of the opportunity. **This is required to create an opportunity.** + */ + stage_name?: string + /** + * Estimated total sale amount. + */ + amount?: string + /** + * A text description of the opportunity. + */ + description?: string + /** + * + * Additional fields to send to Salesforce. On the left-hand side, input the Salesforce field API name. On the right-hand side, map the Segment field that contains the value. + * + * This can include standard or custom fields. Custom fields must be predefined in your Salesforce account and the API field name should have __c appended. + * + * --- + * + * + */ + customFields?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/salesforce/opportunity2/index.ts b/packages/destination-actions/src/destinations/salesforce/opportunity2/index.ts new file mode 100644 index 0000000000..fa6cafd7a3 --- /dev/null +++ b/packages/destination-actions/src/destinations/salesforce/opportunity2/index.ts @@ -0,0 +1,113 @@ +import { ActionDefinition, IntegrationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import Salesforce, { generateSalesforceRequest } from '../sf-operations' +import { + bulkUpsertExternalId2, + bulkUpdateRecordId2, + customFields2, + traits2, + validateLookup2, + enable_batching2, + recordMatcherOperator2, + batch_size2, + hideIfDeleteSyncMode +} from '../sf-properties' +import type { Payload } from './generated-types' + +const OBJECT_NAME = 'Opportunity' + +const action: ActionDefinition = { + title: 'Opportunity V2', + description: 'Create, update, or upsert opportunities in Salesforce.', + syncMode: { + description: 'Define how the records from your destination will be synced.', + label: 'How to sync records', + default: 'add', + choices: [ + { label: 'Insert Records', value: 'add' }, + { label: 'Update Records', value: 'update' }, + { label: 'Upsert Records', value: 'upsert' }, + { label: 'Delete Records. Not available when using batching. Requests will result in errors.', value: 'delete' } + ] + }, + fields: { + recordMatcherOperator: recordMatcherOperator2, + enable_batching: enable_batching2, + batch_size: batch_size2, + traits: traits2, + bulkUpsertExternalId: bulkUpsertExternalId2, + bulkUpdateRecordId: bulkUpdateRecordId2, + close_date: { + label: 'Close Date', + description: + 'Date when the opportunity is expected to close. Use yyyy-MM-dd format. **This is required to create an opportunity.**', + type: 'string', + depends_on: hideIfDeleteSyncMode + }, + name: { + label: 'Name', + description: 'A name for the opportunity. **This is required to create an opportunity.**', + type: 'string', + depends_on: hideIfDeleteSyncMode + }, + stage_name: { + label: 'Stage Name', + description: 'Current stage of the opportunity. **This is required to create an opportunity.**', + type: 'string', + depends_on: hideIfDeleteSyncMode + }, + amount: { + label: 'Amount', + description: 'Estimated total sale amount.', + type: 'string', + depends_on: hideIfDeleteSyncMode + }, + description: { + label: 'Description', + description: 'A text description of the opportunity.', + type: 'string', + depends_on: hideIfDeleteSyncMode + }, + customFields: customFields2 + }, + perform: async (request, { settings, payload, syncMode }) => { + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) + + if (syncMode === 'add') { + if (!payload.close_date || !payload.name || !payload.stage_name) { + throw new IntegrationError('Missing close_date, name or stage_name value', 'Misconfigured required field', 400) + } + return await sf.createRecord(payload, OBJECT_NAME) + } + + validateLookup2(syncMode, payload) + + if (syncMode === 'update') { + return await sf.updateRecord(payload, OBJECT_NAME) + } + + if (syncMode === 'upsert') { + if (!payload.close_date || !payload.name || !payload.stage_name) { + throw new IntegrationError('Missing close_date, name or stage_name value', 'Misconfigured required field', 400) + } + return await sf.upsertRecord(payload, OBJECT_NAME) + } + + if (syncMode === 'delete') { + return await sf.deleteRecord(payload, OBJECT_NAME) + } + }, + performBatch: async (request, { settings, payload, syncMode }) => { + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) + + if (syncMode === 'upsert') { + if (!payload[0].close_date || !payload[0].name || !payload[0].stage_name) { + throw new IntegrationError('Missing close_date, name or stage_name value', 'Misconfigured required field', 400) + } + } + + return sf.bulkHandlerWithSyncMode(payload, OBJECT_NAME, syncMode) + } +} + +export default action