diff --git a/packages/destination-actions/src/destinations/braze/__tests__/multistatus.test.ts b/packages/destination-actions/src/destinations/braze/__tests__/multistatus.test.ts new file mode 100644 index 0000000000..29c5bef8fe --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/__tests__/multistatus.test.ts @@ -0,0 +1,701 @@ +import { SegmentEvent, createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import Braze from '../index' +import { BrazeTrackUserAPIResponse } from '../utils' + +beforeEach(() => nock.cleanAll()) + +const testDestination = createTestIntegration(Braze) + +const settings = { + app_id: 'my-app-id', + api_key: 'my-api-key', + endpoint: 'https://rest.iad-01.braze.com' as const +} + +const receivedAt = '2024-08-01T17:40:04.055Z' + +describe('MultiStatus', () => { + describe('trackEvent', () => { + const mapping = { + name: { + '@path': '$.traits.name' + }, + time: receivedAt, + email: { + '@path': '$.traits.email' + }, + external_id: { + '@path': '$.traits.externalId' + }, + braze_id: { + '@path': '$.traits.brazeId' + } + } + + it('should successfully handle a batch of events with complete success response from Braze API', async () => { + nock(settings.endpoint).post('/users/track').reply(201, {}) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User One', + externalId: 'test-external-id' + } + }), + // Event without any user identifier + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User Two' + } + }) + ] + + const response = await testDestination.executeBatch('trackEvent', { + events, + settings, + mapping + }) + + // The first event doesn't fail as there is no error reported by Braze API + expect(response[0]).toMatchObject({ + status: 200, + body: 'success' + }) + + // The third event fails as pre-request validation fails for not having a valid user identifier + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with partial success response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + events_processed: 2, + message: 'success', + errors: [ + { + type: 'a test error occurred', + input_array: 'events', + index: 1 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(201, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User One', + externalId: 'test-external-id' + } + }), + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User Two', + externalId: 'test-external-id' + } + }) + ] + + const response = await testDestination.executeBatch('trackEvent', { + events, + settings, + mapping + }) + + // The first doesn't fail as there is no error reported by Braze API + expect(response[0]).toMatchObject({ + status: 200 + }) + + // The second event fails as Braze API reports an error + expect(response[1]).toMatchObject({ + status: 400, + errormessage: 'a test error occurred', + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with fatal error response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + message: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errors: [ + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + }, + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(400, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User One', + externalId: 'test-external-id' + } + }), + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + name: 'Example User Two', + externalId: 'test-external-id' + } + }) + ] + + const response = await testDestination.executeBatch('trackEvent', { + events, + settings, + mapping + }) + + expect(response).toMatchObject([ + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }, + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + } + ]) + }) + }) + + describe('trackPurchase', () => { + const mapping = { + time: receivedAt, + external_id: { + '@path': '$.properties.externalId' + }, + products: { + '@path': '$.properties.products' + } + } + + it('should successfully handle a batch of events with complete success response from Braze API', async () => { + nock(settings.endpoint).post('/users/track').reply(201, {}) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + }, + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }), + // Event with no product + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [] + } + }), + // Event without any user identifier + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }) + ] + + const response = await testDestination.executeBatch('trackPurchase', { + events, + settings, + mapping + }) + + // The first event doesn't fail as there is no error reported by Braze API + expect(response[0]).toMatchObject({ + status: 200, + body: 'success' + }) + + // The second event fails as it doesn't have any products + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'This event was not sent to Braze because it did not contain any products.', + errorreporter: 'DESTINATION' + }) + + // The third event fails as pre-request validation fails for not having a valid user identifier + expect(response[2]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with partial success response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + events_processed: 4, + message: 'success', + errors: [ + { + type: 'a test error occurred', + input_array: 'purchases', + index: 1 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(201, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + }, + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }), + // Valid Event + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + }, + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }), + // Event with no product + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [] + } + }) + ] + + const response = await testDestination.executeBatch('trackPurchase', { + events, + settings, + mapping + }) + + // Since each product in an event is further flattened to multiple items in requests, + // if any one of the is reported as failed then the entire event is considered failed. + + // The first event fails as it expands to 2 request items and one of them fails + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'a test error occurred', + errorreporter: 'DESTINATION' + }) + + // The second event doesn't fail as both the expanded request items are successful + expect(response[1]).toMatchObject({ + status: 200, + body: 'success' + }) + + // The third event fails as it doesn't have any products and skipped with success response + expect(response[2]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'This event was not sent to Braze because it did not contain any products.', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with fatal error response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + message: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errors: [ + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + }, + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(400, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + }, + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }), + // Valid Event + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [ + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + }, + { + product_id: 'test-product-id', + currency: 'USD', + price: 99.99, + quantity: 1 + } + ] + } + }), + // Event with no product + createTestEvent({ + event: 'Order Completed', + type: 'track', + receivedAt, + properties: { + externalId: 'test-external-id', + products: [] + } + }) + ] + + const response = await testDestination.executeBatch('trackPurchase', { + events, + settings, + mapping + }) + + expect(response).toMatchObject([ + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }, + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }, + { + status: 400, + errormessage: 'This event was not sent to Braze because it did not contain any products.', + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + } + ]) + }) + }) + + describe('updateUserProfile', () => { + const mapping = { + first_name: { + '@path': '$.traits.firstName' + }, + last_name: { + '@path': '$.traits.lastName' + }, + time: receivedAt, + email: { + '@path': '$.traits.email' + }, + external_id: { + '@path': '$.traits.externalId' + }, + braze_id: { + '@path': '$.traits.brazeId' + } + } + + it('should successfully handle a batch of events with complete success response from Braze API', async () => { + nock(settings.endpoint).post('/users/track').reply(201, {}) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id' + } + }), + // Event without any user identifier + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User' + } + }) + ] + + const response = await testDestination.executeBatch('updateUserProfile', { + events, + settings, + mapping + }) + + // The first event doesn't fail as there is no error reported by Braze API + expect(response[0]).toMatchObject({ + status: 200, + body: 'success' + }) + + // The third event fails as pre-request validation fails for not having a valid user identifier + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with partial success response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + events_processed: 2, + message: 'success', + errors: [ + { + type: 'a test error occurred', + input_array: 'events', + index: 1 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(201, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id' + } + }), + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id' + } + }) + ] + + const response = await testDestination.executeBatch('updateUserProfile', { + events, + settings, + mapping + }) + + // The first doesn't fail as there is no error reported by Braze API + expect(response[0]).toMatchObject({ + status: 200 + }) + + // The second event fails as Braze API reports an error + expect(response[1]).toMatchObject({ + status: 400, + errormessage: 'a test error occurred', + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch of events with fatal error response from Braze API', async () => { + // Mocking a 400 response from Braze API + const mockResponse: BrazeTrackUserAPIResponse = { + message: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errors: [ + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + }, + { + type: 'Test fatal error', + input_array: 'events', + index: 0 + } + ] + } + + nock(settings.endpoint).post('/users/track').reply(400, mockResponse) + + const events: SegmentEvent[] = [ + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id' + } + }), + // Valid Event + createTestEvent({ + type: 'identify', + receivedAt, + traits: { + firstName: 'Example', + lastName: 'User', + externalId: 'test-external-id' + } + }) + ] + + const response = await testDestination.executeBatch('updateUserProfile', { + events, + settings, + mapping + }) + + expect(response).toMatchObject([ + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + }, + { + status: 400, + errormessage: "Valid data must be provided in the 'attributes', 'events', or 'purchases' fields.", + errortype: 'PAYLOAD_VALIDATION_FAILED', + errorreporter: 'DESTINATION' + } + ]) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/braze/utils.ts b/packages/destination-actions/src/destinations/braze/utils.ts index d6b4ad8fcb..0d618f762d 100644 --- a/packages/destination-actions/src/destinations/braze/utils.ts +++ b/packages/destination-actions/src/destinations/braze/utils.ts @@ -1,4 +1,4 @@ -import { omit } from '@segment/actions-core' +import { JSONLikeObject, ModifiedResponse, MultiStatusResponse, omit } from '@segment/actions-core' import { IntegrationError, RequestClient, removeUndefined } from '@segment/actions-core' import dayjs from 'dayjs' import { Settings } from './generated-types' @@ -7,9 +7,22 @@ import { Payload as TrackEventPayload } from './trackEvent/generated-types' import { Payload as TrackPurchasePayload } from './trackPurchase/generated-types' import { Payload as UpdateUserProfilePayload } from './updateUserProfile/generated-types' import { getUserAlias } from './userAlias' +import { HTTPError } from '@segment/actions-core' type DateInput = string | Date | number | null | undefined type DateOutput = string | undefined | null +export type BrazeTrackUserAPIResponse = { + attributes_processed?: number + events_processed?: number + purchases_processed?: number + message: string + errors?: { + type: string + input_array: 'events' | 'purchases' | 'attributes' + index: number + }[] +} + function toISO8601(date: DateInput): DateOutput { if (date === null || date === undefined) { return date @@ -91,22 +104,30 @@ export function sendTrackEvent(request: RequestClient, settings: Settings, paylo }) } -export function sendBatchedTrackEvent(request: RequestClient, settings: Settings, payloads: TrackEventPayload[]) { - const payload = payloads.map((payload) => { +export async function sendBatchedTrackEvent(request: RequestClient, settings: Settings, payloads: TrackEventPayload[]) { + const multiStatusResponse = new MultiStatusResponse() + + const filteredPayloads: JSONLikeObject[] = [] + + // A bitmap that stores arr[new_index] = original_batch_payload_index + const validPayloadIndicesBitmap: number[] = [] + + payloads.forEach((payload, originalBatchIndex) => { const { braze_id, external_id, email } = payload // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. const user_alias = getUserAlias(payload.user_alias) - // Disable errors until Actions Framework has a multistatus support - // if (!braze_id && !user_alias && !external_id) { - // throw new IntegrationError( - // 'One of "external_id" or "user_alias" or "braze_id" is required.', - // 'Missing required fields', - // 400 - // ) - // } + // Filter out and record if payload is invalid + if (!braze_id && !user_alias && !external_id) { + multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.' + }) + return + } - return { + const payloadToSend = { braze_id, external_id, email, @@ -117,15 +138,34 @@ export function sendBatchedTrackEvent(request: RequestClient, settings: Settings properties: payload.properties, _update_existing_only: payload._update_existing_only } + + filteredPayloads.push(payloadToSend as JSONLikeObject) + validPayloadIndicesBitmap.push(originalBatchIndex) + + // Initialize the Multi-Status response to be valid for all validated payloads + multiStatusResponse.setSuccessResponseAtIndex(originalBatchIndex, { + status: 200, + sent: payloadToSend as JSONLikeObject, + body: 'success' + }) }) - return request(`${settings.endpoint}/users/track`, { + const response = request(`${settings.endpoint}/users/track`, { method: 'post', - ...(payload.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), + ...(filteredPayloads.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), json: { - events: payload + events: filteredPayloads } }) + + await handleBrazeAPIResponse( + transformPayloadsType(payloads), + response, + multiStatusResponse, + validPayloadIndicesBitmap + ) + + return multiStatusResponse } export function sendTrackPurchase(request: RequestClient, settings: Settings, payload: TrackPurchasePayload) { @@ -179,66 +219,98 @@ export function sendTrackPurchase(request: RequestClient, settings: Settings, pa }) } -export function sendBatchedTrackPurchase(request: RequestClient, settings: Settings, payloads: TrackPurchasePayload[]) { - let payload = payloads - .map((payload) => { - const { braze_id, external_id, email } = payload - // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. - const user_alias = getUserAlias(payload.user_alias) - - // Disable errors until Actions Framework has a multistatus support - // if (!braze_id && !user_alias && !external_id) { - // throw new IntegrationError( - // 'One of "external_id" or "user_alias" or "braze_id" is required.', - // 'Missing required fields', - // 400 - // ) - // } - - // Skip when there are no products to send to Braze - if (payload.products.length === 0) { - return - } +export async function sendBatchedTrackPurchase( + request: RequestClient, + settings: Settings, + payloads: TrackPurchasePayload[] +) { + const multiStatusResponse = new MultiStatusResponse() - const base = { - braze_id, - external_id, - user_alias, - email, - app_id: settings.app_id, - time: toISO8601(payload.time), - _update_existing_only: payload._update_existing_only - } + const flattenedPayload: JSONLikeObject[] = [] - const reservedKeys = Object.keys(action.fields.products.properties ?? {}) - const event_properties = omit(payload.properties, ['products']) + // A bitmap that stores arr[new_index] = original_batch_payload_index + const validPayloadIndicesBitmap: number[] = [] - return payload.products.map(function (product) { - return { - ...base, - product_id: product.product_id, - currency: product.currency ?? 'USD', - price: product.price, - quantity: product.quantity, - properties: { - ...omit(product, reservedKeys), - ...event_properties - } - } + const reservedKeys = Object.keys(action.fields.products.properties ?? {}) + + payloads.forEach((payload, originalBatchIndex) => { + const { braze_id, external_id, email } = payload + // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. + const user_alias = getUserAlias(payload.user_alias) + + // Filter out and record if payload is invalid + if (!braze_id && !user_alias && !external_id) { + multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.' + }) + return + } + + // Filter if no products are there to send + if (payload.products.length === 0) { + multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'This event was not sent to Braze because it did not contain any products.' }) + return + } + + const requestBase = { + braze_id, + external_id, + user_alias, + email, + app_id: settings.app_id, + time: toISO8601(payload.time), + _update_existing_only: payload._update_existing_only + } + + const eventProperties = omit(payload.properties, ['products']) + + payload.products.forEach((product) => { + flattenedPayload.push({ + ...requestBase, + product_id: product.product_id, + currency: product.currency ?? 'USD', + price: product.price, + quantity: product.quantity, + properties: { + ...omit(product, reservedKeys), + ...eventProperties + } + } as JSONLikeObject) + + // Record the index of the flattened payload with the index of original batch payload + validPayloadIndicesBitmap.push(originalBatchIndex) }) - .filter((notFalsy) => notFalsy) - // flatten arrays - payload = ([] as any[]).concat(...payload) + // Initialize the Multi-Status response to be valid for all validated payloads + multiStatusResponse.setSuccessResponseAtIndex(originalBatchIndex, { + status: 200, + sent: payload as unknown as JSONLikeObject, + body: 'success' + }) + }) - return request(`${settings.endpoint}/users/track`, { + const response = request(`${settings.endpoint}/users/track`, { method: 'post', - ...(payload.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), + ...(flattenedPayload.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), json: { - purchases: payload + purchases: flattenedPayload } }) + + await handleBrazeAPIResponse( + transformPayloadsType(payloads), + response, + multiStatusResponse, + validPayloadIndicesBitmap + ) + + return multiStatusResponse } export function updateUserProfile(request: RequestClient, settings: Settings, payload: UpdateUserProfilePayload) { @@ -306,35 +378,42 @@ export function updateUserProfile(request: RequestClient, settings: Settings, pa }) } -export function updateBatchedUserProfile( +export async function updateBatchedUserProfile( request: RequestClient, settings: Settings, payloads: UpdateUserProfilePayload[] ) { - const payload = payloads.map((payload) => { - const { braze_id, external_id, email } = payload + const multiStatusResponse = new MultiStatusResponse() + + const filteredPayloads: JSONLikeObject[] = [] + // A bitmap that stores arr[new_index] = original_batch_payload_index + const validPayloadIndicesBitmap: number[] = [] + // Since we merge reserved keys on top of custom_attributes we need to remove them + // to respect the customers mappings that might resolve `undefined`, without this we'd + // potentially send a value from `custom_attributes` that conflicts with their mappings. + const reservedKeys = Object.keys(action.fields) + // Push additional default keys so they are not added as custom attributes + reservedKeys.push('firstName', 'lastName', 'avatar') + + payloads.forEach((payload, originalBatchIndex) => { + const { braze_id, external_id, email } = payload // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. const user_alias = getUserAlias(payload.user_alias) - // Disable errors until Actions Framework has a multistatus support - // if (!braze_id && !user_alias && !external_id) { - // throw new IntegrationError( - // 'One of "external_id" or "user_alias" or "braze_id" is required.', - // 'Missing required fields', - // 400 - // ) - // } - - // Since we are merge reserved keys on top of custom_attributes we need to remove them - // to respect the customers mappings that might resolve `undefined`, without this we'd - // potentially send a value from `custom_attributes` that conflicts with their mappings. - const reservedKeys = Object.keys(action.fields) - // push additional default keys so they are not added as custom attributes - reservedKeys.push('firstName', 'lastName', 'avatar') + // Filter out and record if payload is invalid + if (!braze_id && !user_alias && !external_id) { + multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of "external_id" or "user_alias" or "braze_id" is required.' + }) + return + } + const customAttrs = omit(payload.custom_attributes, reservedKeys) - return { + const payloadToSend = { ...customAttrs, braze_id, external_id, @@ -368,13 +447,92 @@ export function updateBatchedUserProfile( twitter: payload.twitter, _update_existing_only: payload._update_existing_only } + + // Push valid payload to filtered array + filteredPayloads.push(payloadToSend as JSONLikeObject) + + // Record the index of the original payload in the filtered array + validPayloadIndicesBitmap.push(originalBatchIndex) + + // Initialize the Multi-Status response to be valid for all validated payloads + multiStatusResponse.setSuccessResponseAtIndex(originalBatchIndex, { + status: 200, + sent: payloadToSend as JSONLikeObject, + body: 'success' + }) }) - return request(`${settings.endpoint}/users/track`, { + const response = request(`${settings.endpoint}/users/track`, { method: 'post', - ...(payload.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), + ...(filteredPayloads.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), json: { - attributes: payload + attributes: filteredPayloads } }) + + await handleBrazeAPIResponse( + transformPayloadsType(payloads), + response, + multiStatusResponse, + validPayloadIndicesBitmap + ) + + return multiStatusResponse +} + +async function handleBrazeAPIResponse( + payloads: JSONLikeObject[], + apiResponse: Promise>, + multiStatusResponse: MultiStatusResponse, + validPayloadIndicesBitmap: number[] +) { + try { + const response: ModifiedResponse = await apiResponse + + // Responses were assumed to be successful by default + // If there are errors we need to update the response + if (response.data.errors && Array.isArray(response.data.errors)) { + response.data.errors.forEach((error) => { + // Resolve error's index back to the original payload's index + const indexInOriginalPayload = validPayloadIndicesBitmap[error.index] + + // Skip if the index is already marked as an error earlier + // This is to prevent overwriting the error response if the payload is already marked as an error in Track Purchase + if (multiStatusResponse.isErrorResponseAtIndex(indexInOriginalPayload)) { + return + } + + multiStatusResponse.setErrorResponseAtIndex(indexInOriginalPayload, { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: error.type, + sent: payloads[indexInOriginalPayload], + body: error.type + }) + }) + } + } catch (error) { + if (error instanceof HTTPError) { + for (let i = 0; i < multiStatusResponse.length(); i++) { + // Skip if the index is already marked as an error in pre-validation + if (multiStatusResponse.isErrorResponseAtIndex(i)) { + continue + } + + // Set the error response + multiStatusResponse.setErrorResponseAtIndex(i, { + status: error.response.status, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: (error?.response as ModifiedResponse)?.data?.message ?? error.message + }) + } + } else { + // Bubble up the error and let Actions Framework handle it + throw error + } + } +} + +function transformPayloadsType(obj: object[]) { + return obj as JSONLikeObject[] }