diff --git a/packages/destination-actions/src/destinations/klaviyo/__tests__/multistatus.test.ts b/packages/destination-actions/src/destinations/klaviyo/__tests__/multistatus.test.ts index 765c8295b0..6072b9bf53 100644 --- a/packages/destination-actions/src/destinations/klaviyo/__tests__/multistatus.test.ts +++ b/packages/destination-actions/src/destinations/klaviyo/__tests__/multistatus.test.ts @@ -10,6 +10,7 @@ const testDestination = createTestIntegration(Klaviyo) const settings = { api_key: 'my-api-key' } +const listId = 'XYZABC' const timestamp = '2024-07-22T20:08:49.7931Z' @@ -547,4 +548,620 @@ describe('MultiStatus', () => { }) }) }) + describe('removeProfile', () => { + beforeEach(() => { + nock.cleanAll() + jest.resetAllMocks() + }) + afterEach(() => { + jest.resetAllMocks() + }) + const requestBody = { + data: [ + { + type: 'profile', + id: 'XYZABC' + }, + { type: 'profile', id: 'XYZABD' } + ] + } + const mapping = { + email: { + '@path': '$.traits.email' + }, + phone_number: { + '@path': '$.traits.phone_number' + }, + list_id: { + '@path': '$.traits.list_id' + }, + country_code: { + '@path': '$.traits.country_code' + }, + external_id: { + '@path': '$.traits.external_id' + } + } + + it("should successfully handle those payload where phone_number is invalid and couldn't be converted to E164 format", async () => { + nock(`${API_URL}/profiles`) + .get(`/?filter=any(email,["valid@gmail.com"])`) + .reply(200, { + data: [{ id: 'XYZABC' }] + }) + const requestBody = { + data: [ + { + type: 'profile', + id: 'XYZABC' + } + ] + } + + nock(API_URL).delete(`/lists/${listId}/relationships/profiles/`, requestBody).reply(202, {}) + + const events: SegmentEvent[] = [ + // Event with invalid phone_number + createTestEvent({ + type: 'identify', + timestamp, + traits: { + country_code: 'IN', + phone_number: '701271', + email: 'valid+1@gmail.com', + list_id: listId + } + }), + // Valid Event + createTestEvent({ + type: 'identify', + timestamp, + traits: { + email: 'valid@gmail.com', + list_id: listId + } + }) + ] + + const response = await testDestination.executeBatch('removeProfile', { + events, + settings, + mapping + }) + + // The First event fails as pre-request validation fails for having invalid phone_number and could not be converted to E164 format + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Phone number could not be converted to E.164 format.', + errorreporter: 'INTEGRATIONS' + }) + + // The Second event doesn't fail as there is no error reported by Klaviyo API + expect(response[1]).toMatchObject({ + status: 200, + body: '{}' + }) + }) + + it('should successfully handle a batch of events with complete success response from Klaviyo API', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + type: 'identify', + traits: { + country_code: 'IN', + phone_number: '7012716787', + list_id: listId + } + }), + createTestEvent({ + type: 'identify', + traits: { + email: 'user1@example.com', + list_id: listId + } + }), + createTestEvent({ + type: 'identify', + traits: { + external_id: 'externalId2', + list_id: listId + } + }) + ] + + nock(`${API_URL}/profiles`) + .get(`/?filter=any(email,["user1@example.com"])`) + .reply(200, { + data: [{ id: 'XYZABC' }] + }) + + nock(`${API_URL}/profiles`) + .get(`/?filter=any(external_id,["externalId2"])`) + .reply(200, { + data: [{ id: 'XYZABD' }] + }) + + nock(`${API_URL}/profiles`) + .get(`/?filter=any(phone_number,["+917012716787"])`) + .reply(200, { + data: [{ id: 'ABCXYZ' }] + }) + + const requestBody = { + data: [ + { type: 'profile', id: 'XYZABD' }, + { + type: 'profile', + id: 'XYZABC' + }, + { type: 'profile', id: 'ABCXYZ' } + ] + } + + nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200, {}) + + const response = await testDestination.executeBatch('removeProfile', { + settings, + events, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 200, + body: '{}' + }) + expect(response[1]).toMatchObject({ + status: 200, + body: '{}' + }) + expect(response[1]).toMatchObject({ + status: 200, + body: '{}' + }) + }) + + it('should successfully handle a batch of events with failure response from Klaviyo API', async () => { + // Mocking a 400 response from Klaviyo API + const mockResponse = { + errors: [ + { + id: 'cd962f40-31ae-4830-a5a1-7ab08a2a222e', + status: 400, + code: 'invalid', + title: 'Invalid input.', + detail: 'Invalid profile IDs: XYZABD', + source: { + pointer: '/data/' + } + } + ] + } + nock(`${API_URL}`) + .get('/profiles/') + .query({ + filter: 'any(email,["user1@example.com","user2@example.com"])' + }) + .reply(200, { + data: [{ id: 'XYZABC' }, { id: 'XYZABD' }] + }) + nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(400, mockResponse) + + const events: SegmentEvent[] = [ + // Invalid Event + createTestEvent({ + type: 'identify', + timestamp, + traits: { + email: 'user1@example.com', + list_id: listId + } + }), + // Valid Event + createTestEvent({ + type: 'identify', + timestamp, + traits: { + email: 'user2@example.com', + list_id: listId + } + }) + ] + + const response = await testDestination.executeBatch('removeProfile', { + events, + settings, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 400, + errormessage: 'Bad Request', + sent: { email: 'user1@example.com', list_id: listId }, + body: '{"errors":[{"id":"cd962f40-31ae-4830-a5a1-7ab08a2a222e","status":400,"code":"invalid","title":"Invalid input.","detail":"Invalid profile IDs: XYZABD","source":{"pointer":"/data/"}}]}', + errortype: 'BAD_REQUEST', + errorreporter: 'DESTINATION' + }) + + expect(response[1]).toMatchObject({ + status: 400, + errormessage: 'Bad Request', + sent: { email: 'user2@example.com', list_id: listId }, + body: '{"errors":[{"id":"cd962f40-31ae-4830-a5a1-7ab08a2a222e","status":400,"code":"invalid","title":"Invalid input.","detail":"Invalid profile IDs: XYZABD","source":{"pointer":"/data/"}}]}', + errortype: 'BAD_REQUEST', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch when all payload is invalid', async () => { + const events: SegmentEvent[] = [ + // Event with invalid phone_number + createTestEvent({ + type: 'identify', + traits: { + country_code: 'IN', + phone_number: '701271', + email: 'valid@gmail.com', + list_id: listId + } + }), + // Event without any user identifier + createTestEvent({ + type: 'identify', + traits: { list_id: listId } + }) + ] + + const response = await testDestination.executeBatch('removeProfile', { + events, + settings, + mapping + }) + + // The First event fails as pre-request validation fails for having invalid phone_number and could not be converted to E164 format + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Phone number could not be converted to E.164 format.', + errorreporter: 'INTEGRATIONS' + }) + + // The second event fails as pre-request validation fails for not having any user identifier + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of External ID, Phone Number or Email is required.', + errorreporter: 'INTEGRATIONS' + }) + }) + }) + + describe('removeProfileFromList', () => { + beforeEach(() => { + nock.cleanAll() + jest.resetAllMocks() + }) + afterEach(() => { + jest.resetAllMocks() + }) + const requestBody = { + data: [ + { + type: 'profile', + id: 'XYZABC' + }, + { type: 'profile', id: 'XYZABD' } + ] + } + const mapping = { + email: { + '@path': '$.properties.email' + }, + phone_number: { + '@path': '$.properties.phone_number' + }, + list_id: listId, + country_code: { + '@path': '$.properties.country_code' + }, + external_id: { + '@path': '$.properties.external_id' + } + } + + it("should successfully handle those payload where phone_number is invalid and couldn't be converted to E164 format", async () => { + nock(`${API_URL}/profiles`) + .get(`/?filter=any(email,["valid@gmail.com"])`) + .reply(200, { + data: [{ id: 'XYZABC' }] + }) + const requestBody = { + data: [ + { + type: 'profile', + id: 'XYZABC' + } + ] + } + + nock(API_URL).delete(`/lists/${listId}/relationships/profiles/`, requestBody).reply(202, {}) + + const events: SegmentEvent[] = [ + // Event with invalid phone_number + createTestEvent({ + type: 'track', + event: 'Audience Exited', + timestamp, + properties: { + country_code: 'IN', + phone_number: '701271', + email: 'valid+1@gmail.com' + } + }), + // Valid Event + createTestEvent({ + type: 'track', + event: 'Audience Exited', + timestamp, + properties: { + email: 'valid@gmail.com' + } + }) + ] + + const response = await testDestination.executeBatch('removeProfileFromList', { + events, + settings, + mapping + }) + + // The First event fails as pre-request validation fails for having invalid phone_number and could not be converted to E164 format + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Phone number could not be converted to E.164 format.', + errorreporter: 'INTEGRATIONS' + }) + + // The Second event doesn't fail as there is no error reported by Klaviyo API + expect(response[1]).toMatchObject({ + status: 200, + body: '{}' + }) + }) + + it('should successfully handle a batch of events with complete success response from Klaviyo API', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + type: 'track', + event: 'Audience Exited', + properties: { + country_code: 'IN', + phone_number: '7012716787' + } + }), + createTestEvent({ + type: 'track', + event: 'Audience Exited', + properties: { + email: 'user1@example.com' + } + }), + createTestEvent({ + type: 'track', + event: 'Audience Exited', + properties: { + external_id: 'externalId2' + } + }) + ] + + nock(`${API_URL}/profiles`) + .get(`/?filter=any(email,["user1@example.com"])`) + .reply(200, { + data: [{ id: 'XYZABC' }] + }) + + nock(`${API_URL}/profiles`) + .get(`/?filter=any(external_id,["externalId2"])`) + .reply(200, { + data: [{ id: 'XYZABD' }] + }) + + nock(`${API_URL}/profiles`) + .get(`/?filter=any(phone_number,["+917012716787"])`) + .reply(200, { + data: [{ id: 'ABCXYZ' }] + }) + + const requestBody = { + data: [ + { type: 'profile', id: 'XYZABD' }, + { + type: 'profile', + id: 'XYZABC' + }, + { type: 'profile', id: 'ABCXYZ' } + ] + } + + nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200, {}) + + const response = await testDestination.executeBatch('removeProfileFromList', { + settings, + events, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 200, + body: '{}' + }) + expect(response[1]).toMatchObject({ + status: 200, + body: '{}' + }) + expect(response[1]).toMatchObject({ + status: 200, + body: '{}' + }) + }) + + it('should successfully handle a batch of events with failure response from Klaviyo API', async () => { + // Mocking a 400 response from Klaviyo API + const mockResponse = { + errors: [ + { + id: 'cd962f40-31ae-4830-a5a1-7ab08a2a222e', + status: 400, + code: 'invalid', + title: 'Invalid input.', + detail: 'Invalid profile IDs: XYZABD', + source: { + pointer: '/data/' + } + } + ] + } + nock(`${API_URL}`) + .get('/profiles/') + .query({ + filter: 'any(email,["user1@example.com","user2@example.com"])' + }) + .reply(200, { + data: [{ id: 'XYZABC' }, { id: 'XYZABD' }] + }) + nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(400, mockResponse) + + const events: SegmentEvent[] = [ + // Invalid Event + createTestEvent({ + type: 'track', + event: 'Audience Exited', + timestamp, + properties: { + email: 'user1@example.com' + } + }), + // Valid Event + createTestEvent({ + type: 'track', + event: 'Audience Exited', + timestamp, + properties: { + email: 'user2@example.com' + } + }) + ] + + const response = await testDestination.executeBatch('removeProfileFromList', { + events, + settings, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 400, + errormessage: 'Bad Request', + sent: { email: 'user1@example.com', list_id: listId }, + body: '{"errors":[{"id":"cd962f40-31ae-4830-a5a1-7ab08a2a222e","status":400,"code":"invalid","title":"Invalid input.","detail":"Invalid profile IDs: XYZABD","source":{"pointer":"/data/"}}]}', + errortype: 'BAD_REQUEST', + errorreporter: 'DESTINATION' + }) + + expect(response[1]).toMatchObject({ + status: 400, + errormessage: 'Bad Request', + sent: { email: 'user2@example.com', list_id: listId }, + body: '{"errors":[{"id":"cd962f40-31ae-4830-a5a1-7ab08a2a222e","status":400,"code":"invalid","title":"Invalid input.","detail":"Invalid profile IDs: XYZABD","source":{"pointer":"/data/"}}]}', + errortype: 'BAD_REQUEST', + errorreporter: 'DESTINATION' + }) + }) + + it('should successfully handle a batch when all payload is invalid', async () => { + const events: SegmentEvent[] = [ + // Event with invalid phone_number + createTestEvent({ + type: 'track', + event: 'Audience Exited', + properties: { + country_code: 'IN', + phone_number: '701271', + email: 'valid@gmail.com' + } + }), + // Event without any user identifier + createTestEvent({ + type: 'track', + event: 'Audience Exited', + properties: {} + }) + ] + + const response = await testDestination.executeBatch('removeProfileFromList', { + events, + settings, + mapping + }) + + // The First event fails as pre-request validation fails for having invalid phone_number and could not be converted to E164 format + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Phone number could not be converted to E.164 format.', + errorreporter: 'INTEGRATIONS' + }) + + // The second event fails as pre-request validation fails for not having any user identifier + expect(response[1]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of External ID, Phone Number or Email is required.', + errorreporter: 'INTEGRATIONS' + }) + }) + it('should successfully mark the payload as success when there is no profileIds', async () => { + nock(`${API_URL}/profiles`) + .get(`/?filter=any(email,["valid@gmail.com","valid+1@gmail.com"])`) + .reply(200, { data: [] }) + + const events: SegmentEvent[] = [ + // Event with invalid phone_number + createTestEvent({ + type: 'track', + event: 'Audience Exited', + timestamp, + properties: { + email: 'valid@gmail.com' + } + }), + // Valid Event + createTestEvent({ + type: 'track', + event: 'Audience Exited', + timestamp, + properties: { + email: 'valid+1@gmail.com' + } + }) + ] + + const response = await testDestination.executeBatch('removeProfileFromList', { + events, + settings, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 200, + body: 'success' + }) + + expect(response[1]).toMatchObject({ + status: 200, + body: 'success' + }) + }) + }) }) diff --git a/packages/destination-actions/src/destinations/klaviyo/functions.ts b/packages/destination-actions/src/destinations/klaviyo/functions.ts index caed8cc8d1..1a7db2d1e9 100644 --- a/packages/destination-actions/src/destinations/klaviyo/functions.ts +++ b/packages/destination-actions/src/destinations/klaviyo/functions.ts @@ -4,11 +4,11 @@ import { DynamicFieldResponse, IntegrationError, PayloadValidationError, - MultiStatusResponse, + JSONLikeObject, HTTPError, + MultiStatusResponse, ErrorCodes } from '@segment/actions-core' -import { JSONLikeObject } from '@segment/actions-core' import { API_URL, REVISION_DATE } from './config' import { Settings } from './generated-types' import { @@ -29,9 +29,10 @@ import { KlaviyoAPIErrorResponse } from './types' import { Payload } from './upsertProfile/generated-types' +import { Payload as RemoveProfilePayload } from './removeProfile/generated-types' +import { PhoneNumberUtil, PhoneNumberFormat } from 'google-libphonenumber' import { Payload as TrackEventPayload } from './trackEvent/generated-types' import dayjs from '../../lib/dayjs' -import { PhoneNumberUtil, PhoneNumberFormat } from 'google-libphonenumber' import { Payload as AddProfileToListPayload } from './addProfileToList/generated-types' import { eventBulkCreateRegex } from './properties' import { ActionDestinationErrorResponseType } from '@segment/actions-core/destination-kittypes' @@ -506,6 +507,98 @@ async function updateMultiStatusWithKlaviyoErrors( }) } +export async function removeBulkProfilesFromList(request: RequestClient, payloads: RemoveProfilePayload[]) { + const multiStatusResponse = new MultiStatusResponse() + + const { filteredPayloads, validPayloadIndicesBitmap } = validateAndPrepareRemoveBulkProfilePayloads( + payloads, + multiStatusResponse + ) + // if there are no payloads with valid phone number/email/external_id, return multiStatusResponse + if (!filteredPayloads.length) { + return multiStatusResponse + } + + const emails = extractField(filteredPayloads, 'email') + const externalIds = extractField(filteredPayloads, 'external_id') + const phoneNumbers = extractField(filteredPayloads, 'phone_number') + + const listId = filteredPayloads[0]?.list_id as string + + try { + const profileIds = await getProfiles(request, emails, externalIds, phoneNumbers) + let response = null + if (profileIds.length) { + response = await removeProfileFromList(request, profileIds, listId) + } + updateMultiStatusWithSuccessData(filteredPayloads, validPayloadIndicesBitmap, multiStatusResponse, response) + } catch (error) { + if (error instanceof HTTPError) { + await updateMultiStatusWithKlaviyoErrors( + payloads as object as JSONLikeObject[], + error, + multiStatusResponse, + validPayloadIndicesBitmap + ) + } else { + throw error // Bubble up the error + } + } + return multiStatusResponse +} + +function extractField(payloads: JSONLikeObject[], field: string): string[] { + return payloads.map((profile) => profile[field]).filter(Boolean) as string[] +} + +function validateAndPrepareRemoveBulkProfilePayloads( + payloads: RemoveProfilePayload[], + multiStatusResponse: MultiStatusResponse +) { + const filteredPayloads: JSONLikeObject[] = [] + const validPayloadIndicesBitmap: number[] = [] + + payloads.forEach((payload, originalBatchIndex) => { + const { validPayload, error } = validateAndConstructRemoveProfilePayloads(payload) + if (error) { + multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, error) + } else { + filteredPayloads.push(validPayload as JSONLikeObject) + validPayloadIndicesBitmap.push(originalBatchIndex) + } + }) + return { filteredPayloads, validPayloadIndicesBitmap } +} +function validateAndConstructRemoveProfilePayloads(payload: RemoveProfilePayload): { + validPayload?: JSONLikeObject + error?: ActionDestinationErrorResponseType +} { + const { phone_number, email, external_id } = payload + const response: { validPayload?: JSONLikeObject; error?: ActionDestinationErrorResponseType } = {} + if (!email && !phone_number && !external_id) { + response.error = { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'One of External ID, Phone Number or Email is required.' + } + return response + } + + if (phone_number) { + const validPhoneNumber = validateAndConvertPhoneNumber(phone_number, payload.country_code as string) + if (!validPhoneNumber) { + response.error = { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Phone number could not be converted to E.164 format.' + } + return response + } + payload.phone_number = validPhoneNumber + } + return { validPayload: payload as object as JSONLikeObject } +} + function validateAndConstructProfilePayload(payload: AddProfileToListPayload): { validPayload?: JSONLikeObject error?: ActionDestinationErrorResponseType @@ -790,7 +883,7 @@ export function updateMultiStatusWithSuccessData( multiStatusResponse.setSuccessResponseAtIndex(validPayloadIndicesBitmap[index], { status: 200, sent: payload, - body: JSON.stringify(response?.data) + body: JSON.stringify(response?.data) || 'success' }) }) } diff --git a/packages/destination-actions/src/destinations/klaviyo/removeProfile/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/removeProfile/__tests__/index.test.ts index 27e0151b59..87e0c5e3f6 100644 --- a/packages/destination-actions/src/destinations/klaviyo/removeProfile/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfile/__tests__/index.test.ts @@ -3,7 +3,6 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../../index' import { API_URL } from '../../config' import { AggregateAjvError } from '@segment/ajv-human-errors' -import * as Functions from '../../functions' const testDestination = createTestIntegration(Definition) @@ -14,19 +13,6 @@ export const settings = { } const listId = 'XYZABC' -const requestBody = { - data: [ - { - type: 'profile', - id: 'XYZABC' - }, - { - type: 'profile', - id: 'XYZABD' - } - ] -} - describe('Remove Profile', () => { it('should throw error if no external_id/email or phone_number is provided', async () => { const event = createTestEvent({ @@ -250,360 +236,3 @@ describe('Remove Profile', () => { await expect(testDestination.testAction('removeProfile', { event, mapping, settings })).resolves.not.toThrowError() }) }) - -describe('Remove Profile Batch', () => { - beforeEach(() => { - nock.cleanAll() - jest.resetAllMocks() - }) - afterEach(() => { - jest.resetAllMocks() - }) - - it('should filter out profiles with invalid phone numbers', async () => { - const getProfilesMock = jest - .spyOn(Functions, 'getProfiles') - .mockImplementation(jest.fn()) - .mockReturnValue(Promise.resolve(['XYZABC'])) - - const events = [ - createTestEvent({ - properties: { - email: 'user1@example.com' - } - }), - createTestEvent({ - properties: { - phone: 'invalid-phone-number' - } - }) - ] - - const mapping = { - list_id: listId, - email: { - '@path': '$.properties.email' - }, - phone_number: { - '@path': '$.properties.phone' - }, - country_code: 'IN' - } - - const requestBody = { - data: [ - { - type: 'profile', - id: 'XYZABC' - } - ] - } - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfile', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - - // Verify that the profile with invalid phone number was filtered out - expect(getProfilesMock).toHaveBeenCalledWith(expect.anything(), ['user1@example.com'], [], []) - getProfilesMock.mockRestore() - }) - - it('should convert a phone number to E.164 format if country code is provided', async () => { - const events = [ - createTestEvent({ - properties: { - phone: '8448309222' - } - }), - createTestEvent({ - properties: { - phone: '8448309223' - } - }) - ] - const mapping = { - list_id: listId, - phone_number: { - '@path': '$.properties.phone' - }, - country_code: 'IN' - } - - nock(`${API_URL}/profiles`) - .get(`/?filter=any(phone_number,["+918448309222","+918448309223"])`) - .reply(200, { - data: [{ id: 'XYZABC' }, { id: 'XYZABD' }] - }) - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfile', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - }) - - it('should remove multiple profiles with valid emails', async () => { - const events = [ - createTestEvent({ - properties: { - email: 'user1@example.com' - } - }), - createTestEvent({ - properties: { - email: 'user2@example.com' - } - }) - ] - const mapping = { - list_id: listId, - email: { - '@path': '$.properties.email' - } - } - - nock(`${API_URL}`) - .get('/profiles/') - .query({ - filter: 'any(email,["user1@example.com","user2@example.com"])' - }) - .reply(200, { - data: [{ id: 'XYZABC' }, { id: 'XYZABD' }] - }) - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfile', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - }) - - it('should remove multiple profiles with valid external IDs', async () => { - const events = [ - createTestEvent({ - properties: { - external_id: 'externalId1' - } - }), - createTestEvent({ - properties: { - external_id: 'externalId2' - } - }) - ] - - const mapping = { - list_id: listId, - external_id: { - '@path': '$.properties.external_id' - } - } - - nock(`${API_URL}`) - .get('/profiles/') - .query({ - filter: 'any(external_id,["externalId1","externalId2"])' - }) - .reply(200, { - data: [{ id: 'XYZABC' }, { id: 'XYZABD' }] - }) - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfile', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - }) - - it('should remove multiple profiles with valid phone numbers', async () => { - const events = [ - createTestEvent({ - properties: { - phone: '+15005435907' - } - }), - createTestEvent({ - properties: { - phone: '+15005435908' - } - }) - ] - const mapping = { - list_id: listId, - phone_number: { - '@path': '$.properties.phone' - } - } - - nock(`${API_URL}/profiles`) - .get(`/?filter=any(phone_number,["+15005435907","+15005435908"])`) - .reply(200, { - data: [{ id: 'XYZABC' }, { id: 'XYZABD' }] - }) - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfile', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - }) - - it('should remove profiles with valid emails, phone numbers and external IDs', async () => { - const events = [ - createTestEvent({ - properties: { - email: 'user1@example.com' - } - }), - createTestEvent({ - properties: { - external_id: 'externalId2' - } - }), - createTestEvent({ - properties: { - phone: '+15005435907' - } - }) - ] - - const mapping = { - list_id: listId, - external_id: { - '@path': '$.properties.external_id' - }, - email: { - '@path': '$.properties.email' - }, - phone_number: { - '@path': '$.properties.phone' - } - } - - nock(`${API_URL}`) - .get('/profiles/') - .query({ - filter: 'any(email,["user1@example.com"])' - }) - .reply(200, { - data: [{ id: 'XYZABD' }] - }) - - nock(`${API_URL}`) - .get('/profiles/') - .query({ - filter: 'any(external_id,["externalId2"])' - }) - .reply(200, { - data: [{ id: 'XYZABC' }] - }) - - const phone_number = '+15005435907' - nock(`${API_URL}/profiles`) - .get(`/?filter=any(phone_number,["${phone_number}"])`) - .reply(200, { - data: [{ id: 'XYZABC' }] - }) - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfile', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - }) - - it('should filter out profiles without email, phone number or external ID', async () => { - const events = [ - createTestEvent({ - properties: { - fake: 'property' - } - }), - createTestEvent({ - properties: { - email: 'valid@example.com' - } - }) - ] - - const mapping = { - list_id: listId, - external_id: { - '@path': '$.properties.external_id' - }, - email: { - '@path': '$.properties.email' - } - } - - const requestBody = { - data: [ - { - type: 'profile', - id: 'XYZABC' - } - ] - } - - nock(`${API_URL}`) - .get('/profiles/') - .query({ - filter: 'any(email,["valid@example.com"])' - }) - .reply(200, { - data: [{ id: 'XYZABC' }] - }) - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfile', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - }) - - it('should handle an empty payload', async () => { - const getProfilesMock = jest.spyOn(Functions, 'getProfiles').mockImplementation(jest.fn()) - const removeProfileFromListMock = jest.spyOn(Functions, 'removeProfileFromList').mockImplementation() - - await testDestination.testBatchAction('removeProfile', { - settings, - events: [] - }) - - expect(Functions.getProfiles).not.toHaveBeenCalled() - expect(Functions.removeProfileFromList).not.toHaveBeenCalled() - - getProfilesMock.mockRestore() - removeProfileFromListMock.mockRestore() - }) -}) diff --git a/packages/destination-actions/src/destinations/klaviyo/removeProfile/generated-types.ts b/packages/destination-actions/src/destinations/klaviyo/removeProfile/generated-types.ts index 6f8b25f321..b36af9880a 100644 --- a/packages/destination-actions/src/destinations/klaviyo/removeProfile/generated-types.ts +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfile/generated-types.ts @@ -17,6 +17,10 @@ export interface Payload { * When enabled, the action will use the klaviyo batch API. */ enable_batching?: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number /** * Individual's phone number in E.164 format. If SMS is not enabled and if you use Phone Number as identifier, then you have to provide one of Email or External ID. */ diff --git a/packages/destination-actions/src/destinations/klaviyo/removeProfile/index.ts b/packages/destination-actions/src/destinations/klaviyo/removeProfile/index.ts index 16b3b3d4fa..675694c50a 100644 --- a/packages/destination-actions/src/destinations/klaviyo/removeProfile/index.ts +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfile/index.ts @@ -6,10 +6,10 @@ import { getListIdDynamicData, getProfiles, processPhoneNumber, - removeProfileFromList, - validateAndConvertPhoneNumber + removeBulkProfilesFromList, + removeProfileFromList } from '../functions' -import { country_code, enable_batching } from '../properties' +import { batch_size, country_code, enable_batching } from '../properties' const action: ActionDefinition = { title: 'Remove Profile', @@ -36,6 +36,7 @@ const action: ActionDefinition = { required: true }, enable_batching: { ...enable_batching }, + batch_size: { ...batch_size, default: 1000 }, phone_number: { label: 'Phone Number', description: `Individual's phone number in E.164 format. If SMS is not enabled and if you use Phone Number as identifier, then you have to provide one of Email or External ID.`, @@ -65,44 +66,8 @@ const action: ActionDefinition = { ) return await removeProfileFromList(request, profileIds, list_id) }, - performBatch: async (request, { payload, features, statsContext }) => { - // Filtering out profiles that do not contain either an email, valid phone_number or external_id. - const filteredPayload = payload.filter((profile) => { - // Validate and convert the phone number using the provided country code - const validPhoneNumber = validateAndConvertPhoneNumber(profile.phone_number, profile.country_code) - - // If the phone number is valid, update the profile's phone number with the validated format - if (validPhoneNumber) { - profile.phone_number = validPhoneNumber - } - // If the phone number is invalid (null), exclude this profile - else if (validPhoneNumber === null) { - return false - } - return profile.email || profile.external_id || profile.phone_number - }) - const statsClient = statsContext?.statsClient - const tags = statsContext?.tags - - if (features && features['klaviyo-list-id']) { - const set = new Set() - payload.forEach((profile) => { - set.add(profile.list_id) - }) - statsClient?.histogram(`klaviyo_list_id`, set.size, tags) - } - - const listId = filteredPayload[0]?.list_id - const emails = filteredPayload.map((profile) => profile.email).filter((email) => email) as string[] - const externalIds = filteredPayload - .map((profile) => profile.external_id) - .filter((external_id) => external_id) as string[] - const phoneNumbers = filteredPayload - .map((profile) => profile.phone_number) - .filter((phone_number) => phone_number) as string[] - - const profileIds = await getProfiles(request, emails, externalIds, phoneNumbers) - return await removeProfileFromList(request, profileIds, listId) + performBatch: async (request, { payload }) => { + return await removeBulkProfilesFromList(request, payload) } } diff --git a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/__tests__/index.test.ts index 29c87b711a..e0cb8bb1ee 100644 --- a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/__tests__/index.test.ts @@ -3,7 +3,6 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../../index' import { API_URL } from '../../config' import { AggregateAjvError } from '@segment/ajv-human-errors' -import * as Functions from '../../functions' const testDestination = createTestIntegration(Definition) @@ -14,19 +13,6 @@ export const settings = { } const listId = 'XYZABC' -const requestBody = { - data: [ - { - type: 'profile', - id: 'XYZABC' - }, - { - type: 'profile', - id: 'XYZABD' - } - ] -} - describe('Remove List from Profile', () => { it('should throw error if no external_id/email or phone_number is provided', async () => { const event = createTestEvent({ @@ -205,321 +191,3 @@ describe('Remove List from Profile', () => { ).resolves.not.toThrowError() }) }) - -describe('Remove List from Profile Batch', () => { - beforeEach(() => { - nock.cleanAll() - jest.resetAllMocks() - }) - afterEach(() => { - jest.resetAllMocks() - }) - - it('should filter out profiles with invalid phone numbers', async () => { - const getProfilesMock = jest - .spyOn(Functions, 'getProfiles') - .mockImplementation(jest.fn()) - .mockReturnValue(Promise.resolve(['XYZABC'])) - - const events = [ - createTestEvent({ - properties: { - email: 'user1@example.com' - } - }), - createTestEvent({ - properties: { - phone: 'invalid-phone-number' - } - }) - ] - - const mapping = { - list_id: listId, - email: { - '@path': '$.properties.email' - }, - phone_number: { - '@path': '$.properties.phone' - } - } - - const requestBody = { - data: [ - { - type: 'profile', - id: 'XYZABC' - } - ] - } - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfileFromList', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - - // Verify that the profile with invalid phone number was filtered out - expect(getProfilesMock).toHaveBeenCalledWith(expect.anything(), ['user1@example.com'], [], []) - getProfilesMock.mockRestore() - }) - - it('should remove multiple profiles with valid emails', async () => { - const events = [ - createTestEvent({ - properties: { - email: 'user1@example.com' - } - }), - createTestEvent({ - properties: { - email: 'user2@example.com' - } - }) - ] - const mapping = { - list_id: listId, - email: { - '@path': '$.properties.email' - } - } - - nock(`${API_URL}`) - .get('/profiles/') - .query({ - filter: 'any(email,["user1@example.com","user2@example.com"])' - }) - .reply(200, { - data: [{ id: 'XYZABC' }, { id: 'XYZABD' }] - }) - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfileFromList', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - }) - - it('should remove multiple profiles with valid external IDs', async () => { - const events = [ - createTestEvent({ - properties: { - external_id: 'externalId1' - } - }), - createTestEvent({ - properties: { - external_id: 'externalId2' - } - }) - ] - - const mapping = { - list_id: listId, - external_id: { - '@path': '$.properties.external_id' - } - } - - nock(`${API_URL}`) - .get('/profiles/') - .query({ - filter: 'any(external_id,["externalId1","externalId2"])' - }) - .reply(200, { - data: [{ id: 'XYZABC' }, { id: 'XYZABD' }] - }) - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfileFromList', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - }) - - it('should remove multiple profiles with valid phone numbers', async () => { - const events = [ - createTestEvent({ - properties: { - phone: '+15005435907' - } - }), - createTestEvent({ - properties: { - phone: '+15005435908' - } - }) - ] - const mapping = { - list_id: listId, - phone_number: { - '@path': '$.properties.phone' - } - } - - nock(`${API_URL}/profiles`) - .get(`/?filter=any(phone_number,["+15005435907","+15005435908"])`) - .reply(200, { - data: [{ id: 'XYZABC' }, { id: 'XYZABD' }] - }) - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfileFromList', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - }) - - it('should remove profiles with valid emails, phone numbers and external IDs', async () => { - const events = [ - createTestEvent({ - properties: { - email: 'user1@example.com' - } - }), - createTestEvent({ - properties: { - external_id: 'externalId2' - } - }), - createTestEvent({ - properties: { - phone: '+15005435907' - } - }) - ] - - const mapping = { - list_id: listId, - external_id: { - '@path': '$.properties.external_id' - }, - email: { - '@path': '$.properties.email' - }, - phone_number: { - '@path': '$.properties.phone' - } - } - - nock(`${API_URL}`) - .get('/profiles/') - .query({ - filter: 'any(email,["user1@example.com"])' - }) - .reply(200, { - data: [{ id: 'XYZABD' }] - }) - - nock(`${API_URL}`) - .get('/profiles/') - .query({ - filter: 'any(external_id,["externalId2"])' - }) - .reply(200, { - data: [{ id: 'XYZABC' }] - }) - - const phone_number = '+15005435907' - nock(`${API_URL}/profiles`) - .get(`/?filter=any(phone_number,["${phone_number}"])`) - .reply(200, { - data: [{ id: 'XYZABC' }] - }) - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfileFromList', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - }) - - it('should filter out profiles without email, phone number or external ID', async () => { - const events = [ - createTestEvent({ - properties: { - fake: 'property' - } - }), - createTestEvent({ - properties: { - email: 'valid@example.com' - } - }) - ] - - const mapping = { - list_id: listId, - external_id: { - '@path': '$.properties.external_id' - }, - email: { - '@path': '$.properties.email' - } - } - - const requestBody = { - data: [ - { - type: 'profile', - id: 'XYZABC' - } - ] - } - - nock(`${API_URL}`) - .get('/profiles/') - .query({ - filter: 'any(email,["valid@example.com"])' - }) - .reply(200, { - data: [{ id: 'XYZABC' }] - }) - - nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) - - await expect( - testDestination.testBatchAction('removeProfileFromList', { - settings, - events, - mapping - }) - ).resolves.not.toThrowError() - }) - - it('should handle an empty payload', async () => { - const getProfilesMock = jest.spyOn(Functions, 'getProfiles').mockImplementation(jest.fn()) - const removeProfileFromListMock = jest.spyOn(Functions, 'removeProfileFromList').mockImplementation() - - await testDestination.testBatchAction('removeProfileFromList', { - settings, - events: [] - }) - - expect(Functions.getProfiles).not.toHaveBeenCalled() - expect(Functions.removeProfileFromList).not.toHaveBeenCalled() - - getProfilesMock.mockRestore() - removeProfileFromListMock.mockRestore() - }) -}) diff --git a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/generated-types.ts b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/generated-types.ts index 066c72f39a..88eef8952c 100644 --- a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/generated-types.ts +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/generated-types.ts @@ -25,4 +25,8 @@ export interface Payload { * Country Code in ISO 3166-1 alpha-2 format. If provided, this will be used to validate and automatically format Phone Number field in E.164 format accepted by Klaviyo. */ country_code?: string + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number } diff --git a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/index.ts b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/index.ts index 55eec036af..b9df8902fa 100644 --- a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/index.ts +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/index.ts @@ -2,8 +2,8 @@ import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' import type { Settings } from '../generated-types' import { Payload } from './generated-types' -import { getProfiles, processPhoneNumber, removeProfileFromList, validateAndConvertPhoneNumber } from '../functions' -import { email, list_id, external_id, enable_batching, phone_number, country_code } from '../properties' +import { getProfiles, processPhoneNumber, removeBulkProfilesFromList, removeProfileFromList } from '../functions' +import { email, list_id, external_id, enable_batching, phone_number, country_code, batch_size } from '../properties' const action: ActionDefinition = { title: 'Remove Profile from List (Engage)', @@ -15,7 +15,8 @@ const action: ActionDefinition = { list_id: { ...list_id }, phone_number: { ...phone_number }, enable_batching: { ...enable_batching }, - country_code: { ...country_code } + country_code: { ...country_code }, + batch_size: { ...batch_size, default: 1000 } }, perform: async (request, { payload }) => { const { email, list_id, external_id, phone_number: initialPhoneNumber, country_code } = payload @@ -32,32 +33,7 @@ const action: ActionDefinition = { return await removeProfileFromList(request, profileIds, list_id) }, performBatch: async (request, { payload }) => { - // Filtering out profiles that do not contain either an email, phone_number or external_id. - const filteredPayload = payload.filter((profile) => { - // Validate and convert the phone number using the provided country code - const validPhoneNumber = validateAndConvertPhoneNumber(profile.phone_number, profile.country_code) - - // If the phone number is valid, update the profile's phone number with the validated format - if (validPhoneNumber) { - profile.phone_number = validPhoneNumber - } - // If the phone number is invalid (null), exclude this profile - else if (validPhoneNumber === null) { - return false - } - return profile.email || profile.external_id || profile.phone_number - }) - const listId = filteredPayload[0]?.list_id - const emails = filteredPayload.map((profile) => profile.email).filter((email) => email) as string[] - const externalIds = filteredPayload - .map((profile) => profile.external_id) - .filter((external_id) => external_id) as string[] - const phoneNumbers = filteredPayload - .map((profile) => profile.phone_number) - .filter((phone_number) => phone_number) as string[] - - const profileIds = await getProfiles(request, emails, externalIds, phoneNumbers) - return await removeProfileFromList(request, profileIds, listId) + return await removeBulkProfilesFromList(request, payload) } }