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 340f90df9df..8c842b86961 100644 --- a/packages/destination-actions/src/destinations/klaviyo/__tests__/multistatus.test.ts +++ b/packages/destination-actions/src/destinations/klaviyo/__tests__/multistatus.test.ts @@ -257,6 +257,48 @@ describe('MultiStatus', () => { errorreporter: 'INTEGRATIONS' }) }) + + it('should reject events where external_id exceeds 255 characters', async () => { + nock(API_URL).post('/event-bulk-create-jobs/').reply(202, {}) + + const longExternalId = 'a'.repeat(256) + const events: SegmentEvent[] = [ + // Event with external_id exceeding 255 characters + createTestEvent({ + type: 'track', + timestamp, + properties: { + external_id: longExternalId + } + }), + // Valid Event + createTestEvent({ + type: 'track', + timestamp, + properties: { + email: 'valid@gmail.com' + } + }) + ] + + const response = await testDestination.executeBatch('trackEvent', { + events, + settings, + mapping + }) + + expect(response[0]).toMatchObject({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: 'Length of external_id must be no more than 255 characters.', + errorreporter: 'INTEGRATIONS' + }) + + expect(response[1]).toMatchObject({ + status: 200, + body: '{}' + }) + }) }) describe('addProfileToList', () => { beforeEach(() => { diff --git a/packages/destination-actions/src/destinations/klaviyo/addProfileToList/generated-types.ts b/packages/destination-actions/src/destinations/klaviyo/addProfileToList/generated-types.ts index 669a43d79ab..cc9a70db301 100644 --- a/packages/destination-actions/src/destinations/klaviyo/addProfileToList/generated-types.ts +++ b/packages/destination-actions/src/destinations/klaviyo/addProfileToList/generated-types.ts @@ -14,7 +14,7 @@ export interface Payload { */ list_id: string /** - * A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system. One of External ID and Email required. + * A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system. One of External ID, Email or Phone Number is required. Must not exceed 255 characters. */ external_id?: string /** diff --git a/packages/destination-actions/src/destinations/klaviyo/config.ts b/packages/destination-actions/src/destinations/klaviyo/config.ts index a41f7ffb36c..925f84a8e05 100644 --- a/packages/destination-actions/src/destinations/klaviyo/config.ts +++ b/packages/destination-actions/src/destinations/klaviyo/config.ts @@ -2,6 +2,7 @@ import { KLAVIYO_REVISION_DATE } from './versioning-info' export const API_URL = 'https://a.klaviyo.com/api' export const REVISION_DATE = KLAVIYO_REVISION_DATE +export const MAX_EXTERNAL_ID_LENGTH = 255 export const COUNTRY_CODES = [ { label: 'AD - Andorra', value: 'AD' }, { label: 'AE - United Arab Emirates', value: 'AE' }, diff --git a/packages/destination-actions/src/destinations/klaviyo/functions.ts b/packages/destination-actions/src/destinations/klaviyo/functions.ts index bb0efcaf0c9..03f9f871c84 100644 --- a/packages/destination-actions/src/destinations/klaviyo/functions.ts +++ b/packages/destination-actions/src/destinations/klaviyo/functions.ts @@ -10,7 +10,7 @@ import { ErrorCodes, StatsContext } from '@segment/actions-core' -import { API_URL, REVISION_DATE } from './config' +import { API_URL, REVISION_DATE, MAX_EXTERNAL_ID_LENGTH } from './config' import { Settings } from './generated-types' import { KlaviyoAPIError, @@ -40,6 +40,18 @@ import { ActionDestinationErrorResponseType } from '@segment/actions-core/destin const phoneUtil = PhoneNumberUtil.getInstance() +const EXTERNAL_ID_LENGTH_ERROR: ActionDestinationErrorResponseType = { + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: `Length of external_id must be no more than ${MAX_EXTERNAL_ID_LENGTH} characters.` +} + +export function validateExternalId(externalId: string | undefined): void { + if (externalId && externalId.length > MAX_EXTERNAL_ID_LENGTH) { + throw new PayloadValidationError(EXTERNAL_ID_LENGTH_ERROR.errormessage) + } +} + export async function getListIdDynamicData(request: RequestClient): Promise { try { const result: ListIdResponse = await request(`${API_URL}/lists/`, { @@ -99,6 +111,7 @@ export async function createProfile( phone_number: string | undefined, additionalAttributes: AdditionalAttributes ) { + validateExternalId(external_id) try { const profileData: ProfileData = { data: { @@ -586,6 +599,11 @@ function validateAndConstructRemoveProfilePayloads(payload: RemoveProfilePayload return response } + if (external_id && external_id.length > MAX_EXTERNAL_ID_LENGTH) { + response.error = EXTERNAL_ID_LENGTH_ERROR + return response + } + if (phone_number) { const validPhoneNumber = validateAndConvertPhoneNumber(phone_number, payload.country_code as string) if (!validPhoneNumber) { @@ -616,6 +634,11 @@ function validateAndConstructProfilePayload(payload: AddProfileToListPayload): { return response } + if (external_id && external_id.length > MAX_EXTERNAL_ID_LENGTH) { + response.error = EXTERNAL_ID_LENGTH_ERROR + return response + } + if (phone_number) { const validPhoneNumber = validateAndConvertPhoneNumber(phone_number, payload.country_code as string) if (!validPhoneNumber) { @@ -756,20 +779,22 @@ function validateAndPreparePayloads(payloads: TrackEventPayload[], multiStatusRe return } + if (external_id && external_id.length > MAX_EXTERNAL_ID_LENGTH) { + multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, EXTERNAL_ID_LENGTH_ERROR) + return + } + if (phone_number) { - // Validate and convert the phone number if present const validPhoneNumber = validateAndConvertPhoneNumber(phone_number, country_code as string) - // If the phone number is not valid, skip this payload if (!validPhoneNumber) { multiStatusResponse.setErrorResponseAtIndex(originalBatchIndex, { status: 400, errortype: 'PAYLOAD_VALIDATION_FAILED', errormessage: 'Phone number could not be converted to E.164 format.' }) - return // Skip this payload + return } - // Update the payload's phone number with the validated format payload.profile.phone_number = validPhoneNumber delete payload?.profile?.country_code } @@ -920,6 +945,11 @@ export function validateProfilePayload(payload: Payload): validateProfilePayload return response } + if (payload.external_id && payload.external_id.length > MAX_EXTERNAL_ID_LENGTH) { + response.error = EXTERNAL_ID_LENGTH_ERROR + return response + } + if (payload.phone_number) { const validPhoneNumber = validateAndConvertPhoneNumber(payload.phone_number, payload.country_code as string) if (!validPhoneNumber) { diff --git a/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/index.test.ts index 5f1967d7b9c..3bf60152f51 100644 --- a/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/index.test.ts @@ -53,6 +53,23 @@ describe('Order Completed', () => { ) }) + it('should throw error if external_id exceeds 255 characters', async () => { + const event = createTestEvent({ + type: 'track', + timestamp: '2022-01-01T00:00:00.000Z' + }) + + const mapping = { + profile: { external_id: 'a'.repeat(256) }, + metric_name: 'Order Completed', + properties: { key: 'value' } + } + + await expect(testDestination.testAction('orderCompleted', { event, mapping, settings })).rejects.toThrowError( + 'Length of external_id must be no more than 255 characters.' + ) + }) + it('should throw an error for invalid phone number format', async () => { const profile = { email: 'test@example.com', phone_number: 'invalid-phone-number', country_code: 'US' } const properties = { key: 'value' } diff --git a/packages/destination-actions/src/destinations/klaviyo/orderCompleted/index.ts b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/index.ts index 3417988c41b..49d07a974ea 100644 --- a/packages/destination-actions/src/destinations/klaviyo/orderCompleted/index.ts +++ b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/index.ts @@ -5,7 +5,7 @@ import { PayloadValidationError, RequestClient } from '@segment/actions-core' import { API_URL } from '../config' import { EventData } from '../types' import { v4 as uuidv4 } from '@lukeed/uuid' -import { processPhoneNumber } from '../functions' +import { processPhoneNumber, validateExternalId } from '../functions' import { country_code } from '../properties' import dayjs from 'dayjs' @@ -176,6 +176,7 @@ const action: ActionDefinition = { if (!email && !phone_number && !external_id && !anonymous_id) { throw new PayloadValidationError('One of External ID, Anonymous ID, Phone Number or Email is required.') } + validateExternalId(external_id) const eventData = createEventData(payload) diff --git a/packages/destination-actions/src/destinations/klaviyo/properties.ts b/packages/destination-actions/src/destinations/klaviyo/properties.ts index c0ae56e66b1..74662e30ba7 100644 --- a/packages/destination-actions/src/destinations/klaviyo/properties.ts +++ b/packages/destination-actions/src/destinations/klaviyo/properties.ts @@ -30,8 +30,10 @@ export const email: InputField = { export const external_id: InputField = { label: 'External ID', - description: `A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system. One of External ID and Email required.`, - type: 'string' + description: `A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system. One of External ID, Email or Phone Number is required. Must not exceed 255 characters.`, + type: 'string', + minimum: 0, + maximum: 255 } export const enable_batching: InputField = { 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 991263b9246..89661890630 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 @@ -25,6 +25,22 @@ describe('Remove Profile', () => { ) }) + it('should throw error if external_id exceeds 255 characters', async () => { + const event = createTestEvent({ + type: 'track', + properties: {} + }) + + const mapping = { + list_id: listId, + external_id: 'a'.repeat(256) + } + + await expect(testDestination.testAction('removeProfile', { event, mapping, settings })).rejects.toThrowError( + 'Length of external_id must be no more than 255 characters.' + ) + }) + it('should throw an error for invalid phone number format', async () => { const event = createTestEvent({ type: 'track', diff --git a/packages/destination-actions/src/destinations/klaviyo/removeProfile/index.ts b/packages/destination-actions/src/destinations/klaviyo/removeProfile/index.ts index 7aa007b937a..0c9eef95723 100644 --- a/packages/destination-actions/src/destinations/klaviyo/removeProfile/index.ts +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfile/index.ts @@ -6,6 +6,7 @@ import { getListIdDynamicData, getProfiles, processPhoneNumber, + validateExternalId, removeBulkProfilesFromList, removeProfileFromList } from '../functions' @@ -67,6 +68,7 @@ const action: ActionDefinition = { if (!email && !external_id && !phone_number) { throw new PayloadValidationError('One of External ID, Phone Number and Email is required.') } + validateExternalId(external_id) const profileIds = await getProfiles( request, email ? [email] : undefined, 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 4201cfcf445..addc34f1246 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 @@ -1,8 +1,8 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { AggregateAjvError } from '@segment/ajv-human-errors' import Definition from '../../index' import { API_URL } from '../../config' -import { AggregateAjvError } from '@segment/ajv-human-errors' const testDestination = createTestIntegration(Definition) @@ -25,6 +25,22 @@ describe('Remove List from Profile', () => { ) }) + it('should throw error if external_id exceeds 255 characters', async () => { + const event = createTestEvent({ + type: 'track', + properties: {} + }) + + const mapping = { + list_id: listId, + external_id: 'a'.repeat(256) + } + + await expect( + testDestination.testAction('removeProfileFromList', { event, mapping, settings }) + ).rejects.toThrowError(AggregateAjvError) + }) + it('should throw an error for invalid phone number format', async () => { const event = createTestEvent({ type: 'track', 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 055596f972b..dd062d87a2f 100644 --- a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/generated-types.ts +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/generated-types.ts @@ -6,7 +6,7 @@ export interface Payload { */ email?: string /** - * A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system. One of External ID and Email required. + * A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system. One of External ID, Email or Phone Number is required. Must not exceed 255 characters. */ external_id?: string /** diff --git a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/index.ts b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/index.ts index be73107fa09..00dc73cd1d0 100644 --- a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/index.ts +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/index.ts @@ -2,7 +2,13 @@ import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' import type { Settings } from '../generated-types' import { Payload } from './generated-types' -import { getProfiles, processPhoneNumber, removeBulkProfilesFromList, removeProfileFromList } from '../functions' +import { + getProfiles, + processPhoneNumber, + validateExternalId, + removeBulkProfilesFromList, + removeProfileFromList +} from '../functions' import { email, list_id, external_id, enable_batching, phone_number, country_code, batch_size } from '../properties' const action: ActionDefinition = { @@ -33,6 +39,7 @@ const action: ActionDefinition = { if (!email && !external_id && !phone_number) { throw new PayloadValidationError('One of External ID, Phone Number and Email is required.') } + validateExternalId(external_id) const profileIds = await getProfiles( request, email ? [email] : undefined, diff --git a/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/index.test.ts index 1a319310a53..3c7e7cf64bb 100644 --- a/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/index.test.ts @@ -28,6 +28,23 @@ describe('Track Event', () => { ) }) + it('should throw error if external_id exceeds 255 characters', async () => { + const event = createTestEvent({ + type: 'track', + timestamp: '2022-01-01T00:00:00.000Z' + }) + + const mapping = { + profile: { external_id: 'a'.repeat(256) }, + metric_name: 'Test Event', + properties: {} + } + + await expect(testDestination.testAction('trackEvent', { event, mapping, settings })).rejects.toThrowError( + 'Length of external_id must be no more than 255 characters.' + ) + }) + it('should throw an error for invalid phone number format', async () => { const profile = { email: 'test@example.com', phone_number: 'invalid-phone-number', country_code: 'US' } const properties = { key: 'value' } diff --git a/packages/destination-actions/src/destinations/klaviyo/trackEvent/index.ts b/packages/destination-actions/src/destinations/klaviyo/trackEvent/index.ts index 1a8fc078057..3e3c0cb5b42 100644 --- a/packages/destination-actions/src/destinations/klaviyo/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/klaviyo/trackEvent/index.ts @@ -4,7 +4,7 @@ import type { Payload } from './generated-types' import { PayloadValidationError } from '@segment/actions-core' import { API_URL } from '../config' import { batch_size, enable_batching, country_code } from '../properties' -import { processPhoneNumber, sendBatchedTrackEvent } from '../functions' +import { processPhoneNumber, sendBatchedTrackEvent, validateExternalId } from '../functions' import dayjs from '../../../lib/dayjs' const action: ActionDefinition = { @@ -106,6 +106,7 @@ const action: ActionDefinition = { if (!email && !phone_number && !external_id && !anonymous_id) { throw new PayloadValidationError('One of External ID, Anonymous ID, Phone Number or Email is required.') } + validateExternalId(external_id) const eventData = { data: { type: 'event', diff --git a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/index.test.ts index 6050e086133..d787f59fb7e 100644 --- a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/index.test.ts @@ -50,6 +50,44 @@ describe('Upsert Profile', () => { ) }) + it('should throw error if external_id exceeds 255 characters', async () => { + const longExternalId = 'a'.repeat(256) + const event = createTestEvent({ + type: 'identify', + traits: { + email: 'test@example.com' + } + }) + + const mapping = { + external_id: longExternalId, + email: { '@path': '$.traits.email' } + } + + await expect(testDestination.testAction('upsertProfile', { event, settings, mapping })).rejects.toThrowError( + 'Length of external_id must be no more than 255 characters.' + ) + }) + + it('should not throw error if external_id is exactly 255 characters', async () => { + const exactExternalId = 'a'.repeat(255) + const event = createTestEvent({ + type: 'identify', + traits: { + email: 'test@example.com' + } + }) + + const mapping = { + external_id: exactExternalId, + email: { '@path': '$.traits.email' } + } + + nock(`${API_URL}`).post('/profiles/').reply(200, {}) + + await expect(testDestination.testAction('upsertProfile', { event, settings, mapping })).resolves.not.toThrowError() + }) + it('should throw an error for invalid phone number format in perform', async () => { const event = createTestEvent({ type: 'identify', diff --git a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/index.ts b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/index.ts index a82e0ab6c8a..31a18ebff0c 100644 --- a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/index.ts +++ b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/index.ts @@ -13,6 +13,7 @@ import { getList, createList, processPhoneNumber, + validateExternalId, validateProfilePayload, updateMultiStatusWithSuccessData, updateMultiStatusWithKlaviyoErrors @@ -260,6 +261,8 @@ const action: ActionDefinition = { throw new PayloadValidationError('One of External ID, Phone Number and Email is required.') } + validateExternalId(external_id) + const profileData: ProfileData = { data: { type: 'profile',