diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..4a48abe88e --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-blackbaud-raisers-edge-nxt destination: createOrUpdateIndividualConstituent action - all fields 1`] = `""`; + +exports[`Testing snapshot for actions-blackbaud-raisers-edge-nxt destination: createOrUpdateIndividualConstituent action - required fields 1`] = `""`; + +exports[`Testing snapshot for actions-blackbaud-raisers-edge-nxt destination: createOrUpdateIndividualConstituent action - required fields 2`] = ` +Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer undefined", + ], + "bb-api-subscription-key": Array [ + "hUXnkT]ixm7mm*HX", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, +} +`; diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/__tests__/index.test.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/__tests__/index.test.ts new file mode 100644 index 0000000000..8aab190af5 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/__tests__/index.test.ts @@ -0,0 +1,21 @@ +import nock from 'nock' +// import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestIntegration } from '@segment/actions-core' +import { SKY_API_BASE_URL } from '../constants' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe("Blackbaud Raiser's Edge NXT", () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock(SKY_API_BASE_URL).get('/emailaddresstypes').reply(200, {}) + + const settings = { + bbApiSubscriptionKey: 'subscription_key' + } + + await expect(testDestination.testAuthentication(settings)).resolves.not.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..6d3d45a07f --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/__tests__/snapshot.test.ts @@ -0,0 +1,81 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-blackbaud-raisers-edge-nxt' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + eventData.last = 'Smith' + + nock(/.*/).persist().get(/.*/).reply(200, {}) + nock(/.*/).persist().patch(/.*/).reply(200, {}) + nock(/.*/).persist().post(/.*/).reply(200, {}) + nock(/.*/).persist().put(/.*/).reply(200, {}) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200, {}) + nock(/.*/).persist().patch(/.*/).reply(200, {}) + nock(/.*/).persist().post(/.*/).reply(200, {}) + nock(/.*/).persist().put(/.*/).reply(200, {}) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/api/index.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/api/index.ts new file mode 100644 index 0000000000..30be7c5ea1 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/api/index.ts @@ -0,0 +1,156 @@ +import type { RequestClient, ModifiedResponse } from '@segment/actions-core' +import { SKY_API_BASE_URL } from '../constants' + +export class BlackbaudSkyApi { + request: RequestClient + + constructor(request: RequestClient) { + this.request = request + } + + async getExistingConstituents(searchField: string, searchText: string): Promise { + return this.request( + `${SKY_API_BASE_URL}/constituents/search?search_field=${searchField}&search_text=${searchText}`, + { + method: 'get' + } + ) + } + + async createConstituent(constituentData: object): Promise { + return this.request(`${SKY_API_BASE_URL}/constituents`, { + method: 'post', + json: constituentData + }) + } + + async updateConstituent(constituentId: string, constituentData: object): Promise { + return this.request(`${SKY_API_BASE_URL}/constituents/${constituentId}`, { + method: 'patch', + json: constituentData, + throwHttpErrors: false + }) + } + + async getConstituentAddressList(constituentId: string): Promise { + return this.request(`${SKY_API_BASE_URL}/constituents/${constituentId}/addresses?include_inactive=true`, { + method: 'get', + throwHttpErrors: false + }) + } + + async createConstituentAddress(constituentId: string, constituentAddressData: object): Promise { + return this.request(`${SKY_API_BASE_URL}/addresses`, { + method: 'post', + json: { + ...constituentAddressData, + constituent_id: constituentId + }, + throwHttpErrors: false + }) + } + + async updateConstituentAddressById(addressId: string, constituentAddressData: object): Promise { + return this.request(`${SKY_API_BASE_URL}/addresses/${addressId}`, { + method: 'patch', + json: { + ...constituentAddressData, + inactive: false + }, + throwHttpErrors: false + }) + } + + async getConstituentEmailList(constituentId: string): Promise { + return this.request(`${SKY_API_BASE_URL}/constituents/${constituentId}/emailaddresses?include_inactive=true`, { + method: 'get', + throwHttpErrors: false + }) + } + + async createConstituentEmail(constituentId: string, constituentEmailData: object): Promise { + return this.request(`${SKY_API_BASE_URL}/emailaddresses`, { + method: 'post', + json: { + ...constituentEmailData, + constituent_id: constituentId + }, + throwHttpErrors: false + }) + } + + async updateConstituentEmailById(emailId: string, constituentEmailData: object): Promise { + return this.request(`${SKY_API_BASE_URL}/emailaddresses/${emailId}`, { + method: 'patch', + json: { + ...constituentEmailData, + inactive: false + }, + throwHttpErrors: false + }) + } + + async getConstituentOnlinePresenceList(constituentId: string): Promise { + return this.request(`${SKY_API_BASE_URL}/constituents/${constituentId}/onlinepresences?include_inactive=true`, { + method: 'get', + throwHttpErrors: false + }) + } + + async createConstituentOnlinePresence( + constituentId: string, + constituentOnlinePresenceData: object + ): Promise { + return this.request(`${SKY_API_BASE_URL}/onlinepresences`, { + method: 'post', + json: { + ...constituentOnlinePresenceData, + constituent_id: constituentId + }, + throwHttpErrors: false + }) + } + + async updateConstituentOnlinePresenceById( + onlinePresenceId: string, + constituentOnlinePresenceData: object + ): Promise { + return this.request(`${SKY_API_BASE_URL}/onlinepresences/${onlinePresenceId}`, { + method: 'patch', + json: { + ...constituentOnlinePresenceData, + inactive: false + }, + throwHttpErrors: false + }) + } + + async getConstituentPhoneList(constituentId: string): Promise { + return this.request(`${SKY_API_BASE_URL}/constituents/${constituentId}/phones?include_inactive=true`, { + method: 'get', + throwHttpErrors: false + }) + } + + async createConstituentPhone(constituentId: string, constituentPhoneData: object): Promise { + return this.request(`${SKY_API_BASE_URL}/phones`, { + method: 'post', + json: { + ...constituentPhoneData, + constituent_id: constituentId + }, + throwHttpErrors: false + }) + } + + async updateConstituentPhoneById(phoneId: string, constituentPhoneData: object): Promise { + return this.request(`${SKY_API_BASE_URL}/phones/${phoneId}`, { + method: 'patch', + json: { + ...constituentPhoneData, + inactive: false + }, + throwHttpErrors: false + }) + } +} diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/constants/index.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/constants/index.ts new file mode 100644 index 0000000000..2edb2bac05 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/constants/index.ts @@ -0,0 +1,2 @@ +export const SKY_API_BASE_URL = 'https://api.sky.blackbaud.com/constituent/v1' +export const SKY_OAUTH2_TOKEN_URL = 'https://oauth2.sky.blackbaud.com/token' diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..67d00bfa49 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for BlackbaudRaisersEdgeNxt's createOrUpdateIndividualConstituent destination action: all fields 1`] = `""`; + +exports[`Testing snapshot for BlackbaudRaisersEdgeNxt's createOrUpdateIndividualConstituent destination action: required fields 1`] = `""`; + +exports[`Testing snapshot for BlackbaudRaisersEdgeNxt's createOrUpdateIndividualConstituent destination action: required fields 2`] = ` +Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer undefined", + ], + "bb-api-subscription-key": Array [ + "H*Z39ROa", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, +} +`; diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/index.test.ts new file mode 100644 index 0000000000..036aa2cc5e --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/index.test.ts @@ -0,0 +1,601 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, IntegrationError, RetryableError } from '@segment/actions-core' +import Destination from '../../index' +import { SKY_API_BASE_URL } from '../../constants' +import { + identifyEventData, + identifyEventDataNoEmail, + identifyEventDataNoLastName, + identifyEventDataWithInvalidWebsite, + identifyEventDataWithLookupId, + identifyEventDataUpdated +} from '../fixtures' + +const testDestination = createTestIntegration(Destination) + +const mapping = { + address: { + address_lines: { + '@path': '$.traits.address.street' + }, + city: { + '@path': '$.traits.address.city' + }, + country: { + '@path': '$.traits.address.country' + }, + postal_code: { + '@path': '$.traits.address.postalCode' + }, + state: { + '@path': '$.traits.address.state' + }, + type: { + '@path': '$.traits.addressType' + } + }, + email: { + address: { + '@path': '$.traits.email' + }, + type: { + '@path': '$.traits.emailType' + } + }, + lookup_id: { + '@path': '$.traits.lookup_id' + }, + online_presence: { + address: { + '@path': '$.traits.website' + }, + type: { + '@path': '$.traits.websiteType' + } + }, + phone: { + number: { + '@path': '$.traits.phone' + }, + type: { + '@path': '$.traits.phoneType' + } + } +} + +describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { + test('should create a new constituent successfully', async () => { + const event = createTestEvent(identifyEventData) + + nock(SKY_API_BASE_URL) + .get('/constituents/search?search_field=email_address&search_text=john@example.biz') + .reply(200, { + count: 0, + value: [] + }) + + nock(SKY_API_BASE_URL).post('/constituents').reply(200, { + id: '123' + }) + + await expect( + testDestination.testAction('createOrUpdateIndividualConstituent', { + event, + mapping, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) + + test('should create a new constituent without email or lookup_id successfully', async () => { + const event = createTestEvent(identifyEventDataNoEmail) + + nock(SKY_API_BASE_URL).post('/constituents').reply(200, { + id: '456' + }) + + await expect( + testDestination.testAction('createOrUpdateIndividualConstituent', { + event, + mapping, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) + + test('should update an existing constituent matched by email successfully', async () => { + const event = createTestEvent(identifyEventDataUpdated) + + nock(SKY_API_BASE_URL) + .get('/constituents/search?search_field=email_address&search_text=john@example.biz') + .reply(200, { + count: 1, + value: [ + { + id: '123', + address: 'PO Box 963\r\nNew York City, NY 10108', + email: 'john@example.biz', + fundraiser_status: 'None', + name: 'John Doe' + } + ] + }) + + nock(SKY_API_BASE_URL).patch('/constituents/123').reply(200) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/addresses?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '1000', + address_lines: 'PO Box 963', + city: 'New York City', + constituent_id: '123', + date_added: '2023-01-01T01:01:01.000-05:00', + date_modified: '2023-01-01T01:01:01.000-05:00', + do_not_mail: false, + formatted_address: 'PO Box 963\r\nNew York City, NY 10108', + inactive: false, + postal_code: '10108', + preferred: true, + state: 'NY', + type: 'Home' + } + ] + }) + + nock(SKY_API_BASE_URL).post('/addresses').reply(200, { + id: '1001' + }) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/emailaddresses?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '2000', + address: 'john@example.biz', + constituent_id: '123', + date_added: '2023-01-01T01:01:01.000-05:00', + date_modified: '2023-01-01T01:01:01.000-05:00', + do_not_email: false, + inactive: false, + primary: true, + type: 'Home' + } + ] + }) + + nock(SKY_API_BASE_URL).patch('/emailaddresses/2000').reply(200) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/onlinepresences?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '3000', + address: 'https://www.facebook.com/john.doe', + constituent_id: '123', + inactive: false, + primary: true, + type: 'Facebook' + } + ] + }) + + nock(SKY_API_BASE_URL).post('/onlinepresences').reply(200, { + id: '3001' + }) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/phones?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '4000', + constituent_id: '123', + do_not_call: false, + inactive: false, + number: '+18774466722', + primary: true, + type: 'Home' + } + ] + }) + + nock(SKY_API_BASE_URL).post('/phones').reply(200, { + id: '4001' + }) + + await expect( + testDestination.testAction('createOrUpdateIndividualConstituent', { + event, + mapping, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) + + test('should update an existing constituent matched by lookup_id successfully', async () => { + const event = createTestEvent(identifyEventDataWithLookupId) + + nock(SKY_API_BASE_URL) + .get('/constituents/search?search_field=lookup_id&search_text=abcd1234') + .reply(200, { + count: 1, + value: [ + { + id: '123', + address: '11 Wall St\r\nNew York, NY 1005', + email: 'john@example.biz', + fundraiser_status: 'None', + name: 'John Doe' + } + ] + }) + + nock(SKY_API_BASE_URL).patch('/constituents/123').reply(200) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/addresses?include_inactive=true') + .reply(200, { + count: 2, + value: [ + { + id: '1000', + address_lines: 'PO Box 963', + city: 'New York City', + constituent_id: '123', + date_added: '2023-01-01T01:01:01.000-05:00', + date_modified: '2023-01-01T01:01:01.000-05:00', + do_not_mail: false, + formatted_address: 'PO Box 963\r\nNew York City, NY 10108', + inactive: false, + postal_code: '10108', + preferred: true, + state: 'NY', + type: 'Home' + }, + { + id: '1001', + address_lines: '11 Wall St', + city: 'New York', + constituent_id: '123', + date_added: '2023-01-02T01:01:01.000-05:00', + date_modified: '2023-01-02T01:01:01.000-05:00', + do_not_mail: false, + formatted_address: '11 Wall Street\r\nNew York, NY 10005', + inactive: false, + postal_code: '10005', + preferred: true, + state: 'NY', + type: 'Work' + } + ] + }) + + nock(SKY_API_BASE_URL).post('/addresses').reply(200, { + id: '1002' + }) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/emailaddresses?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '2000', + address: 'john@example.biz', + constituent_id: '123', + date_added: '2023-01-01T01:01:01.000-05:00', + date_modified: '2023-01-01T01:01:01.000-05:00', + do_not_email: false, + inactive: false, + primary: true, + type: 'Work' + } + ] + }) + + nock(SKY_API_BASE_URL).post('/emailaddresses').reply(200, { + id: '2001' + }) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/onlinepresences?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '3001', + address: 'https://www.example.biz', + constituent_id: '123', + inactive: false, + primary: true, + type: 'Website' + } + ] + }) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/phones?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '4001', + constituent_id: '123', + do_not_call: false, + inactive: false, + number: '+18774466723', + primary: true, + type: 'Work' + } + ] + }) + + await expect( + testDestination.testAction('createOrUpdateIndividualConstituent', { + event, + mapping, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) + + test('should throw an IntegrationError if multiple records matched', async () => { + const event = createTestEvent(identifyEventData) + + nock(SKY_API_BASE_URL) + .get('/constituents/search?search_field=email_address&search_text=john@example.biz') + .reply(200, { + count: 2, + value: [ + { + id: '123', + address: '11 Wall Street\r\nNew York, NY 10005', + email: 'john@example.biz', + fundraiser_status: 'None', + name: 'John Doe' + }, + { + id: '1234', + address: '100 Main St\r\nLos Angeles, CA 90210', + email: 'john@example.biz', + fundraiser_status: 'None', + name: 'John Doe' + } + ] + }) + + await expect( + testDestination.testAction('createOrUpdateIndividualConstituent', { + event, + mapping, + useDefaultMappings: true + }) + ).rejects.toThrowError( + new IntegrationError('Multiple records returned for given traits', 'MULTIPLE_EXISTING_RECORDS', 400) + ) + }) + + test('should throw an IntegrationError if new constituent has no last name', async () => { + const event = createTestEvent(identifyEventDataNoLastName) + + nock(SKY_API_BASE_URL) + .get('/constituents/search?search_field=email_address&search_text=john@example.org') + .reply(200, { + count: 0, + value: [] + }) + + await expect( + testDestination.testAction('createOrUpdateIndividualConstituent', { + event, + mapping, + useDefaultMappings: true + }) + ).rejects.toThrowError(new IntegrationError('Missing last name value', 'MISSING_REQUIRED_FIELD', 400)) + }) + + test('should throw an IntegrationError if one or more request returns a 400 when updating an existing constituent', async () => { + const event = createTestEvent(identifyEventDataWithInvalidWebsite) + + nock(SKY_API_BASE_URL) + .get('/constituents/search?search_field=email_address&search_text=john@example.biz') + .reply(200, { + count: 1, + value: [ + { + id: '123', + address: 'PO Box 963\r\nNew York City, NY 10108', + email: 'john@example.biz', + fundraiser_status: 'None', + name: 'John Doe' + } + ] + }) + + nock(SKY_API_BASE_URL).patch('/constituents/123').reply(200) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/addresses?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '1000', + address_lines: 'PO Box 963', + city: 'New York City', + constituent_id: '123', + date_added: '2023-01-01T01:01:01.000-05:00', + date_modified: '2023-01-01T01:01:01.000-05:00', + do_not_mail: false, + formatted_address: 'PO Box 963\r\nNew York City, NY 10108', + inactive: false, + postal_code: '10108', + preferred: true, + state: 'NY', + type: 'Home' + } + ] + }) + + nock(SKY_API_BASE_URL).post('/addresses').reply(200, { + id: '1001' + }) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/emailaddresses?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '2000', + address: 'john@example.biz', + constituent_id: '123', + date_added: '2023-01-01T01:01:01.000-05:00', + date_modified: '2023-01-01T01:01:01.000-05:00', + do_not_email: false, + inactive: false, + primary: true, + type: 'Home' + } + ] + }) + + nock(SKY_API_BASE_URL).patch('/emailaddresses/2000').reply(200) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/onlinepresences?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '3000', + address: 'https://www.facebook.com/john.doe', + constituent_id: '123', + inactive: false, + primary: true, + type: 'Facebook' + } + ] + }) + + nock(SKY_API_BASE_URL).post('/onlinepresences').reply(400) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/phones?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '4000', + constituent_id: '123', + do_not_call: false, + inactive: false, + number: '+18774466722', + primary: true, + type: 'Home' + } + ] + }) + + nock(SKY_API_BASE_URL).post('/phones').reply(200, { + id: '4001' + }) + + await expect( + testDestination.testAction('createOrUpdateIndividualConstituent', { + event, + mapping, + useDefaultMappings: true + }) + ).rejects.toThrowError( + new IntegrationError( + 'One or more errors occurred when updating existing constituent: 400 error occurred when updating constituent online presence', + 'UPDATE_CONSTITUENT_ERROR', + 500 + ) + ) + }) + + test('should throw a RetryableError if a request returns a 429 when updating an existing constituent', async () => { + const event = createTestEvent(identifyEventDataUpdated) + + nock(SKY_API_BASE_URL) + .get('/constituents/search?search_field=email_address&search_text=john@example.biz') + .reply(200, { + count: 1, + value: [ + { + id: '123', + address: 'PO Box 963\r\nNew York City, NY 10108', + email: 'john@example.biz', + fundraiser_status: 'None', + name: 'John Doe' + } + ] + }) + + nock(SKY_API_BASE_URL).patch('/constituents/123').reply(200) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/onlinepresences?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '3001', + address: 'https://www.example.biz', + constituent_id: '123', + inactive: false, + primary: true, + type: 'Website' + } + ] + }) + + nock(SKY_API_BASE_URL) + .get('/constituents/123/addresses?include_inactive=true') + .reply(200, { + count: 1, + value: [ + { + id: '1000', + address_lines: 'PO Box 963', + city: 'New York City', + constituent_id: '123', + date_added: '2023-01-01T01:01:01.000-05:00', + date_modified: '2023-01-01T01:01:01.000-05:00', + do_not_mail: false, + formatted_address: 'PO Box 963\r\nNew York City, NY 10108', + inactive: false, + postal_code: '10108', + preferred: true, + state: 'NY', + type: 'Home' + } + ] + }) + + nock(SKY_API_BASE_URL).post('/addresses').reply(200, { + id: '1001' + }) + + nock(SKY_API_BASE_URL).get('/constituents/123/emailaddresses?include_inactive=true').reply(429) + + await expect( + testDestination.testAction('createOrUpdateIndividualConstituent', { + event, + mapping, + useDefaultMappings: true + }) + ).rejects.toThrowError(new RetryableError('429 error occurred when updating constituent email')) + }) +}) diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..95e842c64d --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/snapshot.test.ts @@ -0,0 +1,79 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'createOrUpdateIndividualConstituent' +const destinationSlug = 'BlackbaudRaisersEdgeNxt' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + eventData.last = 'Smith' + + nock(/.*/).persist().get(/.*/).reply(200, {}) + nock(/.*/).persist().patch(/.*/).reply(200, {}) + nock(/.*/).persist().post(/.*/).reply(200, {}) + nock(/.*/).persist().put(/.*/).reply(200, {}) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200, {}) + nock(/.*/).persist().patch(/.*/).reply(200, {}) + nock(/.*/).persist().post(/.*/).reply(200, {}) + nock(/.*/).persist().put(/.*/).reply(200, {}) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/fixtures.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/fixtures.ts new file mode 100644 index 0000000000..96d7a7820e --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/fixtures.ts @@ -0,0 +1,169 @@ +import { SegmentEvent } from '@segment/actions-core' + +// identify events +export const identifyEventData: Partial = { + type: 'identify', + traits: { + address: { + city: 'New York City', + postalCode: '10108', + state: 'NY', + street: 'PO Box 963' + }, + addressType: 'Home', + email: 'john@example.biz', + emailType: 'Personal', + firstName: 'John', + lastName: 'Doe', + phone: '+18774466722', + phoneType: 'Home', + website: 'https://www.facebook.com/john.doe', + websiteType: 'Facebook' + } +} + +export const identifyEventDataNoEmail: Partial = { + type: 'identify', + traits: { + firstName: 'John', + lastName: 'Doe', + phone: '+18774466722', + phoneType: 'Home' + } +} + +export const identifyEventDataNoLastName: Partial = { + type: 'identify', + traits: { + email: 'john@example.org' + } +} + +export const identifyEventDataUpdated: Partial = { + ...identifyEventData, + traits: { + ...identifyEventData.traits, + address: { + city: 'New York', + postalCode: '10005', + state: 'NY', + street: '11 Wall St' + }, + addressType: 'Work', + emailType: 'Work', + phone: '+18774466723', + phoneType: 'Work', + website: 'https://www.example.biz', + websiteType: 'Website' + } +} + +export const identifyEventDataWithLookupId: Partial = { + ...identifyEventDataUpdated, + traits: { + ...identifyEventDataUpdated.traits, + address: { + ...(typeof identifyEventDataUpdated.traits?.address === 'object' ? identifyEventDataUpdated.traits.address : {}), + street: '11 Wall Street' + }, + birthday: '2001-01-01T01:01:01-05:00', + email: 'john.doe@aol.com', + emailType: 'Personal', + lookup_id: 'abcd1234' + } +} + +export const identifyEventDataWithInvalidWebsite: Partial = { + ...identifyEventDataUpdated, + traits: { + ...identifyEventDataUpdated.traits, + websiteType: 'Invalid' + } +} + +// constituent data +export const constituentPayload = { + address: { + address_lines: 'PO Box 963', + city: 'New York City', + state: 'NY', + postalCode: '10108', + type: 'Home' + }, + email: { + address: 'john@example.biz', + type: 'Personal' + }, + first: 'John', + last: 'Doe', + online_presence: { + address: 'https://www.facebook.com/john.doe', + type: 'Facebook' + }, + phone: { + number: '+18774466722', + type: 'Home' + }, + type: 'Individual' +} + +export const constituentPayloadNoEmail = { + first: 'John', + last: 'Doe', + phone: { + number: '+18774466722', + type: 'Home' + }, + type: 'Individual' +} + +export const constituentPayloadWithLookupId = { + birthdate: { + d: '1', + m: '1', + y: '2001' + }, + first: 'John', + last: 'Doe', + lookup_id: 'abcd1234' +} + +// address data +export const addressPayloadUpdated = { + address_lines: '11 Wall St', + city: 'New York', + state: 'NY', + postalCode: '10005', + type: 'Work' +} + +export const addressPayloadWithUpdatedStreet = { + address_lines: '11 Wall Street', + city: 'New York', + state: 'NY', + postalCode: '10005', + type: 'Work' +} + +// email data +export const emailPayloadUpdated = { + address: 'john@example.biz', + type: 'Work' +} + +export const emailPayloadPersonal = { + address: 'john.doe@aol.com', + type: 'Personal' +} + +// online presence data +export const onlinePresencePayloadUpdated = { + address: 'https://www.example.biz', + type: 'Website' +} + +// phone data +export const phonePayloadUpdated = { + number: '+18774466723', + type: 'Work' +} diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/generated-types.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/generated-types.ts new file mode 100644 index 0000000000..d43337cd2f --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/generated-types.ts @@ -0,0 +1,67 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The constituent's address. + */ + address?: { + address_lines?: string + city?: string + country?: string + do_not_mail?: boolean + postal_code?: string + primary?: boolean + state?: string + type?: string + } + /** + * The constituent's birthdate. + */ + birthdate?: string | number + /** + * The constituent's email address. + */ + email?: { + address?: string + do_not_email?: boolean + primary?: boolean + type?: string + } + /** + * The constituent's first name up to 50 characters. + */ + first?: string + /** + * The constituent's gender. + */ + gender?: string + /** + * The constituent's income. + */ + income?: string + /** + * The constituent's last name up to 100 characters. This is required to create a constituent. + */ + last?: string + /** + * The organization-defined identifier for the constituent. + */ + lookup_id?: string + /** + * The constituent's online presence. + */ + online_presence?: { + address?: string + primary?: boolean + type?: string + } + /** + * The constituent's phone number. + */ + phone?: { + do_not_call?: boolean + number?: string + primary?: boolean + type?: string + } +} diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/index.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/index.ts new file mode 100644 index 0000000000..c523538b77 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/index.ts @@ -0,0 +1,751 @@ +import { ActionDefinition, IntegrationError, RetryableError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { BlackbaudSkyApi } from '../api' +import { + Address, + Constituent, + Email, + ExistingAddress, + ExistingEmail, + ExistingOnlinePresence, + ExistingPhone, + OnlinePresence, + Phone +} from '../types' +import { dateStringToFuzzyDate, filterObjectListByMatchFields, isRequestErrorRetryable } from '../utils' + +const action: ActionDefinition = { + title: 'Create or Update Individual Constituent', + description: "Create or update an Individual Constituent record in Raiser's Edge NXT.", + defaultSubscription: 'type = "identify"', + fields: { + address: { + label: 'Address', + description: "The constituent's address.", + type: 'object', + properties: { + address_lines: { + label: 'Address Lines', + type: 'string' + }, + city: { + label: 'City', + type: 'string' + }, + country: { + label: 'Country', + type: 'string' + }, + do_not_mail: { + label: 'Do Not Mail', + type: 'boolean' + }, + postal_code: { + label: 'ZIP/Postal Code', + type: 'string' + }, + primary: { + label: 'Is Primary', + type: 'boolean' + }, + state: { + label: 'State/Province', + type: 'string' + }, + type: { + label: 'Address Type', + type: 'string' + } + }, + default: { + address_lines: { + '@if': { + exists: { + '@path': '$.traits.address.street' + }, + then: { + '@path': '$.traits.address.street' + }, + else: { + '@path': '$.properties.address.street' + } + } + }, + city: { + '@if': { + exists: { + '@path': '$.traits.address.city' + }, + then: { + '@path': '$.traits.address.city' + }, + else: { + '@path': '$.properties.address.city' + } + } + }, + country: { + '@if': { + exists: { + '@path': '$.traits.address.country' + }, + then: { + '@path': '$.traits.address.country' + }, + else: { + '@path': '$.properties.address.country' + } + } + }, + do_not_mail: '', + postal_code: { + '@if': { + exists: { + '@path': '$.traits.address.postalCode' + }, + then: { + '@path': '$.traits.address.postalCode' + }, + else: { + '@path': '$.properties.address.postalCode' + } + } + }, + primary: '', + state: { + '@if': { + exists: { + '@path': '$.traits.address.state' + }, + then: { + '@path': '$.traits.address.state' + }, + else: { + '@path': '$.properties.address.state' + } + } + }, + type: '' + } + }, + birthdate: { + label: 'Birthdate', + description: "The constituent's birthdate.", + type: 'datetime', + default: { + '@if': { + exists: { + '@path': '$.traits.birthday' + }, + then: { + '@path': '$.traits.birthday' + }, + else: { + '@path': '$.properties.birthday' + } + } + } + }, + email: { + label: 'Email', + description: "The constituent's email address.", + type: 'object', + properties: { + address: { + label: 'Email Address', + type: 'string' + }, + do_not_email: { + label: 'Do Not Email', + type: 'boolean' + }, + primary: { + label: 'Is Primary', + type: 'boolean' + }, + type: { + label: 'Email Type', + type: 'string' + } + }, + default: { + address: { + '@if': { + exists: { + '@path': '$.traits.email' + }, + then: { + '@path': '$.traits.email' + }, + else: { + '@path': '$.properties.email' + } + } + }, + do_not_email: '', + primary: '', + type: '' + } + }, + first: { + label: 'First Name', + description: "The constituent's first name up to 50 characters.", + type: 'string', + default: { + '@if': { + exists: { + '@path': '$.traits.firstName' + }, + then: { + '@path': '$.traits.firstName' + }, + else: { + '@path': '$.properties.firstName' + } + } + } + }, + gender: { + label: 'Gender', + description: "The constituent's gender.", + type: 'string', + default: { + '@if': { + exists: { + '@path': '$.traits.gender' + }, + then: { + '@path': '$.traits.gender' + }, + else: { + '@path': '$.properties.gender' + } + } + } + }, + income: { + label: 'Income', + description: "The constituent's income.", + type: 'string' + }, + last: { + label: 'Last Name', + description: "The constituent's last name up to 100 characters. This is required to create a constituent.", + type: 'string', + default: { + '@if': { + exists: { + '@path': '$.traits.lastName' + }, + then: { + '@path': '$.traits.lastName' + }, + else: { + '@path': '$.properties.lastName' + } + } + } + }, + lookup_id: { + label: 'Lookup ID', + description: 'The organization-defined identifier for the constituent.', + type: 'string', + default: '' + }, + online_presence: { + label: 'Online Presence', + description: "The constituent's online presence.", + type: 'object', + properties: { + address: { + label: 'Web Address', + type: 'string' + }, + primary: { + label: 'Is Primary', + type: 'boolean' + }, + type: { + label: 'Online Presence Type', + type: 'string' + } + }, + default: { + address: { + '@if': { + exists: { + '@path': '$.traits.website' + }, + then: { + '@path': '$.traits.website' + }, + else: { + '@path': '$.properties.website' + } + } + }, + primary: '', + type: '' + } + }, + phone: { + label: 'Phone', + description: "The constituent's phone number.", + type: 'object', + properties: { + do_not_call: { + label: 'Do Not Call', + type: 'boolean' + }, + number: { + label: 'Phone Number', + type: 'string' + }, + primary: { + label: 'Is Primary', + type: 'boolean' + }, + type: { + label: 'Phone Type', + type: 'string' + } + }, + default: { + do_not_call: '', + number: { + '@if': { + exists: { + '@path': '$.traits.phone' + }, + then: { + '@path': '$.traits.phone' + }, + else: { + '@path': '$.properties.phone' + } + } + }, + primary: '', + type: '' + } + } + }, + perform: async (request, { payload }) => { + const blackbaudSkyApiClient: BlackbaudSkyApi = new BlackbaudSkyApi(request) + + // search for existing constituent + let constituentId = undefined + if (payload.email?.address || payload.lookup_id) { + // default to searching by email + let searchField = 'email_address' + let searchText = payload.email?.address || '' + + if (payload.lookup_id) { + // search by lookup_id if one is provided + searchField = 'lookup_id' + searchText = payload.lookup_id + } + + const constituentSearchResponse = await blackbaudSkyApiClient.getExistingConstituents(searchField, searchText) + const constituentSearchResults = await constituentSearchResponse.json() + + if (constituentSearchResults.count > 1) { + // multiple existing constituents, throw an error + throw new IntegrationError('Multiple records returned for given traits', 'MULTIPLE_EXISTING_RECORDS', 400) + } else if (constituentSearchResults.count === 1) { + // existing constituent + constituentId = constituentSearchResults.value[0].id + } + } + + // data for constituent call + const constituentData: Constituent = { + first: payload.first, + gender: payload.gender, + income: payload.income, + last: payload.last, + lookup_id: payload.lookup_id + } + Object.keys(constituentData).forEach((key) => { + if (!constituentData[key as keyof Constituent]) { + delete constituentData[key as keyof Constituent] + } + }) + if (payload.birthdate) { + const birthdateFuzzyDate = dateStringToFuzzyDate(payload.birthdate) + if (birthdateFuzzyDate) { + constituentData.birthdate = birthdateFuzzyDate + } + } + + // data for address call + let constituentAddressData: Address = {} + if ( + payload.address && + (payload.address.address_lines || + payload.address.city || + payload.address.country || + payload.address.postal_code || + payload.address.state) && + payload.address.type + ) { + constituentAddressData = payload.address + } + + // data for email call + let constituentEmailData: Email = {} + if (payload.email && payload.email.address && payload.email.type) { + constituentEmailData = payload.email + } + + // data for online presence call + let constituentOnlinePresenceData: OnlinePresence = {} + if (payload.online_presence && payload.online_presence.address && payload.online_presence.type) { + constituentOnlinePresenceData = payload.online_presence + } + + // data for phone call + let constituentPhoneData: Phone = {} + if (payload.phone && payload.phone.number && payload.phone.type) { + constituentPhoneData = payload.phone + } + + if (!constituentId) { + // new constituent + // hardcode type + constituentData.type = 'Individual' + if (!constituentData.last) { + // last name is required to create a new constituent + // no last name, throw an error + throw new IntegrationError('Missing last name value', 'MISSING_REQUIRED_FIELD', 400) + } else { + // request has last name + // append other data objects to constituent + if (Object.keys(constituentAddressData).length > 0) { + constituentData.address = constituentAddressData + } + if (Object.keys(constituentEmailData).length > 0) { + constituentData.email = constituentEmailData + } + if (Object.keys(constituentOnlinePresenceData).length > 0) { + constituentData.online_presence = constituentOnlinePresenceData + } + if (Object.keys(constituentPhoneData).length > 0) { + constituentData.phone = constituentPhoneData + } + + // create constituent + await blackbaudSkyApiClient.createConstituent(constituentData) + } + + return + } else { + // existing constituent + // aggregate all errors + const integrationErrors = [] + if (Object.keys(constituentData).length > 0) { + // request has at least one constituent field to update + // update constituent + const updateConstituentResponse = await blackbaudSkyApiClient.updateConstituent(constituentId, constituentData) + if (updateConstituentResponse.status !== 200) { + const statusCode = updateConstituentResponse.status + const errorMessage = statusCode + ? `${statusCode} error occurred when updating constituent` + : 'Error occurred when updating constituent' + if (isRequestErrorRetryable(statusCode)) { + throw new RetryableError(errorMessage) + } else { + integrationErrors.push(errorMessage) + } + } + } + + if (Object.keys(constituentAddressData).length > 0) { + // request has address data + // get existing addresses + const getConstituentAddressListResponse = await blackbaudSkyApiClient.getConstituentAddressList(constituentId) + let updateAddressErrorCode = undefined + if (getConstituentAddressListResponse.status !== 200) { + updateAddressErrorCode = getConstituentAddressListResponse.status + } else { + const constituentAddressListResults = await getConstituentAddressListResponse.json() + + // check address list for one that matches request + let existingAddress: ExistingAddress | undefined = undefined + if (constituentAddressListResults.count > 0) { + existingAddress = filterObjectListByMatchFields( + constituentAddressListResults.value, + constituentAddressData, + ['address_lines', 'city', 'postal_code', 'state'] + ) as ExistingAddress | undefined + } + + if (!existingAddress) { + // new address + // if this is the only address, make it primary + if (constituentAddressData.primary !== false && constituentAddressListResults.count === 0) { + constituentAddressData.primary = true + } + // create address + const createConstituentAddressResponse = await blackbaudSkyApiClient.createConstituentAddress( + constituentId, + constituentAddressData + ) + if (createConstituentAddressResponse.status !== 200) { + updateAddressErrorCode = createConstituentAddressResponse.status + } + } else { + // existing address + if ( + existingAddress.inactive || + (constituentAddressData.do_not_mail !== undefined && + constituentAddressData.do_not_mail !== existingAddress.do_not_mail) || + (constituentAddressData.primary !== undefined && + constituentAddressData.primary && + constituentAddressData.primary !== existingAddress.primary) || + constituentAddressData.type !== existingAddress.type + ) { + // request has at least one address field to update + // update address + const updateConstituentAddressByIdResponse = await blackbaudSkyApiClient.updateConstituentAddressById( + existingAddress.id, + constituentAddressData + ) + if (updateConstituentAddressByIdResponse.status !== 200) { + updateAddressErrorCode = updateConstituentAddressByIdResponse.status + } + } + } + } + + if (updateAddressErrorCode) { + const errorMessage = updateAddressErrorCode + ? `${updateAddressErrorCode} error occurred when updating constituent address` + : 'Error occurred when updating constituent address' + if (isRequestErrorRetryable(updateAddressErrorCode)) { + throw new RetryableError(errorMessage) + } else { + integrationErrors.push(errorMessage) + } + } + } + + if (Object.keys(constituentEmailData).length > 0) { + // request has email data + // get existing addresses + const getConstituentEmailListResponse = await blackbaudSkyApiClient.getConstituentEmailList(constituentId) + let updateEmailErrorCode = undefined + if (getConstituentEmailListResponse.status !== 200) { + updateEmailErrorCode = getConstituentEmailListResponse.status + } else { + const constituentEmailListResults = await getConstituentEmailListResponse.json() + + // check email list for one that matches request + let existingEmail: ExistingEmail | undefined = undefined + if (constituentEmailListResults.count > 0) { + existingEmail = filterObjectListByMatchFields(constituentEmailListResults.value, constituentEmailData, [ + 'address' + ]) as ExistingEmail | undefined + } + + if (!existingEmail) { + // new email + // if this is the only email, make it primary + if (constituentEmailData.primary !== false && constituentEmailListResults.count === 0) { + constituentEmailData.primary = true + } + // create email + const createConstituentEmailResponse = await blackbaudSkyApiClient.createConstituentEmail( + constituentId, + constituentEmailData + ) + if (createConstituentEmailResponse.status !== 200) { + updateEmailErrorCode = createConstituentEmailResponse.status + } + } else { + // existing email + if ( + existingEmail.inactive || + (constituentEmailData.do_not_email !== undefined && + constituentEmailData.do_not_email !== existingEmail.do_not_email) || + (constituentEmailData.primary !== undefined && + constituentEmailData.primary && + constituentEmailData.primary !== existingEmail.primary) || + constituentEmailData.type !== existingEmail.type + ) { + // request has at least one email field to update + // update email + const updateConstituentEmailByIdResponse = await blackbaudSkyApiClient.updateConstituentEmailById( + existingEmail.id, + constituentEmailData + ) + if (updateConstituentEmailByIdResponse.status !== 200) { + updateEmailErrorCode = updateConstituentEmailByIdResponse.status + } + } + } + } + + if (updateEmailErrorCode) { + const errorMessage = updateEmailErrorCode + ? `${updateEmailErrorCode} error occurred when updating constituent email` + : 'Error occurred when updating constituent email' + if (isRequestErrorRetryable(updateEmailErrorCode)) { + throw new RetryableError(errorMessage) + } else { + integrationErrors.push(errorMessage) + } + } + } + + if (Object.keys(constituentOnlinePresenceData).length > 0) { + // request has online presence data + // get existing online presences + const getConstituentOnlinePresenceListResponse = await blackbaudSkyApiClient.getConstituentOnlinePresenceList( + constituentId + ) + let updateOnlinePresenceErrorCode = undefined + if (getConstituentOnlinePresenceListResponse.status !== 200) { + updateOnlinePresenceErrorCode = getConstituentOnlinePresenceListResponse.status + } else { + const constituentOnlinePresenceListResults = await getConstituentOnlinePresenceListResponse.json() + + // check online presence list for one that matches request + let existingOnlinePresence: ExistingOnlinePresence | undefined = undefined + if (constituentOnlinePresenceListResults.count > 0) { + existingOnlinePresence = filterObjectListByMatchFields( + constituentOnlinePresenceListResults.value, + constituentOnlinePresenceData, + ['address'] + ) as ExistingOnlinePresence | undefined + } + + if (!existingOnlinePresence) { + // new online presence + // if this is the only online presence, make it primary + if (constituentOnlinePresenceData.primary !== false && constituentOnlinePresenceListResults.count === 0) { + constituentOnlinePresenceData.primary = true + } + // create online presence + const createConstituentOnlinePresenceResponse = await blackbaudSkyApiClient.createConstituentOnlinePresence( + constituentId, + constituentOnlinePresenceData + ) + if (createConstituentOnlinePresenceResponse.status !== 200) { + updateOnlinePresenceErrorCode = createConstituentOnlinePresenceResponse.status + } + } else { + // existing online presence + if ( + existingOnlinePresence.inactive || + (constituentOnlinePresenceData.primary !== undefined && + constituentOnlinePresenceData.primary !== existingOnlinePresence.primary) || + constituentOnlinePresenceData.type !== existingOnlinePresence.type + ) { + // request has at least one online presence field to update + // update online presence + const updateConstituentOnlinePresenceByIdResponse = + await blackbaudSkyApiClient.updateConstituentOnlinePresenceById( + existingOnlinePresence.id, + constituentOnlinePresenceData + ) + if (updateConstituentOnlinePresenceByIdResponse.status !== 200) { + updateOnlinePresenceErrorCode = updateConstituentOnlinePresenceByIdResponse.status + } + } + } + } + + if (updateOnlinePresenceErrorCode) { + const errorMessage = updateOnlinePresenceErrorCode + ? `${updateOnlinePresenceErrorCode} error occurred when updating constituent online presence` + : 'Error occurred when updating constituent online presence' + if (isRequestErrorRetryable(updateOnlinePresenceErrorCode)) { + throw new RetryableError(errorMessage) + } else { + integrationErrors.push(errorMessage) + } + } + } + + if (Object.keys(constituentPhoneData).length > 0) { + // request has phone data + // get existing phones + const getConstituentPhoneListResponse = await blackbaudSkyApiClient.getConstituentPhoneList(constituentId) + let updatePhoneErrorCode = undefined + if (getConstituentPhoneListResponse.status !== 200) { + updatePhoneErrorCode = getConstituentPhoneListResponse.status + } else { + const constituentPhoneListResults = await getConstituentPhoneListResponse.json() + + // check phone list for one that matches request + let existingPhone: ExistingPhone | undefined = undefined + if (constituentPhoneListResults.count > 0) { + existingPhone = filterObjectListByMatchFields(constituentPhoneListResults.value, constituentPhoneData, [ + 'int:number' + ]) as ExistingPhone | undefined + } + + if (!existingPhone) { + // new phone + // if this is the only phone, make it primary + if (constituentPhoneData.primary !== false && constituentPhoneListResults.count === 0) { + constituentPhoneData.primary = true + } + // create phone + const createConstituentPhoneResponse = await blackbaudSkyApiClient.createConstituentPhone( + constituentId, + constituentPhoneData + ) + if (createConstituentPhoneResponse.status !== 200) { + updatePhoneErrorCode = createConstituentPhoneResponse.status + } + } else { + // existing phone + if ( + existingPhone.inactive || + (constituentPhoneData.do_not_call !== undefined && + constituentPhoneData.do_not_call !== existingPhone.do_not_call) || + (constituentPhoneData.primary !== undefined && constituentPhoneData.primary !== existingPhone.primary) || + constituentPhoneData.type !== existingPhone.type + ) { + // request has at least one phone field to update + // update phone + const updateConstituentPhoneByIdResponse = await blackbaudSkyApiClient.updateConstituentPhoneById( + existingPhone.id, + constituentPhoneData + ) + if (updateConstituentPhoneByIdResponse.status !== 200) { + updatePhoneErrorCode = updateConstituentPhoneByIdResponse.status + } + } + } + } + + if (updatePhoneErrorCode) { + const errorMessage = updatePhoneErrorCode + ? `${updatePhoneErrorCode} error occurred when updating constituent online presence` + : 'Error occurred when updating constituent online presence' + if (isRequestErrorRetryable(updatePhoneErrorCode)) { + throw new RetryableError(errorMessage) + } else { + integrationErrors.push(errorMessage) + } + } + } + + if (integrationErrors.length > 0) { + throw new IntegrationError( + 'One or more errors occurred when updating existing constituent: ' + integrationErrors.join(', '), + 'UPDATE_CONSTITUENT_ERROR', + 500 + ) + } + + return + } + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/generated-types.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/generated-types.ts new file mode 100644 index 0000000000..0f7daac31a --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * The access key found on your Blackbaud "My subscriptions" page. + */ + bbApiSubscriptionKey: string +} diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/index.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/index.ts new file mode 100644 index 0000000000..280cc3651b --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/index.ts @@ -0,0 +1,61 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import { SKY_API_BASE_URL, SKY_OAUTH2_TOKEN_URL } from './constants' +import { RefreshTokenResponse } from './types' +import createOrUpdateIndividualConstituent from './createOrUpdateIndividualConstituent' + +const destination: DestinationDefinition = { + name: "Blackbaud Raiser's Edge NXT", + slug: 'actions-blackbaud-raisers-edge-nxt', + mode: 'cloud', + + authentication: { + scheme: 'oauth-managed', + fields: { + bbApiSubscriptionKey: { + label: 'Blackbaud API Subscription Key', + description: 'The access key found on your Blackbaud "My subscriptions" page.', + type: 'string', + required: true + } + }, + testAuthentication: (request) => { + return request(`${SKY_API_BASE_URL}/emailaddresstypes`) + }, + refreshAccessToken: async (request, { auth }) => { + // Return a request that refreshes the access_token if the API supports it + const res = await request(SKY_OAUTH2_TOKEN_URL, { + method: 'POST', + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: auth.refreshToken, + client_id: auth.clientId, + client_secret: auth.clientSecret + }) + }) + + return { accessToken: res.data.access_token } + } + }, + extendRequest({ auth, settings }) { + return { + headers: { + authorization: `Bearer ${auth?.accessToken}`, + 'Bb-Api-Subscription-Key': `${settings.bbApiSubscriptionKey}` + } + } + }, + + //onDelete: async (request, { settings, payload }) => { + onDelete: async () => { + // Return a request that performs a GDPR delete for the provided Segment userId or anonymousId + // provided in the payload. If your destination does not support GDPR deletion you should not + // implement this function and should remove it completely. + }, + + actions: { + createOrUpdateIndividualConstituent + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/types/index.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/types/index.ts new file mode 100644 index 0000000000..5d25799dd2 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/types/index.ts @@ -0,0 +1,78 @@ +export interface RefreshTokenResponse { + access_token: string +} + +export interface StringIndexedObject { + [key: string]: any +} + +export interface FuzzyDate { + d: string + m: string + y: string +} + +export interface Constituent { + address?: Address + birthdate?: FuzzyDate + email?: Email + first?: string + gender?: string + income?: string + last?: string + lookup_id?: string + online_presence?: OnlinePresence + phone?: Phone + type?: string +} + +export interface Address { + address_lines?: string + city?: string + country?: string + do_not_mail?: boolean + postal_code?: string + primary?: boolean + state?: string + type?: string + inactive?: boolean +} + +export interface ExistingAddress extends Address { + id: string +} + +export interface Email { + address?: string + do_not_email?: boolean + primary?: boolean + type?: string + inactive?: boolean +} + +export interface ExistingEmail extends Email { + id: string +} + +export interface OnlinePresence { + address?: string + primary?: boolean + type?: string + inactive?: boolean +} + +export interface ExistingOnlinePresence extends OnlinePresence { + id: string +} + +export interface Phone { + do_not_call?: boolean + number?: string + primary?: boolean + type?: string + inactive?: boolean +} + +export interface ExistingPhone extends Phone { + id: string +} diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/utils/index.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/utils/index.ts new file mode 100644 index 0000000000..6c49070771 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/utils/index.ts @@ -0,0 +1,48 @@ +import { StringIndexedObject } from '../types' + +export const dateStringToFuzzyDate = (dateString: string | number) => { + const date = new Date(dateString) + if (isNaN(date.getTime())) { + // invalid date object + return false + } else { + // valid date object + // convert date to a "Fuzzy date" + // https://developer.blackbaud.com/skyapi/renxt/constituent/entities#FuzzyDate + return { + d: date.getDate().toString(), + m: (date.getMonth() + 1).toString(), + y: date.getFullYear().toString() + } + } +} + +export const filterObjectListByMatchFields = ( + list: StringIndexedObject[], + data: StringIndexedObject, + matchFields: string[] +) => { + return list.find((item: StringIndexedObject) => { + let isMatch: boolean | undefined = undefined + matchFields.forEach((field: string) => { + if (isMatch !== false) { + let fieldName = field + if (field.startsWith('int:')) { + fieldName = field.split('int:')[1] + } + let itemValue = item[fieldName] ? item[fieldName].toLowerCase() : '' + let dataValue = data[fieldName] ? data[fieldName].toLowerCase() : '' + if (field.startsWith('int:')) { + itemValue = itemValue.replace(/\D/g, '') + dataValue = dataValue.replace(/\D/g, '') + } + isMatch = itemValue === dataValue + } + }) + return isMatch + }) +} + +export const isRequestErrorRetryable = (statusCode: number) => { + return statusCode === 429 || statusCode >= 500 +}