diff --git a/packages/destination-actions/src/destinations/salesforce/__tests__/contact2.test.ts b/packages/destination-actions/src/destinations/salesforce/__tests__/contact2.test.ts new file mode 100644 index 0000000000..6ee775629d --- /dev/null +++ b/packages/destination-actions/src/destinations/salesforce/__tests__/contact2.test.ts @@ -0,0 +1,565 @@ +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('Contact', () => { + it('should create a contact record', async () => { + nock(`${settings.instanceUrl}services/data/${API_VERSION}/sobjects`).post('/Contact').reply(201, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Create Contact', + properties: { + email: 'sponge@seamail.com', + last_name: 'Squarepants' + } + }) + + const responses = await testDestination.testAction('contact2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'add', + email: { + '@path': '$.properties.email' + }, + company: { + '@path': '$.properties.company' + }, + last_name: { + '@path': '$.properties.last_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( + `"{\\"LastName\\":\\"Squarepants\\",\\"Email\\":\\"sponge@seamail.com\\"}"` + ) + }) + + it('should create a contact record with default mappings', async () => { + nock(`${settings.instanceUrl}services/data/${API_VERSION}/sobjects`).post('/Contact').reply(201, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Create Contact', + properties: { + email: 'sponge@seamail.com', + address: { + city: 'Bikini Bottom', + postal_code: '12345', + country: 'The Ocean', + street: 'Pineapple Ln', + state: 'Water' + }, + last_name: 'Bob', + first_name: 'Sponge' + } + }) + + const responses = await testDestination.testAction('contact2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'add' + }, + useDefaultMappings: true + }) + + 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( + `"{\\"LastName\\":\\"Bob\\",\\"FirstName\\":\\"Sponge\\",\\"Email\\":\\"sponge@seamail.com\\",\\"MailingState\\":\\"Water\\",\\"MailingStreet\\":\\"Pineapple Ln\\",\\"MailingCountry\\":\\"The Ocean\\",\\"MailingPostalCode\\":\\"12345\\",\\"MailingCity\\":\\"Bikini Bottom\\"}"` + ) + }) + + it('should create a contact record with custom fields', async () => { + nock(`${settings.instanceUrl}services/data/${API_VERSION}/sobjects`).post('/Contact').reply(201, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Create Contact', + properties: { + email: 'sponge@seamail.com', + last_name: 'Squarepants' + } + }) + + const responses = await testDestination.testAction('contact2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'add', + email: { + '@path': '$.properties.email' + }, + last_name: { + '@path': '$.properties.last_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( + `"{\\"LastName\\":\\"Squarepants\\",\\"Email\\":\\"sponge@seamail.com\\",\\"A\\":\\"1\\",\\"B\\":\\"2\\",\\"C\\":\\"3\\"}"` + ) + }) + + it('should delete a contact record given an Id', async () => { + nock(`${settings.instanceUrl}services/data/${API_VERSION}/sobjects`).delete('/Contact/123').reply(204, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Delete', + userId: '123' + }) + + const responses = await testDestination.testAction('contact2', { + 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 a contact record given some lookup traits', async () => { + const query = encodeURIComponent(`SELECT Id FROM Contact 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('/Contact/abc123').reply(201, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Delete', + properties: { + email: 'bob@bobsburgers.net' + } + }) + + const responses = await testDestination.testAction('contact2', { + 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 contact record', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Update Contact', + properties: { + last_name: 'Squarepants', + address: { + city: 'Bikini Bottom', + postal_code: '12345', + street: 'Pineapple St' + } + } + }) + + const query = encodeURIComponent(`SELECT Id FROM Contact WHERE email = 'sponge@seamail.com'`) + 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('/Contact/123456').reply(201, {}) + + const responses = await testDestination.testAction('contact2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'update', + traits: { + email: 'sponge@seamail.com' + }, + email: { + '@path': '$.properties.email' + }, + last_name: { + '@path': '$.properties.last_name' + }, + mailing_city: { + '@path': '$.properties.address.city' + }, + mailing_postal_code: { + '@path': '$.properties.address.postal_code' + }, + mailing_street: { + '@path': '$.properties.address.street' + } + } + }) + + 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( + `"{\\"LastName\\":\\"Squarepants\\",\\"MailingStreet\\":\\"Pineapple St\\",\\"MailingPostalCode\\":\\"12345\\",\\"MailingCity\\":\\"Bikini Bottom\\"}"` + ) + }) + + it('should upsert an existing contact record', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Upsert Contact', + properties: { + email: 'sponge@seamail.com', + last_name: 'Squarepants', + address: { + city: 'Bikini Bottom', + postal_code: '12345', + street: 'Pineapple St' + } + } + }) + + const query = encodeURIComponent(`SELECT Id FROM Contact WHERE email = 'spongebob@gmail.com'`) + 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('/Contact/123456').reply(201, {}) + + const responses = await testDestination.testAction('contact2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'upsert', + traits: { + email: 'spongebob@gmail.com' + }, + email: { + '@path': '$.properties.email' + }, + last_name: { + '@path': '$.properties.last_name' + }, + mailing_city: { + '@path': '$.properties.address.city' + }, + mailing_postal_code: { + '@path': '$.properties.address.postal_code' + }, + mailing_street: { + '@path': '$.properties.address.street' + } + } + }) + + 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( + `"{\\"LastName\\":\\"Squarepants\\",\\"Email\\":\\"sponge@seamail.com\\",\\"MailingStreet\\":\\"Pineapple St\\",\\"MailingPostalCode\\":\\"12345\\",\\"MailingCity\\":\\"Bikini Bottom\\"}"` + ) + }) + + it('should upsert a nonexistent record', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Upsert Contact', + properties: { + email: 'sponge@seamail.com', + last_name: 'Squarepants', + address: { + city: 'Bikini Bottom', + postal_code: '12345', + street: 'Pineapple St' + } + } + }) + + const query = encodeURIComponent(`SELECT Id FROM Contact WHERE email = 'plankton@gmail.com'`) + 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('/Contact').reply(201, {}) + + const responses = await testDestination.testAction('contact2', { + event, + settings, + auth, + mapping: { + __segment_internal_sync_mode: 'upsert', + traits: { + email: 'plankton@gmail.com' + }, + email: { + '@path': '$.properties.email' + }, + company: { + '@path': '$.properties.company' + }, + last_name: { + '@path': '$.properties.last_name' + }, + mailing_city: { + '@path': '$.properties.address.city' + }, + mailing_postal_code: { + '@path': '$.properties.address.postal_code' + }, + mailing_street: { + '@path': '$.properties.address.street' + } + } + }) + + 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( + `"{\\"LastName\\":\\"Squarepants\\",\\"Email\\":\\"sponge@seamail.com\\",\\"MailingStreet\\":\\"Pineapple St\\",\\"MailingPostalCode\\":\\"12345\\",\\"MailingCity\\":\\"Bikini Bottom\\"}"` + ) + }) + + describe('batching', () => { + it('should fail if delete is set as syncMode', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Create Contact', + properties: { + email: 'sponge@seamail.com', + last_name: 'Squarepants' + } + }) + + await expect(async () => { + await testDestination.testBatchAction('contact2', { + events: [event], + settings, + auth, + mapping: { + enable_batching: true, + __segment_internal_sync_mode: 'delete', + email: { + '@path': '$.properties.email' + }, + company: { + '@path': '$.properties.company' + }, + last_name: { + '@path': '$.properties.last_name' + } + } + }) + }).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 Contact', + properties: { + email: 'sponge@seamail.com', + last_name: 'Squarepants' + } + }) + + await expect(async () => { + await testDestination.testBatchAction('contact2', { + events: [event], + settings, + auth, + mapping: { + enable_batching: true, + email: { + '@path': '$.properties.email' + }, + company: { + '@path': '$.properties.company' + }, + last_name: { + '@path': '$.properties.last_name' + } + } + }) + }).rejects.toThrowErrorMatchingInlineSnapshot(`"syncMode is required"`) + }) + + it('should fail if sync mode is upsert and no last_name is provided', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Create Contact', + properties: { + email: 'sponge@seamail.com' + } + }) + + await expect(async () => { + await testDestination.testBatchAction('contact2', { + events: [event], + settings, + auth, + mapping: { + enable_batching: true, + __segment_internal_sync_mode: 'upsert', + email: { + '@path': '$.properties.email' + }, + company: { + '@path': '$.properties.company' + }, + last_name: { + '@path': '$.properties.last_name' + } + } + }) + }).rejects.toThrowErrorMatchingInlineSnapshot(`"Missing last_name value"`) + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/salesforce/contact2/generated-types.ts b/packages/destination-actions/src/destinations/salesforce/contact2/generated-types.ts new file mode 100644 index 0000000000..f826727416 --- /dev/null +++ b/packages/destination-actions/src/destinations/salesforce/contact2/generated-types.ts @@ -0,0 +1,96 @@ +// 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 + /** + * The contact's last name up to 80 characters. **This is required to create a contact.** + */ + last_name?: string + /** + * The contact's first name up to 40 characters. + */ + first_name?: string + /** + * The ID of the account that this contact is associated with. This is the Salesforce-generated ID assigned to the account during creation (i.e. 0018c00002CDThnAAH). + */ + account_id?: string + /** + * The contact's email address. + */ + email?: string + /** + * City for the contact's mailing address. + */ + mailing_city?: string + /** + * Postal Code for the contact's mailing address. + */ + mailing_postal_code?: string + /** + * Country for the contact's mailing address. + */ + mailing_country?: string + /** + * Street number and name for the contact's mailing address. + */ + mailing_street?: string + /** + * State for the contact's mailing address. + */ + mailing_state?: 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/contact2/index.ts b/packages/destination-actions/src/destinations/salesforce/contact2/index.ts new file mode 100644 index 0000000000..445d37ed14 --- /dev/null +++ b/packages/destination-actions/src/destinations/salesforce/contact2/index.ts @@ -0,0 +1,193 @@ +import { ActionDefinition, IntegrationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { + bulkUpsertExternalId2, + bulkUpdateRecordId2, + customFields2, + traits2, + validateLookup2, + enable_batching2, + recordMatcherOperator2, + batch_size2, + hideIfDeleteSyncMode +} from '../sf-properties' +import Salesforce, { generateSalesforceRequest } from '../sf-operations' + +const OBJECT_NAME = 'Contact' + +const action: ActionDefinition = { + title: 'Contact V2', + description: 'Create, update, or upsert contacts 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, + last_name: { + label: 'Last Name', + description: "The contact's last name up to 80 characters. **This is required to create a contact.**", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.last_name' }, + then: { '@path': '$.traits.last_name' }, + else: { '@path': '$.properties.last_name' } + } + }, + depends_on: hideIfDeleteSyncMode + }, + first_name: { + label: 'First Name', + description: "The contact's first name up to 40 characters.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.first_name' }, + then: { '@path': '$.traits.first_name' }, + else: { '@path': '$.properties.first_name' } + } + }, + depends_on: hideIfDeleteSyncMode + }, + account_id: { + label: 'Account ID', + description: + 'The ID of the account that this contact is associated with. This is the Salesforce-generated ID assigned to the account during creation (i.e. 0018c00002CDThnAAH).', + type: 'string', + depends_on: hideIfDeleteSyncMode + }, + email: { + label: 'Email', + description: "The contact's email address.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + depends_on: hideIfDeleteSyncMode + }, + mailing_city: { + label: 'Mailing City', + description: "City for the contact's mailing address.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.address.city' }, + then: { '@path': '$.traits.address.city' }, + else: { '@path': '$.properties.address.city' } + } + }, + depends_on: hideIfDeleteSyncMode + }, + mailing_postal_code: { + label: 'Mailing Postal Code', + description: "Postal Code for the contact's mailing address.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.address.postal_code' }, + then: { '@path': '$.traits.address.postal_code' }, + else: { '@path': '$.properties.address.postal_code' } + } + }, + depends_on: hideIfDeleteSyncMode + }, + mailing_country: { + label: 'Mailing Country', + description: "Country for the contact's mailing address.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.address.country' }, + then: { '@path': '$.traits.address.country' }, + else: { '@path': '$.properties.address.country' } + } + }, + depends_on: hideIfDeleteSyncMode + }, + mailing_street: { + label: 'Mailing Street', + description: "Street number and name for the contact's mailing address.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.address.street' }, + then: { '@path': '$.traits.address.street' }, + else: { '@path': '$.properties.address.street' } + } + }, + depends_on: hideIfDeleteSyncMode + }, + mailing_state: { + label: 'Mailing State', + description: "State for the contact's mailing address.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.address.state' }, + then: { '@path': '$.traits.address.state' }, + else: { '@path': '$.properties.address.state' } + } + }, + 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.last_name) { + throw new IntegrationError('Missing last_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.last_name) { + throw new IntegrationError('Missing last_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].last_name) { + throw new IntegrationError('Missing last_name value', 'Misconfigured required field', 400) + } + } + + return sf.bulkHandlerWithSyncMode(payload, OBJECT_NAME, syncMode) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/salesforce/index.ts b/packages/destination-actions/src/destinations/salesforce/index.ts index d3a3ad6863..b8c242604c 100644 --- a/packages/destination-actions/src/destinations/salesforce/index.ts +++ b/packages/destination-actions/src/destinations/salesforce/index.ts @@ -10,9 +10,10 @@ import account from './account' import { authenticateWithPassword } from './sf-operations' import lead2 from './lead2' - +import contact2 from './contact2' import cases2 from './cases2' + interface RefreshTokenResponse { access_token?: string error?: string @@ -124,7 +125,9 @@ const destination: DestinationDefinition = { opportunity, account, lead2, + contact2, cases2 + } }