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 index 4a48abe88e..3d7321fcd7 100644 --- 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 @@ -1,21 +1,44 @@ // 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: createGift action - all fields 1`] = ` +Object { + "birthdate": Object { + "d": "1", + "m": "2", + "y": "2021", + }, + "first": "D0dEn", + "gender": "D0dEn", + "income": "D0dEn", + "last": "D0dEn", + "lookup_id": "D0dEn", +} +`; -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: createGift action - required fields 1`] = ` +Object { + "lookup_id": "D0dEn", +} +`; -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)", - ], +exports[`Testing snapshot for actions-blackbaud-raisers-edge-nxt destination: createOrUpdateIndividualConstituent action - all fields 1`] = ` +Object { + "birthdate": Object { + "d": "1", + "m": "2", + "y": "2021", }, + "first": "hUXnkT]ixm7mm*HX", + "gender": "hUXnkT]ixm7mm*HX", + "income": "hUXnkT]ixm7mm*HX", + "last": "hUXnkT]ixm7mm*HX", + "lookup_id": "hUXnkT]ixm7mm*HX", +} +`; + +exports[`Testing snapshot for actions-blackbaud-raisers-edge-nxt destination: createOrUpdateIndividualConstituent action - required fields 1`] = ` +Object { + "last": "Smith", + "lookup_id": "hUXnkT]ixm7mm*HX", } `; 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 index 6d3d45a07f..cf6503a644 100644 --- 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 @@ -13,7 +13,9 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, true) - eventData.last = 'Smith' + if (actionSlug === 'createOrUpdateIndividualConstituent') { + eventData.last = 'Smith' + } nock(/.*/).persist().get(/.*/).reply(200, {}) nock(/.*/).persist().patch(/.*/).reply(200, {}) 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 index 30be7c5ea1..2ab79c2916 100644 --- 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 @@ -1,5 +1,21 @@ -import type { RequestClient, ModifiedResponse } from '@segment/actions-core' -import { SKY_API_BASE_URL } from '../constants' +import { IntegrationError, ModifiedResponse, RequestClient, RetryableError } from '@segment/actions-core' +import { SKY_API_CONSTITUENT_URL, SKY_API_GIFTS_URL } from '../constants' +import { + Address, + Constituent, + CreateConstituentResult, + Email, + ExistingAddress, + ExistingConstituentResult, + ExistingEmail, + ExistingOnlinePresence, + ExistingPhone, + Gift, + OnlinePresence, + Phone, + UpdateConstituentResult +} from '../types' +import { filterObjectListByMatchFields, isRequestErrorRetryable } from '../utils' export class BlackbaudSkyApi { request: RequestClient @@ -8,24 +24,94 @@ export class BlackbaudSkyApi { this.request = request } - async getExistingConstituents(searchField: string, searchText: string): Promise { + async searchForConstituents(searchField: string, searchText: string): Promise { return this.request( - `${SKY_API_BASE_URL}/constituents/search?search_field=${searchField}&search_text=${searchText}`, + `${SKY_API_CONSTITUENT_URL}/constituents/search?search_field=${searchField}&search_text=${searchText}`, { method: 'get' } ) } - async createConstituent(constituentData: object): Promise { - return this.request(`${SKY_API_BASE_URL}/constituents`, { + async getExistingConstituent(emailData?: Partial, lookupId?: string): Promise { + let constituentId = undefined + + // default to searching by email + let searchField = 'email_address' + let searchText = emailData?.address || '' + + if (lookupId) { + // search by lookup_id if one is provided + searchField = 'lookup_id' + searchText = lookupId + } + + const constituentSearchResponse = await this.searchForConstituents(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 + } else if (constituentSearchResults.count !== 0) { + // if constituent count is not >= 0, something went wrong + throw new IntegrationError('Unexpected constituent record count for given traits', 'UNEXPECTED_RECORD_COUNT', 500) + } + + return Promise.resolve({ + id: constituentId + }) + } + + async createConstituent(constituentData: Constituent): Promise { + return this.request(`${SKY_API_CONSTITUENT_URL}/constituents`, { method: 'post', json: constituentData }) } - async updateConstituent(constituentId: string, constituentData: object): Promise { - return this.request(`${SKY_API_BASE_URL}/constituents/${constituentId}`, { + async createConstituentWithRelatedObjects( + constituentData: Constituent, + addressData: Partial
, + emailData: Partial, + onlinePresenceData: Partial, + phoneData: Partial + ): Promise { + // 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) + } + // request has last name + // append other data objects to constituent + if (Object.keys(addressData).length > 0) { + constituentData.address = addressData as Address + } + if (Object.keys(emailData).length > 0) { + constituentData.email = emailData as Email + } + if (Object.keys(onlinePresenceData).length > 0) { + constituentData.online_presence = onlinePresenceData as OnlinePresence + } + if (Object.keys(phoneData).length > 0) { + constituentData.phone = phoneData as Phone + } + + // create constituent + const createConstituentResponse = await this.createConstituent(constituentData) + const constituentResult = await createConstituentResponse.json() + + return Promise.resolve({ + id: constituentResult.id + }) + } + + async updateConstituent(constituentId: string, constituentData: Partial): Promise { + return this.request(`${SKY_API_CONSTITUENT_URL}/constituents/${constituentId}`, { method: 'patch', json: constituentData, throwHttpErrors: false @@ -33,28 +119,28 @@ export class BlackbaudSkyApi { } async getConstituentAddressList(constituentId: string): Promise { - return this.request(`${SKY_API_BASE_URL}/constituents/${constituentId}/addresses?include_inactive=true`, { + return this.request(`${SKY_API_CONSTITUENT_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`, { + async createConstituentAddress(constituentId: string, addressData: Address): Promise { + return this.request(`${SKY_API_CONSTITUENT_URL}/addresses`, { method: 'post', json: { - ...constituentAddressData, + ...addressData, constituent_id: constituentId }, throwHttpErrors: false }) } - async updateConstituentAddressById(addressId: string, constituentAddressData: object): Promise { - return this.request(`${SKY_API_BASE_URL}/addresses/${addressId}`, { + async updateConstituentAddressById(addressId: string, addressData: Address): Promise { + return this.request(`${SKY_API_CONSTITUENT_URL}/addresses/${addressId}`, { method: 'patch', json: { - ...constituentAddressData, + ...addressData, inactive: false }, throwHttpErrors: false @@ -62,28 +148,31 @@ export class BlackbaudSkyApi { } async getConstituentEmailList(constituentId: string): Promise { - return this.request(`${SKY_API_BASE_URL}/constituents/${constituentId}/emailaddresses?include_inactive=true`, { - method: 'get', - throwHttpErrors: false - }) + return this.request( + `${SKY_API_CONSTITUENT_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`, { + async createConstituentEmail(constituentId: string, emailData: Email): Promise { + return this.request(`${SKY_API_CONSTITUENT_URL}/emailaddresses`, { method: 'post', json: { - ...constituentEmailData, + ...emailData, constituent_id: constituentId }, throwHttpErrors: false }) } - async updateConstituentEmailById(emailId: string, constituentEmailData: object): Promise { - return this.request(`${SKY_API_BASE_URL}/emailaddresses/${emailId}`, { + async updateConstituentEmailById(emailId: string, emailData: Email): Promise { + return this.request(`${SKY_API_CONSTITUENT_URL}/emailaddresses/${emailId}`, { method: 'patch', json: { - ...constituentEmailData, + ...emailData, inactive: false }, throwHttpErrors: false @@ -91,20 +180,23 @@ export class BlackbaudSkyApi { } async getConstituentOnlinePresenceList(constituentId: string): Promise { - return this.request(`${SKY_API_BASE_URL}/constituents/${constituentId}/onlinepresences?include_inactive=true`, { - method: 'get', - throwHttpErrors: false - }) + return this.request( + `${SKY_API_CONSTITUENT_URL}/constituents/${constituentId}/onlinepresences?include_inactive=true`, + { + method: 'get', + throwHttpErrors: false + } + ) } async createConstituentOnlinePresence( constituentId: string, - constituentOnlinePresenceData: object + onlinePresenceData: OnlinePresence ): Promise { - return this.request(`${SKY_API_BASE_URL}/onlinepresences`, { + return this.request(`${SKY_API_CONSTITUENT_URL}/onlinepresences`, { method: 'post', json: { - ...constituentOnlinePresenceData, + ...onlinePresenceData, constituent_id: constituentId }, throwHttpErrors: false @@ -113,12 +205,12 @@ export class BlackbaudSkyApi { async updateConstituentOnlinePresenceById( onlinePresenceId: string, - constituentOnlinePresenceData: object + onlinePresenceData: OnlinePresence ): Promise { - return this.request(`${SKY_API_BASE_URL}/onlinepresences/${onlinePresenceId}`, { + return this.request(`${SKY_API_CONSTITUENT_URL}/onlinepresences/${onlinePresenceId}`, { method: 'patch', json: { - ...constituentOnlinePresenceData, + ...onlinePresenceData, inactive: false }, throwHttpErrors: false @@ -126,31 +218,339 @@ export class BlackbaudSkyApi { } async getConstituentPhoneList(constituentId: string): Promise { - return this.request(`${SKY_API_BASE_URL}/constituents/${constituentId}/phones?include_inactive=true`, { + return this.request(`${SKY_API_CONSTITUENT_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`, { + async createConstituentPhone(constituentId: string, phoneData: Phone): Promise { + return this.request(`${SKY_API_CONSTITUENT_URL}/phones`, { method: 'post', json: { - ...constituentPhoneData, + ...phoneData, constituent_id: constituentId }, throwHttpErrors: false }) } - async updateConstituentPhoneById(phoneId: string, constituentPhoneData: object): Promise { - return this.request(`${SKY_API_BASE_URL}/phones/${phoneId}`, { + async updateConstituentWithRelatedObjects( + constituentId: string, + constituentData: Partial, + addressData: Partial
, + emailData: Partial, + onlinePresenceData: Partial, + phoneData: Partial + ): Promise { + // 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 this.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(addressData).length > 0) { + // request has address data + // get existing addresses + const getConstituentAddressListResponse = await this.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, addressData, [ + 'address_lines', + 'city', + 'postal_code', + 'state' + ]) as ExistingAddress | undefined + } + + if (!existingAddress) { + // new address + // if this is the only address, make it primary + if (addressData.primary !== false && constituentAddressListResults.count === 0) { + addressData.primary = true + } + // create address + const createConstituentAddressResponse = await this.createConstituentAddress( + constituentId, + addressData as Address + ) + if (createConstituentAddressResponse.status !== 200) { + updateAddressErrorCode = createConstituentAddressResponse.status + } + } else { + // existing address + if ( + existingAddress.inactive || + (addressData.do_not_mail !== undefined && addressData.do_not_mail !== existingAddress.do_not_mail) || + (addressData.primary !== undefined && + addressData.primary && + addressData.primary !== existingAddress.primary) || + addressData.type !== existingAddress.type + ) { + // request has at least one address field to update + // update address + const updateConstituentAddressByIdResponse = await this.updateConstituentAddressById( + existingAddress.id, + addressData as Address + ) + 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(emailData).length > 0) { + // request has email data + // get existing addresses + const getConstituentEmailListResponse = await this.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, emailData, ['address']) as + | ExistingEmail + | undefined + } + + if (!existingEmail) { + // new email + // if this is the only email, make it primary + if (emailData.primary !== false && constituentEmailListResults.count === 0) { + emailData.primary = true + } + // create email + const createConstituentEmailResponse = await this.createConstituentEmail(constituentId, emailData as Email) + if (createConstituentEmailResponse.status !== 200) { + updateEmailErrorCode = createConstituentEmailResponse.status + } + } else { + // existing email + if ( + existingEmail.inactive || + (emailData.do_not_email !== undefined && emailData.do_not_email !== existingEmail.do_not_email) || + (emailData.primary !== undefined && emailData.primary && emailData.primary !== existingEmail.primary) || + emailData.type !== existingEmail.type + ) { + // request has at least one email field to update + // update email + const updateConstituentEmailByIdResponse = await this.updateConstituentEmailById( + existingEmail.id, + emailData as Email + ) + 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(onlinePresenceData).length > 0) { + // request has online presence data + // get existing online presences + const getConstituentOnlinePresenceListResponse = await this.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, + onlinePresenceData, + ['address'] + ) as ExistingOnlinePresence | undefined + } + + if (!existingOnlinePresence) { + // new online presence + // if this is the only online presence, make it primary + if (onlinePresenceData.primary !== false && constituentOnlinePresenceListResults.count === 0) { + onlinePresenceData.primary = true + } + // create online presence + const createConstituentOnlinePresenceResponse = await this.createConstituentOnlinePresence( + constituentId, + onlinePresenceData as OnlinePresence + ) + if (createConstituentOnlinePresenceResponse.status !== 200) { + updateOnlinePresenceErrorCode = createConstituentOnlinePresenceResponse.status + } + } else { + // existing online presence + if ( + existingOnlinePresence.inactive || + (onlinePresenceData.primary !== undefined && + onlinePresenceData.primary !== existingOnlinePresence.primary) || + onlinePresenceData.type !== existingOnlinePresence.type + ) { + // request has at least one online presence field to update + // update online presence + const updateConstituentOnlinePresenceByIdResponse = await this.updateConstituentOnlinePresenceById( + existingOnlinePresence.id, + onlinePresenceData as OnlinePresence + ) + 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(phoneData).length > 0) { + // request has phone data + // get existing phones + const getConstituentPhoneListResponse = await this.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, phoneData, [ + 'int:number' + ]) as ExistingPhone | undefined + } + + if (!existingPhone) { + // new phone + // if this is the only phone, make it primary + if (phoneData.primary !== false && constituentPhoneListResults.count === 0) { + phoneData.primary = true + } + // create phone + const createConstituentPhoneResponse = await this.createConstituentPhone(constituentId, phoneData as Phone) + if (createConstituentPhoneResponse.status !== 200) { + updatePhoneErrorCode = createConstituentPhoneResponse.status + } + } else { + // existing phone + if ( + existingPhone.inactive || + (phoneData.do_not_call !== undefined && phoneData.do_not_call !== existingPhone.do_not_call) || + (phoneData.primary !== undefined && phoneData.primary !== existingPhone.primary) || + phoneData.type !== existingPhone.type + ) { + // request has at least one phone field to update + // update phone + const updateConstituentPhoneByIdResponse = await this.updateConstituentPhoneById( + existingPhone.id, + phoneData as Phone + ) + 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 Promise.resolve({ + id: constituentId + }) + } + + async updateConstituentPhoneById(phoneId: string, phoneData: Partial): Promise { + return this.request(`${SKY_API_CONSTITUENT_URL}/phones/${phoneId}`, { method: 'patch', json: { - ...constituentPhoneData, + ...phoneData, inactive: false }, throwHttpErrors: false }) } + + async createGift(giftData: Gift): Promise { + return this.request(`${SKY_API_GIFTS_URL}/gifts`, { + method: 'post', + json: giftData + }) + } } 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 index 2edb2bac05..9008e60545 100644 --- 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 @@ -1,2 +1,4 @@ -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' +export const SKY_API_BASE_URL = 'https://api.sky.blackbaud.com' +export const SKY_API_CONSTITUENT_URL = `${SKY_API_BASE_URL}/constituent/v1` +export const SKY_API_GIFTS_URL = `${SKY_API_BASE_URL}/gift/v1` diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..264ac2f746 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for BlackbaudRaisersEdgeNxt's createGift destination action: all fields 1`] = ` +Object { + "birthdate": Object { + "d": "1", + "m": "2", + "y": "2021", + }, + "first": "O#lbz", + "gender": "O#lbz", + "income": "O#lbz", + "last": "O#lbz", + "lookup_id": "O#lbz", +} +`; + +exports[`Testing snapshot for BlackbaudRaisersEdgeNxt's createGift destination action: required fields 1`] = ` +Object { + "lookup_id": "O#lbz", +} +`; diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/__tests__/index.test.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/__tests__/index.test.ts new file mode 100644 index 0000000000..0607d278a3 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/__tests__/index.test.ts @@ -0,0 +1,84 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, IntegrationError } from '@segment/actions-core' +import Destination from '../../index' +import { SKY_API_CONSTITUENT_URL, SKY_API_GIFTS_URL } from '../../constants' +import { trackEventData, trackEventDataNewConstituent, trackEventDataNoConstituent } from '../fixtures' + +const testDestination = createTestIntegration(Destination) + +const mapping = { + constituent_email: { + address: { + '@path': '$.properties.email' + }, + type: { + '@path': '$.properties.emailType' + } + }, + constituent_id: { + '@path': '$.properties.constituentId' + }, + fund_id: { + '@path': '$.properties.fundId' + }, + payment_method: { + '@path': '$.properties.paymentMethod' + } +} + +describe('BlackbaudRaisersEdgeNxt.createGift', () => { + test('should create a new gift successfully', async () => { + const event = createTestEvent(trackEventData) + + nock(SKY_API_GIFTS_URL).post('/gifts').reply(200, { + id: '1000' + }) + + await expect( + testDestination.testAction('createGift', { + event, + mapping, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) + + test('should create a new constituent and associate gift with it', async () => { + const event = createTestEvent(trackEventDataNewConstituent) + + nock(SKY_API_CONSTITUENT_URL) + .get('/constituents/search?search_field=email_address&search_text=john@example.biz') + .reply(200, { + count: 0, + value: [] + }) + + nock(SKY_API_CONSTITUENT_URL).post('/constituents').reply(200, { + id: '456' + }) + + nock(SKY_API_GIFTS_URL).post('/gifts').reply(200, { + id: '1001' + }) + + await expect( + testDestination.testAction('createGift', { + event, + mapping, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) + + test('should throw an IntegrationError if no constituent provided', async () => { + const event = createTestEvent(trackEventDataNoConstituent) + + await expect( + testDestination.testAction('createGift', { + event, + mapping, + useDefaultMappings: true + }) + ).rejects.toThrowError(new IntegrationError('Missing constituent_id value', 'MISSING_REQUIRED_FIELD', 400)) + }) +}) diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..2fea0211a8 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/__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 = 'createGift' +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.date = '2023-01-01T01:00:00Z' + + 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/createGift/fixtures.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/fixtures.ts new file mode 100644 index 0000000000..341dd86602 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/fixtures.ts @@ -0,0 +1,65 @@ +import { SegmentEvent } from '@segment/actions-core' + +// track events +export const trackEventData: Partial = { + type: 'track', + properties: { + constituentId: '123', + fundId: '1', + paymentMethod: 'CreditCard', + revenue: 100 + } +} + +export const trackEventDataNewConstituent: Partial = { + type: 'track', + properties: { + email: 'john@example.biz', + emailType: 'Personal', + firstName: 'John', + lastName: 'Doe', + fundId: '1', + paymentMethod: 'CreditCard', + revenue: 100 + } +} + +export const trackEventDataNoConstituent: Partial = { + type: 'track', + properties: { + fundId: '1', + paymentMethod: 'CreditCard', + revenue: 100 + } +} + +// gift data +export const giftPayload = { + amount: { + value: 100 + }, + constituent_id: '123', + gift_splits: [ + { + amount: { + value: 100 + }, + fund_id: '1' + } + ], + payments: [ + { + payment_method: 'CreditCard' + } + ] +} + +// constituent data +export const constituentPayload = { + email: { + address: 'john@example.biz', + type: 'Personal' + }, + first: 'John', + last: 'Doe' +} diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/generated-types.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/generated-types.ts new file mode 100644 index 0000000000..2ec3f4f67b --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/generated-types.ts @@ -0,0 +1,170 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The gift acknowledgement. + */ + acknowledgement?: { + /** + * The date associated with the acknowledgement in ISO-8601 format. + */ + date?: string | number + /** + * The status of the acknowledgement. Available values are: ACKNOWLEDGED, NEEDSACKNOWLEDGEMENT, and DONOTACKNOWLEDGE. + */ + status?: string + } + /** + * The monetary amount of the gift in number format, e.g. 12.34 + */ + amount: number + /** + * The check date in ISO-8601 format. + */ + check_date?: string | number + /** + * The check number in string format, e.g. "12345" + */ + check_number?: string + /** + * The gift date in ISO-8601 format. + */ + date?: string | number + /** + * The ID of the fund associated with the gift. + */ + fund_id: string + /** + * The status of the gift. Available values are "Active", "Held", "Terminated", "Completed", and "Cancelled". + */ + gift_status?: string + /** + * Indicates whether the gift is anonymous. + */ + is_anonymous?: boolean + /** + * The recurring gift associated with the payment being added. When adding a recurring gift payment, a linked_gifts field must be included as an array of strings with the ID of the recurring gift to which the payment is linked. + */ + linked_gifts?: string[] + /** + * The organization-defined identifier for the gift. + */ + lookup_id?: string + /** + * The payment method. Available values are "Cash", "CreditCard", "PersonalCheck", "DirectDebit", "Other", "PayPal", or "Venmo". + */ + payment_method: string + /** + * The date that the gift was posted to general ledger in ISO-8601 format. + */ + post_date?: string | number + /** + * The general ledger post status of the gift. Available values are "Posted", "NotPosted", and "DoNotPost". + */ + post_status?: string + /** + * The gift receipt. + */ + receipt?: { + /** + * The date that the gift was receipted. Includes an offset from UTC in ISO-8601 format: 1969-11-21T10:29:43. + */ + date?: string | number + /** + * The receipt status of the gift. Available values are RECEIPTED, NEEDSRECEIPT, and DONOTRECEIPT. + */ + status?: string + } + /** + * The recurring gift schedule. When adding a recurring gift, a schedule is required. + */ + recurring_gift_schedule?: { + /** + * Date the recurring gift should end in ISO-8601 format. + */ + end_date?: string | number + /** + * Installment frequency of the recurring gift to add. Available values are WEEKLY, EVERY_TWO_WEEKS, EVERY_FOUR_WEEKS, MONTHLY, QUARTERLY, ANNUALLY. + */ + frequency?: string + /** + * Date the recurring gift should start in ISO-8601 format. + */ + start_date?: string | number + } + /** + * The subtype of the gift. + */ + subtype?: string + /** + * The gift type. Available values are "Donation", "Other", "GiftInKind", "RecurringGift", and "RecurringGiftPayment". + */ + type?: string + /** + * The constituent's address. + */ + constituent_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. + */ + constituent_birthdate?: string | number + /** + * The ID of the constituent. + */ + constituent_id?: string + /** + * The constituent's email address. + */ + constituent_email?: { + address?: string + do_not_email?: boolean + primary?: boolean + type?: string + } + /** + * The constituent's first name up to 50 characters. + */ + constituent_first?: string + /** + * The constituent's gender. + */ + constituent_gender?: string + /** + * The constituent's income. + */ + constituent_income?: string + /** + * The constituent's last name up to 100 characters. This is required to create a constituent. + */ + constituent_last?: string + /** + * The organization-defined identifier for the constituent. + */ + constituent_lookup_id?: string + /** + * The constituent's online presence. + */ + constituent_online_presence?: { + address?: string + primary?: boolean + type?: string + } + /** + * The constituent's phone number. + */ + constituent_phone?: { + do_not_call?: boolean + number?: string + primary?: boolean + type?: string + } +} diff --git a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/index.ts b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/index.ts new file mode 100644 index 0000000000..6b3f70dd29 --- /dev/null +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/createGift/index.ts @@ -0,0 +1,261 @@ +import { ActionDefinition, ExecuteInput, InputField, IntegrationError, RequestFn } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { + fields as createOrUpdateIndividualConstituentFields, + perform as performCreateOrUpdateIndividualConstituent +} from '../createOrUpdateIndividualConstituent' +import { BlackbaudSkyApi } from '../api' +import { Gift, StringIndexedObject } from '../types' +import { buildConstituentPayloadFromPayload, buildGiftDataFromPayload } from '../utils' + +const fields: Record = { + acknowledgement: { + label: 'Acknowledgement', + description: 'The gift acknowledgement.', + type: 'object', + defaultObjectUI: 'keyvalue:only', + additionalProperties: false, + properties: { + date: { + label: 'Date', + type: 'datetime', + description: 'The date associated with the acknowledgement in ISO-8601 format.' + }, + status: { + label: 'Status', + type: 'string', + description: + 'The status of the acknowledgement. Available values are: ACKNOWLEDGED, NEEDSACKNOWLEDGEMENT, and DONOTACKNOWLEDGE.' + } + }, + default: { + date: { + '@path': '$.properties.acknowledgement.date' + }, + status: { + '@path': '$.properties.acknowledgement.status' + } + } + }, + amount: { + label: 'Gift Amount', + description: 'The monetary amount of the gift in number format, e.g. 12.34', + type: 'number', + required: true, + default: { + '@path': '$.properties.revenue' + } + }, + check_date: { + label: 'Check Date', + description: 'The check date in ISO-8601 format.', + type: 'datetime' + }, + check_number: { + label: 'Check Number', + description: 'The check number in string format, e.g. "12345"', + type: 'string' + }, + date: { + label: 'Gift Date', + description: 'The gift date in ISO-8601 format.', + type: 'datetime' + }, + fund_id: { + label: 'Fund ID', + description: 'The ID of the fund associated with the gift.', + type: 'string', + required: true + }, + gift_status: { + label: 'Gift Status', + description: + 'The status of the gift. Available values are "Active", "Held", "Terminated", "Completed", and "Cancelled".', + type: 'string', + choices: [ + { label: 'Active', value: 'Active' }, + { label: 'Held', value: 'Held' }, + { label: 'Terminated', value: 'Terminated' }, + { label: 'Completed', value: 'Completed' }, + { label: 'Cancelled', value: 'Cancelled' } + ] + }, + is_anonymous: { + label: 'Is Anonymous', + description: 'Indicates whether the gift is anonymous.', + type: 'boolean' + }, + linked_gifts: { + label: 'Linked Gifts', + description: + 'The recurring gift associated with the payment being added. When adding a recurring gift payment, a linked_gifts field must be included as an array of strings with the ID of the recurring gift to which the payment is linked.', + type: 'string', + multiple: true + }, + lookup_id: { + label: 'Lookup ID', + description: 'The organization-defined identifier for the gift.', + type: 'string' + }, + payment_method: { + label: 'Payment Method', + description: + 'The payment method. Available values are "Cash", "CreditCard", "PersonalCheck", "DirectDebit", "Other", "PayPal", or "Venmo".', + type: 'string', + required: true, + choices: [ + { label: 'Cash', value: 'Cash' }, + { label: 'Credit Card', value: 'CreditCard' }, + { label: 'Personal Check', value: 'PersonalCheck' }, + { label: 'Direct Debit', value: 'DirectDebit' }, + { label: 'Other', value: 'Other' }, + { label: 'PayPal', value: 'PayPal' }, + { label: 'Venmo', value: 'Venmo' } + ] + }, + post_date: { + label: 'Post Date', + description: 'The date that the gift was posted to general ledger in ISO-8601 format.', + type: 'datetime' + }, + post_status: { + label: 'Post Status', + description: + 'The general ledger post status of the gift. Available values are "Posted", "NotPosted", and "DoNotPost".', + type: 'string', + default: 'NotPosted', + choices: [ + { label: 'Posted', value: 'Posted' }, + { label: 'Not Posted', value: 'NotPosted' }, + { label: 'Do Not Post', value: 'DoNotPost' } + ] + }, + receipt: { + label: 'Receipt', + description: 'The gift receipt.', + type: 'object', + defaultObjectUI: 'keyvalue:only', + additionalProperties: false, + properties: { + date: { + label: 'Date', + type: 'datetime', + description: + 'The date that the gift was receipted. Includes an offset from UTC in ISO-8601 format: 1969-11-21T10:29:43.' + }, + status: { + label: 'Status', + type: 'string', + description: 'The receipt status of the gift. Available values are RECEIPTED, NEEDSRECEIPT, and DONOTRECEIPT.' + } + }, + default: { + date: { + '@path': '$.properties.receipt.date' + }, + status: { + '@path': '$.properties.receipt.status' + } + } + }, + recurring_gift_schedule: { + label: 'Recurring Gift Schedule', + description: 'The recurring gift schedule. When adding a recurring gift, a schedule is required.', + type: 'object', + defaultObjectUI: 'keyvalue:only', + additionalProperties: false, + properties: { + end_date: { + label: 'End Date', + type: 'datetime', + description: 'Date the recurring gift should end in ISO-8601 format.' + }, + frequency: { + label: 'Frequency', + type: 'string', + description: + 'Installment frequency of the recurring gift to add. Available values are WEEKLY, EVERY_TWO_WEEKS, EVERY_FOUR_WEEKS, MONTHLY, QUARTERLY, ANNUALLY.' + }, + start_date: { + label: 'Start Date', + type: 'datetime', + description: 'Date the recurring gift should start in ISO-8601 format.' + } + }, + default: { + end_date: { + '@path': '$.properties.recurring_gift_schedule.end_date' + }, + frequency: { + '@path': '$.properties.recurring_gift_schedule.frequency' + }, + start_date: { + '@path': '$.properties.recurring_gift_schedule.start_date' + } + } + }, + subtype: { + label: 'Subtype', + description: 'The subtype of the gift.', + type: 'string' + }, + type: { + label: 'Type', + description: + 'The gift type. Available values are "Donation", "Other", "GiftInKind", "RecurringGift", and "RecurringGiftPayment".', + type: 'string', + default: 'Donation', + choices: [ + { label: 'Donation', value: 'Donation' }, + { label: 'Other', value: 'Other' }, + { label: 'GiftInKind', value: 'GiftInKind' }, + { label: 'RecurringGift', value: 'RecurringGift' }, + { label: 'RecurringGiftPayment', value: 'RecurringGiftPayment' } + ] + } +} + +Object.keys(createOrUpdateIndividualConstituentFields).forEach((key: string) => { + let fieldKey = 'constituent_' + key + let fieldLabel = 'Constituent ' + createOrUpdateIndividualConstituentFields[key].label + if (key === 'constituent_id') { + fieldKey = key + fieldLabel = createOrUpdateIndividualConstituentFields[key].label + } + fields[fieldKey] = { + ...createOrUpdateIndividualConstituentFields[key], + label: fieldLabel + } +}) + +const perform: RequestFn = async (request, { settings, payload }) => { + const constituentPayload = buildConstituentPayloadFromPayload(payload as StringIndexedObject) + + let constituentId = payload.constituent_id + if (Object.keys(constituentPayload).length > 0) { + const createOrUpdateIndividualConstituentResponse = await performCreateOrUpdateIndividualConstituent(request, { + settings: settings, + payload: constituentPayload + } as ExecuteInput) + constituentId = createOrUpdateIndividualConstituentResponse.id + } else if (constituentId === undefined) { + throw new IntegrationError('Missing constituent_id value', 'MISSING_REQUIRED_FIELD', 400) + } + + const blackbaudSkyApiClient: BlackbaudSkyApi = new BlackbaudSkyApi(request) + + const giftData = buildGiftDataFromPayload(constituentId as string, payload) as Gift + + return blackbaudSkyApiClient.createGift(giftData) +} + +const action: ActionDefinition = { + title: 'Create Gift', + description: "Create a Gift record in Raiser's Edge NXT.", + defaultSubscription: 'type = "track" and event = "Donation Completed"', + fields, + perform +} + +export default action 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 index 67d00bfa49..8aa3dba1e4 100644 --- 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 @@ -1,21 +1,23 @@ // 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)", - ], +exports[`Testing snapshot for BlackbaudRaisersEdgeNxt's createOrUpdateIndividualConstituent destination action: all fields 1`] = ` +Object { + "birthdate": Object { + "d": "1", + "m": "2", + "y": "2021", }, + "first": "H*Z39ROa", + "gender": "H*Z39ROa", + "income": "H*Z39ROa", + "last": "H*Z39ROa", + "lookup_id": "H*Z39ROa", +} +`; + +exports[`Testing snapshot for BlackbaudRaisersEdgeNxt's createOrUpdateIndividualConstituent destination action: required fields 1`] = ` +Object { + "last": "Smith", + "lookup_id": "H*Z39ROa", } `; 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 index 036aa2cc5e..7f989a4157 100644 --- 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 @@ -1,7 +1,7 @@ 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 { SKY_API_CONSTITUENT_URL } from '../../constants' import { identifyEventData, identifyEventDataNoEmail, @@ -43,7 +43,7 @@ const mapping = { } }, lookup_id: { - '@path': '$.traits.lookup_id' + '@path': '$.traits.lookupId' }, online_presence: { address: { @@ -67,14 +67,14 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { test('should create a new constituent successfully', async () => { const event = createTestEvent(identifyEventData) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_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, { + nock(SKY_API_CONSTITUENT_URL).post('/constituents').reply(200, { id: '123' }) @@ -90,7 +90,7 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { 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, { + nock(SKY_API_CONSTITUENT_URL).post('/constituents').reply(200, { id: '456' }) @@ -106,7 +106,7 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { test('should update an existing constituent matched by email successfully', async () => { const event = createTestEvent(identifyEventDataUpdated) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/search?search_field=email_address&search_text=john@example.biz') .reply(200, { count: 1, @@ -121,9 +121,9 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).patch('/constituents/123').reply(200) + nock(SKY_API_CONSTITUENT_URL).patch('/constituents/123').reply(200) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/addresses?include_inactive=true') .reply(200, { count: 1, @@ -146,11 +146,11 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).post('/addresses').reply(200, { + nock(SKY_API_CONSTITUENT_URL).post('/addresses').reply(200, { id: '1001' }) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/emailaddresses?include_inactive=true') .reply(200, { count: 1, @@ -169,9 +169,9 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).patch('/emailaddresses/2000').reply(200) + nock(SKY_API_CONSTITUENT_URL).patch('/emailaddresses/2000').reply(200) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/onlinepresences?include_inactive=true') .reply(200, { count: 1, @@ -187,11 +187,11 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).post('/onlinepresences').reply(200, { + nock(SKY_API_CONSTITUENT_URL).post('/onlinepresences').reply(200, { id: '3001' }) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/phones?include_inactive=true') .reply(200, { count: 1, @@ -208,7 +208,7 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).post('/phones').reply(200, { + nock(SKY_API_CONSTITUENT_URL).post('/phones').reply(200, { id: '4001' }) @@ -224,7 +224,7 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { test('should update an existing constituent matched by lookup_id successfully', async () => { const event = createTestEvent(identifyEventDataWithLookupId) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/search?search_field=lookup_id&search_text=abcd1234') .reply(200, { count: 1, @@ -239,9 +239,9 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).patch('/constituents/123').reply(200) + nock(SKY_API_CONSTITUENT_URL).patch('/constituents/123').reply(200) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/addresses?include_inactive=true') .reply(200, { count: 2, @@ -279,11 +279,11 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).post('/addresses').reply(200, { + nock(SKY_API_CONSTITUENT_URL).post('/addresses').reply(200, { id: '1002' }) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/emailaddresses?include_inactive=true') .reply(200, { count: 1, @@ -302,11 +302,11 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).post('/emailaddresses').reply(200, { + nock(SKY_API_CONSTITUENT_URL).post('/emailaddresses').reply(200, { id: '2001' }) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/onlinepresences?include_inactive=true') .reply(200, { count: 1, @@ -322,7 +322,7 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/phones?include_inactive=true') .reply(200, { count: 1, @@ -351,7 +351,7 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { test('should throw an IntegrationError if multiple records matched', async () => { const event = createTestEvent(identifyEventData) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/search?search_field=email_address&search_text=john@example.biz') .reply(200, { count: 2, @@ -387,7 +387,7 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { test('should throw an IntegrationError if new constituent has no last name', async () => { const event = createTestEvent(identifyEventDataNoLastName) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/search?search_field=email_address&search_text=john@example.org') .reply(200, { count: 0, @@ -406,7 +406,7 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { 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) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/search?search_field=email_address&search_text=john@example.biz') .reply(200, { count: 1, @@ -421,9 +421,9 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).patch('/constituents/123').reply(200) + nock(SKY_API_CONSTITUENT_URL).patch('/constituents/123').reply(200) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/addresses?include_inactive=true') .reply(200, { count: 1, @@ -446,11 +446,11 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).post('/addresses').reply(200, { + nock(SKY_API_CONSTITUENT_URL).post('/addresses').reply(200, { id: '1001' }) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/emailaddresses?include_inactive=true') .reply(200, { count: 1, @@ -469,9 +469,9 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).patch('/emailaddresses/2000').reply(200) + nock(SKY_API_CONSTITUENT_URL).patch('/emailaddresses/2000').reply(200) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/onlinepresences?include_inactive=true') .reply(200, { count: 1, @@ -487,9 +487,9 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).post('/onlinepresences').reply(400) + nock(SKY_API_CONSTITUENT_URL).post('/onlinepresences').reply(400) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/phones?include_inactive=true') .reply(200, { count: 1, @@ -506,7 +506,7 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).post('/phones').reply(200, { + nock(SKY_API_CONSTITUENT_URL).post('/phones').reply(200, { id: '4001' }) @@ -528,7 +528,7 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { 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) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/search?search_field=email_address&search_text=john@example.biz') .reply(200, { count: 1, @@ -543,9 +543,9 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).patch('/constituents/123').reply(200) + nock(SKY_API_CONSTITUENT_URL).patch('/constituents/123').reply(200) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/onlinepresences?include_inactive=true') .reply(200, { count: 1, @@ -561,7 +561,7 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL) + nock(SKY_API_CONSTITUENT_URL) .get('/constituents/123/addresses?include_inactive=true') .reply(200, { count: 1, @@ -584,11 +584,11 @@ describe('BlackbaudRaisersEdgeNxt.createOrUpdateIndividualConstituent', () => { ] }) - nock(SKY_API_BASE_URL).post('/addresses').reply(200, { + nock(SKY_API_CONSTITUENT_URL).post('/addresses').reply(200, { id: '1001' }) - nock(SKY_API_BASE_URL).get('/constituents/123/emailaddresses?include_inactive=true').reply(429) + nock(SKY_API_CONSTITUENT_URL).get('/constituents/123/emailaddresses?include_inactive=true').reply(429) await expect( testDestination.testAction('createOrUpdateIndividualConstituent', { 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 index 96d7a7820e..39fadad17b 100644 --- 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 @@ -69,7 +69,7 @@ export const identifyEventDataWithLookupId: Partial = { birthday: '2001-01-01T01:01:01-05:00', email: 'john.doe@aol.com', emailType: 'Personal', - lookup_id: 'abcd1234' + lookupId: 'abcd1234' } } 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 index d43337cd2f..10c45981cf 100644 --- 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 @@ -18,6 +18,10 @@ export interface Payload { * The constituent's birthdate. */ birthdate?: string | number + /** + * The ID of the constituent. + */ + constituent_id?: string /** * The constituent's email address. */ 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 index c523538b77..f9fdec2f25 100644 --- 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 @@ -1,751 +1,367 @@ -import { ActionDefinition, IntegrationError, RetryableError } from '@segment/actions-core' +import { ActionDefinition, InputField, RequestFn } 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' - } +import { Address, Constituent, Email, OnlinePresence, Phone } from '../types' +import { splitConstituentPayload } from '../utils' + +export const fields: Record = { + address: { + label: 'Address', + description: "The constituent's address.", + type: 'object', + properties: { + address_lines: { + label: 'Address Lines', + 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: '' + 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' } }, - birthdate: { - label: 'Birthdate', - description: "The constituent's birthdate.", - type: 'datetime', - default: { + default: { + address_lines: { '@if': { exists: { - '@path': '$.traits.birthday' + '@path': '$.traits.address.street' }, then: { - '@path': '$.traits.birthday' + '@path': '$.traits.address.street' }, else: { - '@path': '$.properties.birthday' + '@path': '$.properties.address.street' } } - } - }, - 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' - } + city: { + '@if': { + exists: { + '@path': '$.traits.address.city' + }, + then: { + '@path': '$.traits.address.city' + }, + else: { + '@path': '$.properties.address.city' } - }, - do_not_email: '', - primary: '', - type: '' - } - }, - first: { - label: 'First Name', - description: "The constituent's first name up to 50 characters.", - type: 'string', - default: { + } + }, + country: { '@if': { exists: { - '@path': '$.traits.firstName' + '@path': '$.traits.address.country' }, then: { - '@path': '$.traits.firstName' + '@path': '$.traits.address.country' }, else: { - '@path': '$.properties.firstName' + '@path': '$.properties.address.country' } } - } - }, - gender: { - label: 'Gender', - description: "The constituent's gender.", - type: 'string', - default: { + }, + do_not_mail: '', + postal_code: { '@if': { exists: { - '@path': '$.traits.gender' + '@path': '$.traits.address.postalCode' }, then: { - '@path': '$.traits.gender' + '@path': '$.traits.address.postalCode' }, else: { - '@path': '$.properties.gender' + '@path': '$.properties.address.postalCode' } } - } - }, - 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: { + }, + primary: '', + state: { '@if': { exists: { - '@path': '$.traits.lastName' + '@path': '$.traits.address.state' }, then: { - '@path': '$.traits.lastName' + '@path': '$.traits.address.state' }, else: { - '@path': '$.properties.lastName' + '@path': '$.properties.address.state' } } - } - }, - 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' + }, + type: '' + } + }, + birthdate: { + label: 'Birthdate', + description: "The constituent's birthdate.", + type: 'datetime', + default: { + '@if': { + exists: { + '@path': '$.traits.birthday' }, - primary: { - label: 'Is Primary', - type: 'boolean' + then: { + '@path': '$.traits.birthday' }, - type: { - label: 'Online Presence Type', - type: 'string' + else: { + '@path': '$.properties.birthday' } + } + } + }, + constituent_id: { + label: 'Constituent ID', + description: 'The ID of the constituent.', + type: 'string' + }, + email: { + label: 'Email', + description: "The constituent's email address.", + type: 'object', + properties: { + address: { + label: 'Email Address', + type: 'string' }, - default: { - address: { - '@if': { - exists: { - '@path': '$.traits.website' - }, - then: { - '@path': '$.traits.website' - }, - else: { - '@path': '$.properties.website' - } - } - }, - primary: '', - type: '' + do_not_email: { + label: 'Do Not Email', + type: 'boolean' + }, + primary: { + label: 'Is Primary', + type: 'boolean' + }, + type: { + label: 'Email Type', + type: 'string' } }, - 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: { + address: { + '@if': { + exists: { + '@path': '$.traits.email' + }, + then: { + '@path': '$.traits.email' + }, + else: { + '@path': '$.properties.email' + } } }, - default: { - do_not_call: '', - number: { - '@if': { - exists: { - '@path': '$.traits.phone' - }, - then: { - '@path': '$.traits.phone' - }, - else: { - '@path': '$.properties.phone' - } - } - }, - primary: '', - type: '' - } + do_not_email: '', + 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 + 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' } - - // 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) - } + } + }, + gender: { + label: 'Gender', + description: "The constituent's gender.", + type: 'string', + default: { + '@if': { + exists: { + '@path': '$.traits.gender' + }, + then: { + '@path': '$.traits.gender' + }, + else: { + '@path': '$.properties.gender' } } - - 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) - } + } + }, + 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' } } - - 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) - } - } + } + }, + lookup_id: { + label: 'Lookup ID', + description: 'The organization-defined identifier for the constituent.', + type: 'string' + }, + 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' } - - 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) + }, + 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' } - - 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 - } - } + }, + default: { + do_not_call: '', + number: { + '@if': { + exists: { + '@path': '$.traits.phone' + }, + then: { + '@path': '$.traits.phone' + }, + else: { + '@path': '$.properties.phone' } } + }, + primary: '', + type: '' + } + } +} - 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) - } - } - } +export const perform: RequestFn = async (request, { payload }) => { + const blackbaudSkyApiClient: BlackbaudSkyApi = new BlackbaudSkyApi(request) - if (integrationErrors.length > 0) { - throw new IntegrationError( - 'One or more errors occurred when updating existing constituent: ' + integrationErrors.join(', '), - 'UPDATE_CONSTITUENT_ERROR', - 500 - ) - } + let constituentId = payload.constituent_id + if (!constituentId && (payload.email?.address || payload.lookup_id)) { + const getExistingConstituentResponse = await blackbaudSkyApiClient.getExistingConstituent( + payload.email, + payload.lookup_id + ) + constituentId = getExistingConstituentResponse.id + } - return - } + const [constituentData, addressData, emailData, onlinePresenceData, phoneData] = splitConstituentPayload(payload) + + if (!constituentId) { + return blackbaudSkyApiClient.createConstituentWithRelatedObjects( + constituentData as Constituent, + addressData as Partial
, + emailData as Partial, + onlinePresenceData as Partial, + phoneData as Partial + ) + } else { + return blackbaudSkyApiClient.updateConstituentWithRelatedObjects( + constituentId, + constituentData as Partial, + addressData as Partial
, + emailData as Partial, + onlinePresenceData as Partial, + phoneData as Partial + ) } } +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, + perform +} + export default action 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 index 280cc3651b..ef24abe14e 100644 --- a/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/index.ts +++ b/packages/destination-actions/src/destinations/blackbaud-raisers-edge-nxt/index.ts @@ -2,6 +2,7 @@ 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 createGift from './createGift' import createOrUpdateIndividualConstituent from './createOrUpdateIndividualConstituent' const destination: DestinationDefinition = { @@ -54,6 +55,7 @@ const destination: DestinationDefinition = { }, actions: { + createGift, createOrUpdateIndividualConstituent } } 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 index 5d25799dd2..f1be3ceab5 100644 --- 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 @@ -6,6 +6,18 @@ export interface StringIndexedObject { [key: string]: any } +export interface ExistingConstituentResult { + id: string | undefined +} + +export interface CreateConstituentResult { + id: string +} + +export interface UpdateConstituentResult { + id: string +} + export interface FuzzyDate { d: string m: string @@ -19,7 +31,7 @@ export interface Constituent { first?: string gender?: string income?: string - last?: string + last: string lookup_id?: string online_presence?: OnlinePresence phone?: Phone @@ -34,7 +46,7 @@ export interface Address { postal_code?: string primary?: boolean state?: string - type?: string + type: string inactive?: boolean } @@ -43,10 +55,10 @@ export interface ExistingAddress extends Address { } export interface Email { - address?: string + address: string do_not_email?: boolean primary?: boolean - type?: string + type: string inactive?: boolean } @@ -55,9 +67,9 @@ export interface ExistingEmail extends Email { } export interface OnlinePresence { - address?: string + address: string primary?: boolean - type?: string + type: string inactive?: boolean } @@ -67,12 +79,63 @@ export interface ExistingOnlinePresence extends OnlinePresence { export interface Phone { do_not_call?: boolean - number?: string + number: string primary?: boolean - type?: string + type: string inactive?: boolean } export interface ExistingPhone extends Phone { id: string } + +export interface Gift { + acknowledgements?: GiftAcknowledgement[] + amount: GiftAmount + constituent_id: string + date: string | number + gift_splits: GiftSplit[] + gift_status?: string + is_anonymous?: boolean + is_manual: boolean + linked_gifts?: string[] + lookup_id?: string + payments: GiftPayment[] + post_date?: string | number + post_status?: string + receipts?: GiftReceipt[] + recurring_gift_schedule?: RecurringGiftSchedule + subtype?: string + type: string +} + +export interface GiftAcknowledgement { + date?: string | number + status?: string +} + +export interface GiftAmount { + value: number +} + +export interface GiftSplit { + amount: GiftAmount + fund_id: string +} + +export interface GiftPayment { + check_date?: FuzzyDate + check_number?: string + payment_method: string +} + +export interface GiftReceipt { + date?: string | number + status?: string +} + +export interface RecurringGiftSchedule { + end_date?: string | number + frequency: string + start_date: string | number +} 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 index 6c49070771..ee49d45f9c 100644 --- 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 @@ -1,7 +1,20 @@ -import { StringIndexedObject } from '../types' +import { + Address, + Constituent, + Email, + Gift, + GiftAcknowledgement, + GiftReceipt, + OnlinePresence, + Phone, + StringIndexedObject +} from '../types' +import { Payload as CreateOrUpdateIndividualConstituentPayload } from '../createOrUpdateIndividualConstituent/generated-types' +import { Payload as CreateGiftPayload } from '../createGift/generated-types' export const dateStringToFuzzyDate = (dateString: string | number) => { - const date = new Date(dateString) + // Ignore timezone + const date = new Date((dateString + '').split('T')[0]) if (isNaN(date.getTime())) { // invalid date object return false @@ -10,13 +23,189 @@ export const dateStringToFuzzyDate = (dateString: string | number) => { // 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() + d: date.getUTCDate().toString(), + m: (date.getUTCMonth() + 1).toString(), + y: date.getUTCFullYear().toString() } } } +export const splitConstituentPayload = (payload: CreateOrUpdateIndividualConstituentPayload) => { + const constituentData: Partial = { + 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 + } + } + + let addressData: Partial
= {} + if ( + payload.address && + (payload.address.address_lines || + payload.address.city || + payload.address.country || + payload.address.postal_code || + payload.address.state) && + payload.address.type + ) { + addressData = payload.address + } + + let emailData: Partial = {} + if (payload.email && payload.email.address && payload.email.type) { + emailData = payload.email + } + + let onlinePresenceData: Partial = {} + if (payload.online_presence && payload.online_presence.address && payload.online_presence.type) { + onlinePresenceData = payload.online_presence + } + + let phoneData: Partial = {} + if (payload.phone && payload.phone.number && payload.phone.type) { + phoneData = payload.phone + } + + return [constituentData, addressData, emailData, onlinePresenceData, phoneData] +} + +export const buildConstituentPayloadFromPayload = (payload: StringIndexedObject) => { + // check if request includes fields to create or update a constituent + // if so, append them to a new payload + const constituentPayload: StringIndexedObject = {} + Object.keys(payload).forEach((key: string) => { + if (key.startsWith('constituent_')) { + // only append non-empty fields/objects + if ( + payload[key] && + (typeof payload[key] !== 'object' || + (Object.keys(payload[key]).length > 0 && Object.values(payload[key]).every((x) => !!x))) + ) { + let constituentPayloadKey = key.substring('constituent_'.length) + if (key === 'constituent_id') { + constituentPayloadKey = key + } + constituentPayload[constituentPayloadKey] = payload[key] + } + } + }) + return constituentPayload as Partial +} + +export const buildGiftDataFromPayload = (constituentId: string, payload: CreateGiftPayload) => { + // data for gift call + const giftData: Partial = { + amount: { + value: payload.amount + }, + constituent_id: constituentId, + date: payload.date, + gift_status: payload.gift_status, + is_anonymous: payload.is_anonymous, + // hardcode is_manual + is_manual: true, + lookup_id: payload.lookup_id, + post_date: payload.post_date, + post_status: payload.post_status, + subtype: payload.subtype, + type: payload.type + } + Object.keys(giftData).forEach((key) => { + if (!giftData[key as keyof Gift]) { + delete giftData[key as keyof Gift] + } + }) + + // default date + giftData.date = giftData.date || new Date().toISOString() + + // create acknowledgements array + if (payload.acknowledgement) { + const acknowledgementData: GiftAcknowledgement = { + status: payload.acknowledgement.status || 'NEEDSACKNOWLEDGEMENT' + } + if ( + acknowledgementData.status !== 'NEEDSACKNOWLEDGEMENT' && + acknowledgementData.status !== 'DONOTACKNOWLEDGE' && + payload.acknowledgement.date + ) { + acknowledgementData.date = payload.acknowledgement.date + } + giftData.acknowledgements = [acknowledgementData] + } + + // create gift splits array + giftData.gift_splits = [ + { + amount: { + value: payload.amount + }, + fund_id: payload.fund_id + } + ] + + // create payments array + giftData.payments = [ + { + payment_method: payload.payment_method + } + ] + + // fields for check gifts + if (giftData.payments[0].payment_method === 'PersonalCheck') { + giftData.payments[0].check_number = payload.check_number + if (payload.check_date) { + const checkDateFuzzyDate = dateStringToFuzzyDate(payload.check_date) + if (checkDateFuzzyDate) { + giftData.payments[0].check_date = checkDateFuzzyDate + } + } + } + + // default post date + if ((giftData.post_status === 'NotPosted' || giftData.post_status === 'Posted') && !giftData.post_date) { + giftData.post_date = payload.date + } + + // create receipts array + if (payload.receipt) { + const receiptData: GiftReceipt = { + status: payload.receipt.status || 'NEEDSRECEIPT' + } + if (receiptData.status === 'RECEIPTED' && payload.receipt.date) { + receiptData.date = payload.receipt.date + } + giftData.receipts = [receiptData] + } + + // fields for recurring gifts + if (giftData.type === 'RecurringGift') { + if (payload.recurring_gift_schedule) { + giftData.recurring_gift_schedule = { + end_date: payload.recurring_gift_schedule.end_date, + frequency: payload.recurring_gift_schedule.frequency || '', + start_date: payload.recurring_gift_schedule.start_date || '' + } + } + } else if (giftData.type === 'RecurringGiftPayment' && payload.linked_gifts) { + giftData.linked_gifts = payload.linked_gifts + } + + return giftData +} + export const filterObjectListByMatchFields = ( list: StringIndexedObject[], data: StringIndexedObject,