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..c6752a739d --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/__tests__/index.test.ts @@ -0,0 +1,19 @@ +import nock from 'nock' +// import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe("Blackbaud Raiser's Edge NXT", () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock('https://your.destination.endpoint').get('*').reply(200, {}) + + // This should match your authentication.fields + const authData = {} + + await expect(testDestination.testAuthentication(authData)).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..2445b287d3 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +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) + + nock(/.*/).persist().get(/.*/).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().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/constants.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/constants.ts new file mode 100644 index 0000000000..8e96d0c8a8 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/constants.ts @@ -0,0 +1 @@ +export const SKY_API_BASE_URL = 'https://api.sky.blackbaud.com/constituent/v1' diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/fixtures.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/fixtures.ts new file mode 100644 index 0000000000..4c2ee32319 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/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', + postal_code: '10108', + state: 'NY', + street: 'PO Box 963' + }, + address_type: 'Home', + email: 'john@example.biz', + email_type: 'Personal', + first_name: 'John', + last_name: 'Doe', + phone: '+18774466722', + phone_type: 'Home', + website: 'https://www.facebook.com/john.doe', + website_type: 'Facebook' + } +} + +export const identifyEventDataNoEmail: Partial = { + type: 'identify', + traits: { + first_name: 'John', + last_name: 'Doe', + phone: '+18774466722', + phone_type: 'Home' + } +} + +export const identifyEventDataNoLastName: Partial = { + type: 'identify', + traits: { + email: 'john@example.org' + } +} + +export const identifyEventDataUpdated: Partial = { + ...identifyEventData, + traits: { + ...identifyEventData.traits, + address: { + city: 'New York', + postal_code: '10005', + state: 'NY', + street: '11 Wall St' + }, + address_type: 'Work', + email_type: 'Work', + phone: '+18774466723', + phone_type: 'Work', + website: 'https://www.example.biz', + website_type: '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', + email_type: 'Personal', + lookup_id: 'abcd1234' + } +} + +export const identifyEventDataWithInvalidWebsite: Partial = { + ...identifyEventDataUpdated, + traits: { + ...identifyEventDataUpdated.traits, + website_type: 'Invalid' + } +} + +// constituent data +export const constituentPayload = { + address: { + address_lines: 'PO Box 963', + city: 'New York City', + state: 'NY', + postal_code: '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', + postal_code: '10005', + type: 'Work' +} + +export const addressPayloadWithUpdatedStreet = { + address_lines: '11 Wall Street', + city: 'New York', + state: 'NY', + postal_code: '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/__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..3c780bb56c --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/index.test.ts @@ -0,0 +1,623 @@ +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 { + addressPayloadWithUpdatedStreet, + addressPayloadUpdated, + constituentPayload, + constituentPayloadNoEmail, + constituentPayloadWithLookupId, + emailPayloadPersonal, + emailPayloadUpdated, + identifyEventData, + identifyEventDataNoEmail, + identifyEventDataNoLastName, + identifyEventDataWithInvalidWebsite, + identifyEventDataWithLookupId, + identifyEventDataUpdated, + onlinePresencePayloadUpdated, + phonePayloadUpdated +} from './fixtures' + +const testDestination = createTestIntegration(Destination) + +const mapping = { + properties: { + address: { + type: { + '@path': '$.traits.address_type' + } + }, + email: { + type: { + '@path': '$.traits.email_type' + } + }, + lookup_id: { + '@path': '$.traits.lookup_id' + }, + online_presence: { + type: { + '@path': '$.traits.website_type' + } + }, + phone: { + type: { + '@path': '$.traits.phone_type' + } + } + } +} + +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', constituentPayload).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', constituentPayloadNoEmail).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) + .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', { + ...addressPayloadUpdated, + constituent_id: '123' + }) + .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/9876', emailPayloadUpdated).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', { + ...onlinePresencePayloadUpdated, + constituent_id: '123' + }) + .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', { + ...phonePayloadUpdated, + constituent_id: '123' + }) + .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', constituentPayloadWithLookupId).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', { + ...addressPayloadWithUpdatedStreet, + constituent_id: '123' + }) + .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', { + ...emailPayloadPersonal, + constituent_id: '123' + }) + .reply(200, { + id: '2001' + }) + + 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 a RetryableError if constituent search returns a 429', async () => { + const event = createTestEvent(identifyEventData) + + nock(SKY_API_BASE_URL) + .get('/constituents/search?search_field=email_address&search_text=john@example.biz') + .reply(429) + + await expect( + testDestination.testAction('createOrUpdateIndividualConstituent', { + event, + mapping, + useDefaultMappings: true + }) + ).rejects.toThrowError(new RetryableError('429 error returned when searching for constituent')) + }) + + 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 a RetryableError if creating new constituent returns a 429', 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', constituentPayload).reply(429) + + await expect( + testDestination.testAction('createOrUpdateIndividualConstituent', { + event, + mapping, + useDefaultMappings: true + }) + ).rejects.toThrowError(new RetryableError('429 error occurred when creating constituent')) + }) + + 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) + .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', { + ...addressPayloadUpdated, + constituent_id: '123' + }) + .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/9876', emailPayloadUpdated).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', { + ...onlinePresencePayloadUpdated, + constituent_id: '123' + }) + .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', { + ...phonePayloadUpdated, + constituent_id: '123' + }) + .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 one or more 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) + .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', { + ...addressPayloadUpdated, + constituent_id: '123' + }) + .reply(200, { + id: '1001' + }) + + nock(SKY_API_BASE_URL).get('/constituents/123/emailaddresses?include_inactive=true').reply(429) + + nock(SKY_API_BASE_URL).get('/constituents/123/onlinepresences?include_inactive=true').reply(429) + + nock(SKY_API_BASE_URL).get('/constituents/123/phones?include_inactive=true').reply(429) + + await expect( + testDestination.testAction('createOrUpdateIndividualConstituent', { + event, + mapping, + useDefaultMappings: true + }) + ).rejects.toThrowError( + new RetryableError( + 'One or more errors occurred when updating existing constituent: 429 error occurred when updating constituent email, 429 error occurred when updating constituent online presence, 429 error occurred when updating constituent phone' + ) + ) + }) +}) 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..7f8ae9ea39 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +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) + + nock(/.*/).persist().get(/.*/).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().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/generated-types.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/generated-types.ts new file mode 100644 index 0000000000..bd76321e8d --- /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 + /** + * 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..4420d5bfd3 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createOrUpdateIndividualConstituent/index.ts @@ -0,0 +1,779 @@ +import { ActionDefinition, IntegrationError, RetryableError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { SKY_API_BASE_URL } from '../constants' +import { 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.postal_code' + }, + then: { + '@path': '$.traits.address.postal_code' + }, + else: { + '@path': '$.properties.address.postal_code' + } + } + }, + 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: 'string', + 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.first_name' + }, + then: { + '@path': '$.traits.first_name' + }, + else: { + '@path': '$.properties.first_name' + } + } + } + }, + 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.last_name' + }, + then: { + '@path': '$.traits.last_name' + }, + else: { + '@path': '$.properties.last_name' + } + } + } + }, + lookup_id: { + label: 'Lookup ID', + description: 'The organization-defined identifier for the constituent.', + type: 'string' + }, + online_presence: { + label: 'Phone', + 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 }) => { + // 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 + } + + try { + const constituentSearchResponse = await request( + `${SKY_API_BASE_URL}/constituents/search?search_field=${searchField}&search_text=${searchText}`, + { + method: 'get' + } + ) + 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 + } else if (constituentSearchResults.count !== 0) { + // if constituent count is not >= 0, something went wrong + throw new IntegrationError('Unexpected record count for given traits', 'UNEXPECTED_RECORD_COUNT', 500) + } + } catch (error) { + const statusCode = error?.response?.status + const errorMessage = statusCode + ? `${statusCode} error occurred when searching for constituent` + : 'Error occurred when searching for constituent' + if (isRequestErrorRetryable(statusCode)) { + throw new RetryableError(errorMessage) + } else { + throw new IntegrationError(errorMessage, 'CONSTITUENT_SEARCH_ERROR', statusCode || 500) + } + } + } + + // data for constituent call + const constituentData = {} + const simpleConstituentFields = ['first', 'gender', 'income', 'last', 'lookup_id'] + simpleConstituentFields.forEach((key) => { + if (payload[key] !== undefined) { + constituentData[key] = payload[key] + } + }) + if (payload.birthdate) { + const birthdateDate = new Date(payload.birthdate) + if (!isNaN(birthdateDate)) { + // valid date object + // convert birthdate to a "Fuzzy date" + // https://developer.blackbaud.com/skyapi/renxt/constituent/entities#FuzzyDate + constituentData.birthdate = { + d: birthdateDate.getDate().toString(), + m: (birthdateDate.getMonth() + 1).toString(), + y: birthdateDate.getFullYear().toString() + } + } + } + + // data for address call + let constituentAddressData = undefined + 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 = undefined + if (payload.email && payload.email.address && payload.email.type) { + constituentEmailData = payload.email + } + + // data for online presence call + let constituentOnlinePresenceData = undefined + if (payload.online_presence && payload.online_presence.address && payload.online_presence.type) { + constituentOnlinePresenceData = payload.online_presence + } + + // data for phone call + let constituentPhoneData = undefined + 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 (constituentAddressData) { + constituentData.address = constituentAddressData + } + if (constituentEmailData) { + constituentData.email = constituentEmailData + } + if (constituentOnlinePresenceData) { + constituentData.online_presence = constituentOnlinePresenceData + } + if (constituentPhoneData) { + constituentData.phone = constituentPhoneData + } + + // create constituent + try { + await request(`${SKY_API_BASE_URL}/constituents`, { + method: 'post', + json: constituentData + }) + } catch (error) { + const statusCode = error?.response?.status + const errorMessage = statusCode + ? `${statusCode} error occurred when creating constituent` + : 'Error occurred when creating constituent' + if (isRequestErrorRetryable(statusCode)) { + throw new RetryableError(errorMessage) + } else { + throw new IntegrationError(errorMessage, 'CREATE_CONSTITUENT_ERROR', statusCode || 500) + } + } + } + + return + } else { + // existing constituent + // aggregate all errors + const integrationErrors = [] + const retryableErrors = [] + if (Object.keys(constituentData).length > 0) { + // request has at least one constituent field to update + // update constituent + try { + await request(`${SKY_API_BASE_URL}/constituents/${constituentId}`, { + method: 'patch', + json: constituentData + }) + } catch (error) { + const statusCode = error?.response?.status + const errorMessage = statusCode + ? `${statusCode} error occurred when updating constituent` + : 'Error occurred when updating constituent' + if (isRequestErrorRetryable(statusCode)) { + retryableErrors.push(errorMessage) + } else { + integrationErrors.push(errorMessage) + } + } + } + + if (constituentAddressData) { + // request has address data + // get existing addresses + try { + const constituentAddressListResponse = await request( + `${SKY_API_BASE_URL}/constituents/${constituentId}/addresses?include_inactive=true`, + { + method: 'get' + } + ) + const constituentAddressListResults = await constituentAddressListResponse.json() + + // check address list for one that matches request + let existingAddress = undefined + if (constituentAddressListResults.count > 0) { + existingAddress = constituentAddressListResults.value.filter((result) => { + return ( + result.address_lines.toLowerCase() === constituentAddressData.address_lines.toLowerCase() && + result.city.toLowerCase() === constituentAddressData.city.toLowerCase() && + result.country.toLowerCase() === constituentAddressData.country.toLowerCase() && + result.postal_code.toLowerCase() === constituentAddressData.postal_code.toLowerCase() && + result.state.toLowerCase() === constituentAddressData.state.toLowerCase() + ) + }) + } + + if (!existingAddress) { + // new address + // create address + await request(`${SKY_API_BASE_URL}/addresses`, { + method: 'post', + json: { + ...constituentAddressData, + constituent_id: constituentId, + // if this is the only address, make it primary + primary: + constituentAddressData.primary || + (constituentAddressData.primary !== false && constituentAddressListResults.count === 0) + } + }) + } else { + // existing address + if ( + existingAddress.inactive || + constituentAddressData.do_not_mail !== existingAddress.do_not_mail || + constituentAddressData.primary !== existingAddress.primary || + constituentAddressData.type !== existingAddress.type + ) { + // request has at least one address field to update + // update address + await request(`${SKY_API_BASE_URL}/addresses/${existingAddress.id}`, { + method: 'patch', + json: { + ...constituentAddressData, + inactive: false + } + }) + } + } + } catch (error) { + const statusCode = error?.response?.status + const errorMessage = statusCode + ? `${statusCode} error occurred when updating constituent address` + : 'Error occurred when updating constituent address' + if (isRequestErrorRetryable(statusCode)) { + retryableErrors.push(errorMessage) + } else { + integrationErrors.push(errorMessage) + } + } + } + + if (constituentEmailData) { + // request has email data + // get existing addresses + try { + const constituentEmailListResponse = await request( + `${SKY_API_BASE_URL}/constituents/${constituentId}/emailaddresses?include_inactive=true`, + { + method: 'get' + } + ) + const constituentEmailListResults = await constituentEmailListResponse.json() + + // check email list for one that matches request + let existingEmail = undefined + if (constituentEmailListResults.count > 0) { + existingEmail = constituentEmailListResults.value.filter((result) => { + return result.address.toLowerCase() === constituentEmailData.address.toLowerCase() + }) + } + + if (!existingEmail) { + // new email + // create email + await request(`${SKY_API_BASE_URL}/emailaddresses`, { + method: 'post', + json: { + ...constituentEmailData, + constituent_id: constituentId, + // if this is the only email, make it primary + primary: + constituentEmailData.primary || + (constituentEmailData.primary !== false && constituentEmailListResults.count === 0) + } + }) + } else { + // existing email + if ( + existingEmail.inactive || + constituentEmailData.do_not_email !== existingEmail.do_not_email || + constituentEmailData.primary !== existingEmail.primary || + constituentEmailData.type !== existingEmail.type + ) { + // request has at least one email field to update + // update email + await request(`${SKY_API_BASE_URL}/emailaddresses/${existingEmail.id}`, { + method: 'patch', + json: { + ...constituentEmailData, + inactive: false + } + }) + } + } + } catch (error) { + const statusCode = error?.response?.status + const errorMessage = statusCode + ? `${statusCode} error occurred when updating constituent email` + : 'Error occurred when updating constituent email' + if (isRequestErrorRetryable(statusCode)) { + retryableErrors.push(errorMessage) + } else { + integrationErrors.push(errorMessage) + } + } + } + + if (constituentOnlinePresenceData) { + // request has online presence data + // get existing online presences + try { + const constituentOnlinePresenceListResponse = await request( + `${SKY_API_BASE_URL}/constituents/${constituentId}/onlinepresences?include_inactive=true`, + { + method: 'get' + } + ) + const constituentOnlinePresenceListResults = await constituentOnlinePresenceListResponse.json() + + // check online presence list for one that matches request + let existingOnlinePresence = undefined + if (constituentOnlinePresenceListResults.count > 0) { + existingOnlinePresence = constituentOnlinePresenceListResults.value.filter((result) => { + return result.address.toLowerCase() === constituentOnlinePresenceData.address.toLowerCase() + }) + } + + if (!existingOnlinePresence) { + // new online presence + // create online presence + await request(`${SKY_API_BASE_URL}/onlinepresences`, { + method: 'post', + json: { + ...constituentOnlinePresenceData, + constituent_id: constituentId, + // if this is the only online presence, make it primary + primary: + constituentOnlinePresenceData.primary || + (constituentOnlinePresenceData.primary !== false && constituentOnlinePresenceListResults.count === 0) + } + }) + } else { + // existing online presence + if ( + existingOnlinePresence.inactive || + constituentOnlinePresenceData.primary !== existingOnlinePresence.primary || + constituentOnlinePresenceData.type !== existingOnlinePresence.type + ) { + // request has at least one online presence field to update + // update online presence + await request(`${SKY_API_BASE_URL}/onlinepresences/${existingOnlinePresence.id}`, { + method: 'patch', + json: { + ...constituentOnlinePresenceData, + inactive: false + } + }) + } + } + } catch (error) { + const statusCode = error?.response?.status + const errorMessage = statusCode + ? `${statusCode} error occurred when updating constituent online presence` + : 'Error occurred when updating constituent online presence' + if (isRequestErrorRetryable(statusCode)) { + retryableErrors.push(errorMessage) + } else { + integrationErrors.push(errorMessage) + } + } + } + + if (constituentPhoneData) { + // request has phone data + // get existing phones + try { + const constituentPhoneListResponse = await request( + `${SKY_API_BASE_URL}/constituents/${constituentId}/phones?include_inactive=true`, + { + method: 'get' + } + ) + const constituentPhoneListResults = await constituentPhoneListResponse.json() + + // check phone list for one that matches request + let existingPhone = undefined + if (constituentPhoneListResults.count > 0) { + existingPhone = constituentPhoneListResults.value.filter((result) => { + return result.number === constituentPhoneData.number + }) + } + + if (!existingPhone) { + // new phone + // create phone + await request(`${SKY_API_BASE_URL}/phones`, { + method: 'post', + json: { + ...constituentPhoneData, + constituent_id: constituentId, + // if this is the only phone, make it primary + primary: + constituentPhoneData.primary || + (constituentPhoneData.primary !== false && constituentPhoneListResults.count === 0) + } + }) + } else { + // existing phone + if ( + existingPhone.inactive || + constituentPhoneData.do_not_call !== existingPhone.do_not_call || + constituentPhoneData.primary !== existingPhone.primary || + constituentPhoneData.type !== existingPhone.type + ) { + // request has at least one phone field to update + // update phone + await request(`${SKY_API_BASE_URL}/phones/${existingPhone.id}`, { + method: 'patch', + json: { + ...constituentPhoneData, + inactive: false + } + }) + } + } + } catch (error) { + const statusCode = error?.response?.status + const errorMessage = statusCode + ? `${statusCode} error occurred when updating constituent phone` + : 'Error occurred when updating constituent phone' + if (isRequestErrorRetryable(statusCode)) { + retryableErrors.push(errorMessage) + } else { + integrationErrors.push(errorMessage) + } + } + } + + if (integrationErrors.length > 0) { + throw new IntegrationError( + 'One or more errors occurred when updating existing constituent: ' + + integrationErrors.concat(retryableErrors).join(', '), + 'UPDATE_CONSTITUENT_ERROR', + 500 + ) + } else if (retryableErrors.length > 0) { + throw new RetryableError( + 'One or more errors occurred when updating existing constituent: ' + retryableErrors.join(', ') + ) + } + + 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..4ab2786ec6 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/generated-types.ts @@ -0,0 +1,3 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings {} 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..86a6835fa4 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/index.ts @@ -0,0 +1,55 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-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: {}, + //testAuthentication: (request) => { + testAuthentication: () => { + // Return a request that tests/validates the user's credentials. + // If you do not have a way to validate the authentication fields safely, + // you can remove the `testAuthentication` function, though discouraged. + }, + refreshAccessToken: async (request, { auth }) => { + // Return a request that refreshes the access_token if the API supports it + const res = await request('https://oauth2.sky.blackbaud.com/token', { + method: 'POST', + body: new URLSearchParams({ + refresh_token: auth.refreshToken, + client_id: auth.clientId, + client_secret: auth.clientSecret, + grant_type: 'refresh_token' + }) + }) + + return { accessToken: res.body.access_token } + } + }, + extendRequest({ auth }) { + return { + headers: { + authorization: `Bearer ${auth?.accessToken}` + } + } + }, + + //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/utils.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/utils.ts new file mode 100644 index 0000000000..79263d9c8b --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/utils.ts @@ -0,0 +1,3 @@ +export const isRequestErrorRetryable = (statusCode: number) => { + return statusCode === 401 || statusCode === 429 || statusCode >= 500 +}