diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap index 82c1b98383a..66bf43c8165 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,51 +2,57 @@ exports[`Testing snapshot for actions-reddit-conversions-api destination: customEvent action - all fields 1`] = ` Object { - "events": Array [ - Object { - "click_id": "Q1Q49h4LP@", - "event_at": "2021-02-01T00:00:00.000Z", - "event_metadata": Object { - "conversion_id": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", - "currency": "HNL", - "item_count": -2840640572358656, - "products": Array [ - Object { - "category": "Q1Q49h4LP@", - "id": "Q1Q49h4LP@", - "name": "Q1Q49h4LP@", - }, - ], - "value_decimal": -28406405723586.56, - }, - "event_type": Object { - "custom_event_name": "Q1Q49h4LP@", - "tracking_type": "Custom", - }, - "user": Object { - "aaid": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", - "data_processing_options": Object { - "country": "GM", - "modes": Array [ - "LDU", + "data": Object { + "events": Array [ + Object { + "action_source": "APP", + "click_id": "Q1Q49h4LP@", + "event_at": 1612137600000, + "event_source_url": "Q1Q49h4LP@", + "metadata": Object { + "conversion_id": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", + "currency": "HNL", + "item_count": -2840640572358656, + "products": Array [ + Object { + "category": "Q1Q49h4LP@", + "id": "Q1Q49h4LP@", + "item_price": -28406405723586.56, + "name": "Q1Q49h4LP@", + "quantity": -2840640572358656, + }, ], - "region": "Q1Q49h4LP@", + "value": -28406405723586.56, }, - "email": "8614950525b98787b31febe07634987a801175f098b60c83bd3903bff1ec3eed", - "external_id": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", - "ip_address": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", - "phone_number": "781a379f97f79178f343a118a245e179b55a414b1b959ef25376dcb43bfe61f6", - "screen_dimensions": Object { - "height": -2840640572358656, - "width": -2840640572358656, + "type": Object { + "custom_event_name": "Q1Q49h4LP@", + "tracking_type": "CUSTOM", + }, + "user": Object { + "aaid": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", + "data_processing_options": Object { + "country": "GM", + "modes": Array [ + "LDU", + ], + "region": "Q1Q49h4LP@", + }, + "email": "8614950525b98787b31febe07634987a801175f098b60c83bd3903bff1ec3eed", + "external_id": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", + "ip_address": "4fbe858b31ee34144cc3d009fd4636ef20fb342e095570d49a9898bb8d77598e", + "phone_number": "781a379f97f79178f343a118a245e179b55a414b1b959ef25376dcb43bfe61f6", + "screen_dimensions": Object { + "height": -2840640572358656, + "width": -2840640572358656, + }, + "user_agent": "Q1Q49h4LP@", + "uuid": "Q1Q49h4LP@", }, - "user_agent": "Q1Q49h4LP@", - "uuid": "Q1Q49h4LP@", }, - }, - ], - "partner": "SEGMENT", - "test_mode": true, + ], + "partner": "SEGMENT", + "test_id": "Q1Q49h4LP@", + }, } `; @@ -84,7 +90,9 @@ Object { Object { "category": "gT6gXh8ga!%xhI", "id": "gT6gXh8ga!%xhI", + "item_price": 20910002898206.72, "name": "gT6gXh8ga!%xhI", + "quantity": 2091000289820672, }, ], "value_decimal": 20910002898206.72, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/utils.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/utils.test.ts new file mode 100644 index 00000000000..99e0ff15903 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/utils.test.ts @@ -0,0 +1,17 @@ +import { getMetadata } from '../utils' + +describe('getMetadata', () => { + it('drops currency/value_decimal/item_count for tracking types that don\'t support any event metadata', () => { + const result = getMetadata({ currency: 'USD', item_count: 5, value_decimal: 10 }, undefined, undefined, 'Search') + expect(result?.currency).toBeUndefined() + expect(result?.item_count).toBeUndefined() + expect(result?.value_decimal).toBeUndefined() + }) + + it('drops item_count but keeps currency/value_decimal for Lead/SignUp', () => { + const result = getMetadata({ currency: 'USD', item_count: 5, value_decimal: 10 }, undefined, undefined, 'Lead') + expect(result?.currency).toBe('USD') + expect(result?.value_decimal).toBe(10) + expect(result?.item_count).toBeUndefined() + }) +}) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts new file mode 100644 index 00000000000..d27cff8878f --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-batch-events.test.ts @@ -0,0 +1,158 @@ +import crypto from 'crypto' +import nock from 'nock' +import { createTestEvent, createTestIntegration, JSONObject, SegmentEvent } from '@segment/actions-core' +import Definition from '../index' +import { Settings } from '../generated-types' +import { LEGACY_API_VERSION } from '../versioning-info' + +const testDestination = createTestIntegration(Definition) +const timestamp = '2024-01-08T13:52:50.212Z' +const epochMs = 1704721970212 +const settings: Settings = { + ad_account_id: 'ad_account_id_1', + conversion_token: 'conversion_token_1' +} + +// Matches processHashing('sha256', 'hex', (value) => value.trim()) in ../utils.ts +const sha256 = (value: string) => crypto.createHash('sha256').update(value.trim()).digest('hex') + +describe('Reddit Conversions Api - V3 batch events', () => { + it('handles a batch of 10: 2 fail schema validation, 2 fail Reddit-side validation inside performBatch, 6 succeed', async () => { + nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, {}) + + // Interleaved on purpose so the 6 successes aren't all bunched at one end of the batch - + // 'schemaInvalid' fails Segment's own schema validation (action_source is conditionally required + // when api_version is v3) and never reaches performBatch; 'businessInvalid' passes schema (has + // action_source) but is missing products.id, which is only enforced inside performBatch by + // toProductIdV3; 'valid' should succeed end to end. + const kinds = [ + 'valid', + 'schemaInvalid', + 'valid', + 'businessInvalid', + 'valid', + 'schemaInvalid', + 'valid', + 'businessInvalid', + 'valid', + 'valid' + ] as const + + const events: SegmentEvent[] = kinds.map((kind, i) => { + const properties: JSONObject = { revenue: 100 } + if (kind !== 'schemaInvalid') properties.action_source = 'WEBSITE' + properties.products = + kind === 'businessInvalid' + ? [{ category: `c${i}`, name: `n${i}` }] + : [{ product_id: `p${i}`, category: `c${i}`, name: `n${i}` }] + + return createTestEvent({ + timestamp, + event: 'Order Completed', + messageId: `msg-${i}`, + type: 'track', + userId: `user_id_${i}`, + properties + }) + }) + + await testDestination.testBatchAction('standardEvent', { + events, + settings, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase', + api_version: 'v3', + action_source: { '@path': '$.properties.action_source' } + } + }) + + const multistatus = testDestination.results.at(0)?.multistatus + expect(multistatus).toHaveLength(10) + + kinds.forEach((kind, i) => { + if (kind === 'schemaInvalid') { + // Schema validation failure (Segment core, before performBatch is ever called). + expect(multistatus?.[i]).toEqual({ + status: 400, + errortype: 'PAYLOAD_VALIDATION_FAILED', + errormessage: + 'The root value is missing the required field \'action_source\'. The root value must match "then" schema.', + errorreporter: 'INTEGRATIONS' + }) + } else if (kind === 'businessInvalid') { + // Reddit-side validation failure (our own code, inside performBatch). + expect(multistatus?.[i]).toEqual({ + status: 400, + errortype: 'BAD_REQUEST', + errormessage: 'products.id is required when sending to Reddit Conversions API v3', + errorreporter: 'INTEGRATIONS' + }) + } else { + expect(multistatus?.[i]).toEqual({ + status: 200, + sent: { + event_at: epochMs, + action_source: 'WEBSITE', + event_source_url: 'https://segment.com/academy/', + type: { + tracking_type: 'PURCHASE' + }, + metadata: { + value: 100, + products: [ + { + category: `c${i}`, + id: `p${i}`, + name: `n${i}` + } + ], + conversion_id: sha256(`msg-${i}`) + }, + user: { + external_id: sha256(`user_id_${i}`), + ip_address: sha256('8.8.8.8'), + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + } + }, + body: { success: true } + }) + } + }) + }) + + it('returns a plain response (no MultiStatusResponse) for a pure V2 batch', async () => { + nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + + const events: SegmentEvent[] = [0, 1, 2].map((i) => + createTestEvent({ + timestamp, + event: 'Order Completed', + messageId: `msg-v2-${i}`, + type: 'track', + userId: `user_id_${i}`, + properties: { revenue: 100 } + }) + ) + + await testDestination.testBatchAction('standardEvent', { + events, + settings, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase', + api_version: LEGACY_API_VERSION + } + }) + + // A pure-V2 batch never builds our own MultiStatusResponse - performBatch just returns the + // plain send() response, so core falls back to its own legacy "whole batch response" handling + // (fillMultiStatusResponse), marking every index success with the same status/body. + const multistatus = testDestination.results.at(0)?.multistatus + expect(multistatus).toHaveLength(3) + multistatus?.forEach((entry) => { + expect(entry).toMatchObject({ status: 200, body: {} }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-single-event.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-single-event.test.ts new file mode 100644 index 00000000000..a0cc4eacd27 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-single-event.test.ts @@ -0,0 +1,187 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../index' +import { Settings } from '../generated-types' + +const testDestination = createTestIntegration(Definition) +const timestamp = '2024-01-08T13:52:50.212Z' +const settings: Settings = { + ad_account_id: 'ad_account_id_1', + conversion_token: 'conversion_token_1' +} + +describe('Reddit Conversions Api - V3 single event', () => { + it('should send a Purchase Standard event to v3 when api_version is v3 and action_source is set', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: { + click_id: 'click_id_1', + currency: 'USD', + quantity: 10, + revenue: 100, + uuid: 'uuid_1', + products: [{ product_id: 'product_id_1', category: 'category_1', name: 'name_1', quantity: 2, price: 25 }], + email: 'test@test.com' + }, + context: { + userAgent: 'test-user-agent', + ip: '111.111.111.111', + page: { url: 'https://example.com/checkout' } + } + }) + + nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, {}) + const responses = await testDestination.testAction('standardEvent', { + event, + settings: { ...settings, test_id: 'test-123' }, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase', + api_version: 'v3', + action_source: 'WEBSITE' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + data: { + partner: 'SEGMENT', + test_id: 'test-123', + events: [ + { + action_source: 'WEBSITE', + event_source_url: 'https://example.com/checkout', + click_id: 'click_id_1', + event_at: 1704721970212, + type: { + tracking_type: 'PURCHASE' + }, + metadata: { + currency: 'USD', + item_count: 10, + value: 100, + products: [ + { + category: 'category_1', + id: 'product_id_1', + name: 'name_1', + quantity: 2, + item_price: 25 + } + ] + } + } + ] + } + }) + }) + + it('should route a Custom event to v3 with UPPER_SNAKE_CASE tracking_type', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Some Custom Event Name', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: {} + }) + + nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, {}) + const responses = await testDestination.testAction('customEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + custom_event_name: 'Some Custom Event Name', + api_version: 'v3', + action_source: 'APP' + } + }) + + expect(responses.length).toBe(1) + const body = responses[0].options.json as { data: { events: Array<{ type: { tracking_type: string } }> } } + expect(body.data.events[0].type.tracking_type).toBe('CUSTOM') + }) + + it('should stay on v2 when api_version is not set (existing customers)', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: { revenue: 100 } + }) + + nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + // api_version is genuinely absent from the mapping here - that's the real "existing customer, + // pre-dates this field" shape. A literal '' would fail the field's enum (['v3', 'v2.0']) + // validation before perform() ever runs, so it can't be used to exercise this scenario. + const responses = await testDestination.testAction('standardEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should reject the mapping when api_version is v3 but action_source is not set', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: { revenue: 100 } + }) + + await expect( + testDestination.testAction('standardEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase', + api_version: 'v3' + } + }) + ).rejects.toThrow("The root value is missing the required field 'action_source'.") + }) + + it('should throw when a product is missing an id (fails Destination-side validation, not schema validation)', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-contact', + type: 'track', + userId: 'user_id_1', + properties: { + revenue: 100, + products: [{ category: 'category_1', name: 'name_1' }] + } + }) + + await expect( + testDestination.testAction('standardEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + tracking_type: 'Purchase', + api_version: 'v3', + action_source: 'WEBSITE' + } + }) + ).rejects.toThrow('products.id is required when sending to Reddit Conversions API v3') + }) +}) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts new file mode 100644 index 00000000000..39446702776 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/v3-utils.test.ts @@ -0,0 +1,329 @@ +import crypto from 'crypto' +import nock from 'nock' +import { MultiStatusResponse } from '@segment/actions-core' +import createRequestClient from '../../../../../core/src/create-request-client' +import { + sendV3, + createRedditPayloadV3, + toEpochMs, + toV3TrackingType, + toActionSourceV3, + getProducts, + toProductIdV3, + getMetadata +} from '../v3/utils-v3' +import type { Settings } from '../generated-types' +import type { Payload as StandardEvent } from '../standardEvent/generated-types' + +const settings: Settings = { + ad_account_id: 'ad_account_id_1', + conversion_token: 'conversion_token_1' +} + +// Matches smartHash(conversion_id, (value) => value.trim()) in ../v3/utils-v3.ts +const sha256 = (value: string) => crypto.createHash('sha256').update(value.trim()).digest('hex') + +function buildPayload(overrides: Partial = {}): StandardEvent { + return { + event_at: 1704721970212, + tracking_type: 'Purchase', + action_source: 'WEBSITE', + ...overrides + } +} + +describe('toEpochMs', () => { + it('passes through an epoch-ms number unchanged', () => { + expect(toEpochMs(1704721970212)).toBe(1704721970212) + }) + + it('passes through an epoch-ms numeric string unchanged', () => { + expect(toEpochMs('1704721970212')).toBe(1704721970212) + }) + + it('parses an ISO 8601 timestamp with milliseconds into epoch ms', () => { + expect(toEpochMs('2024-01-08T13:52:50.212Z')).toBe(1704721970212) + }) + + it('parses an ISO 8601 timestamp with no milliseconds into epoch ms', () => { + expect(toEpochMs('2024-01-08T13:52:50Z')).toBe(1704721970000) + }) + + it('parses an ISO 8601 timestamp with a positive UTC offset into epoch ms', () => { + expect(toEpochMs('2024-01-08T18:52:50.212+05:00')).toBe(1704721970212) + }) + + it('parses an ISO 8601 timestamp with a negative UTC offset into epoch ms', () => { + expect(toEpochMs('2024-01-08T08:52:50.212-05:00')).toBe(1704721970212) + }) + + it('parses a date-only ISO 8601 string as midnight UTC', () => { + expect(toEpochMs('2024-01-08')).toBe(1704672000000) + }) + + it('throws when value is undefined', () => { + expect(() => toEpochMs(undefined)).toThrow('event_at is required') + }) + + it('throws when value is an empty string', () => { + expect(() => toEpochMs('')).toThrow('event_at is required') + }) + + it('throws when a numeric string is below the epoch-ms floor (looks like epoch seconds)', () => { + expect(() => toEpochMs('1704721970')).toThrow( + 'event_at must be an ISO 8601 timestamp or epoch milliseconds, received: 1704721970' + ) + }) + + it('throws when a number is below the epoch-ms floor', () => { + expect(() => toEpochMs(1704721970)).toThrow( + 'event_at must be an ISO 8601 timestamp or epoch milliseconds, received: 1704721970' + ) + }) + + it('throws when the value is an unparseable string', () => { + expect(() => toEpochMs('not-a-date')).toThrow( + 'event_at must be an ISO 8601 timestamp or epoch milliseconds, received: not-a-date' + ) + }) +}) + +describe('toV3TrackingType', () => { + it('maps a v2 tracking_type to its v3 UPPER_SNAKE_CASE equivalent', () => { + expect(toV3TrackingType('Purchase')).toBe('PURCHASE') + expect(toV3TrackingType('PageVisit')).toBe('PAGE_VISIT') + expect(toV3TrackingType('Custom')).toBe('CUSTOM') + }) + + it('throws when tracking_type is undefined', () => { + expect(() => toV3TrackingType(undefined)).toThrow('tracking_type is required') + }) + + it('throws when tracking_type is not a supported value', () => { + expect(() => toV3TrackingType('NotARealTrackingType')).toThrow('Unsupported tracking_type: NotARealTrackingType') + }) +}) + +describe('toActionSourceV3', () => { + it('passes through a supported action_source', () => { + expect(toActionSourceV3('WEBSITE')).toBe('WEBSITE') + expect(toActionSourceV3('APP')).toBe('APP') + expect(toActionSourceV3('OTHER')).toBe('OTHER') + expect(toActionSourceV3('PHYSICAL_STORE')).toBe('PHYSICAL_STORE') + }) + + it('throws when action_source is undefined', () => { + expect(() => toActionSourceV3(undefined)).toThrow( + 'action_source is required when sending to Reddit Conversions API v3' + ) + }) + + it('throws when action_source is not a supported value', () => { + expect(() => toActionSourceV3('NOT_REAL')).toThrow('Unsupported action_source: NOT_REAL') + }) +}) + +describe('toProductIdV3', () => { + it('trims and passes through a valid id', () => { + expect(toProductIdV3(' product_id_1 ')).toBe('product_id_1') + }) + + it('throws when id is undefined', () => { + expect(() => toProductIdV3(undefined)).toThrow('products.id is required when sending to Reddit Conversions API v3') + }) + + it('throws when id is an empty string', () => { + expect(() => toProductIdV3('')).toThrow('products.id is required when sending to Reddit Conversions API v3') + }) +}) + +describe('getProducts', () => { + it('returns undefined when products is undefined', () => { + expect(getProducts(undefined)).toBeUndefined() + }) + + it('maps every product field, defaulting quantity/item_price through cleanNum', () => { + expect( + getProducts([ + { category: ' category_1 ', id: 'product_id_1', name: ' name_1 ', quantity: 2, item_price: 25 }, + { id: 'product_id_2' } + ]) + ).toEqual([ + { category: 'category_1', id: 'product_id_1', name: 'name_1', quantity: 2, item_price: 25 }, + { category: undefined, id: 'product_id_2', name: undefined, quantity: undefined, item_price: undefined } + ]) + }) + + it('throws when any product in the list is missing an id', () => { + expect(() => getProducts([{ id: 'product_id_1' }, { category: 'category_2' }])).toThrow( + 'products.id is required when sending to Reddit Conversions API v3' + ) + }) +}) + +describe('getMetadata', () => { + it('returns undefined when metadata, products, and conversion_id are all absent', () => { + expect(getMetadata(undefined, undefined, undefined)).toBeUndefined() + }) + + it('maps currency/item_count/value_decimal->value, and hashes conversion_id', () => { + const result = getMetadata({ currency: 'USD', item_count: 10, value_decimal: 100 }, undefined, 'msg-1') + expect(result?.currency).toBe('USD') + expect(result?.item_count).toBe(10) + expect(result?.value).toBe(100) + expect(result?.products).toBeUndefined() + // conversion_id is smartHash'd - assert the actual sha256 hex digest, not just its shape. + expect(result?.conversion_id).toBe(sha256('msg-1')) + }) + + it('is present (not undefined) when only products are provided', () => { + const result = getMetadata(undefined, [{ id: 'product_id_1' }], undefined) + expect(result).toEqual({ + currency: undefined, + item_count: undefined, + value: undefined, + products: [ + { category: undefined, id: 'product_id_1', name: undefined, quantity: undefined, item_price: undefined } + ], + conversion_id: undefined + }) + }) + + it('drops currency/value/item_count for tracking types that don\'t support any event metadata', () => { + const result = getMetadata({ currency: 'USD', item_count: 5, value_decimal: 10 }, undefined, undefined, 'Search') + expect(result?.currency).toBeUndefined() + expect(result?.item_count).toBeUndefined() + expect(result?.value).toBeUndefined() + }) + + it('drops item_count but keeps currency/value for Lead/SignUp', () => { + const result = getMetadata({ currency: 'USD', item_count: 5, value_decimal: 10 }, undefined, undefined, 'Lead') + expect(result?.currency).toBe('USD') + expect(result?.value).toBe(10) + expect(result?.item_count).toBeUndefined() + }) +}) + +describe('createRedditPayloadV3', () => { + it('builds a v3 event item for a single valid standardEvent payload and marks it success', () => { + const multiStatusResponse = new MultiStatusResponse() + const payload = buildPayload({ + event_at: '2024-01-08T13:52:50.212Z', + click_id: 'click_id_1', + event_source_url: 'https://example.com/checkout' + }) + + const result = createRedditPayloadV3([payload], settings, multiStatusResponse, false) + + expect(result).toEqual({ + data: { + partner: 'SEGMENT', + test_id: undefined, + events: [ + { + event_at: 1704721970212, + action_source: 'WEBSITE', + event_source_url: 'https://example.com/checkout', + click_id: 'click_id_1', + type: { tracking_type: 'PURCHASE', custom_event_name: undefined }, + metadata: undefined, + user: undefined + } + ] + } + }) + + expect(multiStatusResponse.isSuccessResponseAtIndex(0)).toBe(true) + expect(multiStatusResponse.getResponseAtIndex(0)).toMatchObject({ data: { status: 200, body: { success: true } } }) + }) + + it('routes settings.test_id onto the payload', () => { + const multiStatusResponse = new MultiStatusResponse() + const result = createRedditPayloadV3( + [buildPayload()], + { ...settings, test_id: 'test-123' }, + multiStatusResponse, + false + ) + expect(result.data.test_id).toBe('test-123') + }) + + it('throws for a single (non-batch) invalid payload instead of recording a MultiStatusResponse error', () => { + const multiStatusResponse = new MultiStatusResponse() + const payload = buildPayload({ action_source: undefined }) + + expect(() => createRedditPayloadV3([payload], settings, multiStatusResponse, false)).toThrow( + 'action_source is required when sending to Reddit Conversions API v3' + ) + expect(multiStatusResponse.length()).toBe(0) + }) + + it('for a batch, records a MultiStatusResponse error at the failing index and continues processing the rest', () => { + const multiStatusResponse = new MultiStatusResponse() + const payloads = [ + buildPayload(), + buildPayload({ action_source: undefined }), + buildPayload({ event_at: 1704721970212 }) + ] + + const result = createRedditPayloadV3(payloads, settings, multiStatusResponse, true) + + expect(result.data.events).toHaveLength(2) + expect(multiStatusResponse.isSuccessResponseAtIndex(0)).toBe(true) + expect(multiStatusResponse.isErrorResponseAtIndex(1)).toBe(true) + expect(multiStatusResponse.getResponseAtIndex(1)).toMatchObject({ + data: { + status: 400, + errormessage: 'action_source is required when sending to Reddit Conversions API v3' + } + }) + expect(multiStatusResponse.isSuccessResponseAtIndex(2)).toBe(true) + }) +}) + +describe('sendV3', () => { + const request = createRequestClient() + + afterEach(() => { + nock.cleanAll() + }) + + it('POSTs to the v3 conversion_events endpoint and returns the raw response for a single (non-batch) event', async () => { + nock('https://ads-api.reddit.com').post('/api/v3/pixels/ad_account_id_1/conversion_events').reply(200, { + ok: true + }) + + const response = await sendV3(request, settings, [buildPayload()], false) + expect((response as { status: number }).status).toBe(200) + }) + + it('returns a MultiStatusResponse for a batch, and does not make an HTTP call when every payload fails validation', async () => { + const scope = nock('https://ads-api.reddit.com') + .post('/api/v3/pixels/ad_account_id_1/conversion_events') + .reply(200, {}) + + const payloads = [buildPayload({ action_source: undefined }), buildPayload({ action_source: undefined })] + const response = await sendV3(request, settings, payloads, true) + + expect(response).toBeInstanceOf(MultiStatusResponse) + const multiStatusResponse = response as MultiStatusResponse + expect(multiStatusResponse.isErrorResponseAtIndex(0)).toBe(true) + expect(multiStatusResponse.isErrorResponseAtIndex(1)).toBe(true) + expect(scope.isDone()).toBe(false) + }) + + it('returns a MultiStatusResponse for a batch that has at least one valid payload, and does make an HTTP call', async () => { + const scope = nock('https://ads-api.reddit.com') + .post('/api/v3/pixels/ad_account_id_1/conversion_events') + .reply(200, {}) + + const payloads = [buildPayload(), buildPayload({ action_source: undefined })] + const response = await sendV3(request, settings, payloads, true) + + expect(response).toBeInstanceOf(MultiStatusResponse) + const multiStatusResponse = response as MultiStatusResponse + expect(multiStatusResponse.isSuccessResponseAtIndex(0)).toBe(true) + expect(multiStatusResponse.isErrorResponseAtIndex(1)).toBe(true) + expect(scope.isDone()).toBe(true) + }) +}) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts new file mode 100644 index 00000000000..9ccf484184f --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/action.ts @@ -0,0 +1,104 @@ +import type { ActionDefinition, InputField } from '@segment/actions-core' +import type { Settings } from './generated-types' +import type { Payload as StandardEvent } from './standardEvent/generated-types' +import type { Payload as CustomEvent } from './customEvent/generated-types' +import { send } from './utils' +import { sendV3 } from './v3/utils-v3' +import { LEGACY_API_VERSION, LATEST_API_VERSION, ApiVersion } from './versioning-info' +import { + event_at, + tracking_type, + custom_event_name, + click_id, + products, + user, + data_processing_options, + screen_dimensions, + event_metadata, + conversion_id, + api_version, + action_source, + event_source_url +} from './fields' + +export function resolveVersion(apiVersion: string | undefined): ApiVersion { + return apiVersion === LATEST_API_VERSION ? LATEST_API_VERSION : LEGACY_API_VERSION +} + +function buildEventAction( + trackingTypeField: InputField | undefined, + customEventNameField: InputField | undefined, + title: string, + description: string, + defaultSubscription: string | undefined, + resolvePayload: (payload: Payload) => Payload +): ActionDefinition { + return { + title, + description, + defaultSubscription, + fields: { + event_at, + ...(trackingTypeField ? { tracking_type: trackingTypeField } : {}), + ...(customEventNameField ? { custom_event_name: customEventNameField } : {}), + click_id, + products, + user, + data_processing_options, + screen_dimensions, + event_metadata, + conversion_id, + api_version, + action_source, + event_source_url + }, + perform: async (request, { settings, payload }) => { + const resolvedPayload = resolvePayload(payload) as StandardEvent + return resolveVersion(payload.api_version) === LATEST_API_VERSION + ? sendV3(request, settings, [resolvedPayload], false) + : send(request, settings, [resolvedPayload]) + }, + performBatch: async (request, { settings, payload }) => { + const resolvedPayloads = payload.map(resolvePayload) as StandardEvent[] + + // api_version is a static per-mapping setting, not derived from event data, so a batch is + // always homogeneously all-V2 or all-V3 - checking the first payload is enough. + return resolveVersion(resolvedPayloads[0]?.api_version) === LATEST_API_VERSION + ? sendV3(request, settings, resolvedPayloads, true) + : send(request, settings, resolvedPayloads) + } + } +} + +export function standardEventAction( + // When set, tracking_type is hardcoded to this value and hidden from the UI, replicating + // the old per-event-type presets. When undefined, tracking_type stays a user-selectable field. + trackingType: string | undefined, + title: string, + description: string, + defaultSubscription?: string +): ActionDefinition { + return buildEventAction( + trackingType ? undefined : tracking_type, + undefined, + title, + description, + defaultSubscription, + (payload) => (trackingType ? { ...payload, tracking_type: trackingType } : payload) + ) +} + +export function customEventAction( + title: string, + description: string, + defaultSubscription?: string +): ActionDefinition { + return buildEventAction( + undefined, + custom_event_name, + title, + description, + defaultSubscription, + (payload) => payload + ) +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap index a1d96e2f9be..77ac5376abe 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -14,7 +14,9 @@ Object { Object { "category": "1dc[GGJK6O%*76J4O6Mz", "id": "1dc[GGJK6O%*76J4O6Mz", + "item_price": 83613772949749.77, "name": "1dc[GGJK6O%*76J4O6Mz", + "quantity": 8361377294974976, }, ], "value_decimal": 83613772949749.77, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts index 4c5db62be13..1bd61ef92aa 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts @@ -29,6 +29,14 @@ export interface Payload { * The name of the product. Optional. */ name?: string + /** + * The number of this product in the event. Only applies to Reddit Conversions API V3 (Beta). + */ + quantity?: number + /** + * The unit price of the product. Only applies to Reddit Conversions API V3 (Beta). + */ + item_price?: number }[] /** * The identifying user parameters associated with the conversion event. @@ -118,4 +126,16 @@ export interface Payload { * The unique conversion ID that corresponds to a distinct conversion event. Use this for event deduplication. */ conversion_id?: string + /** + * The version of the Reddit Conversions API to send this event to. "V3 (Beta)" requires Action Source to be set. + */ + api_version?: string + /** + * The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3 (Beta). + */ + action_source?: string + /** + * The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API V3 (Beta). + */ + event_source_url?: string } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts index b470347be1c..f40d40e6c72 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts @@ -1,39 +1,4 @@ -import type { ActionDefinition } from '@segment/actions-core' -import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { send } from '../utils' -import { - event_at, - custom_event_name, - click_id, - products, - user, - data_processing_options, - screen_dimensions, - event_metadata, - conversion_id -} from '../fields' +import { customEventAction } from '../action' -const action: ActionDefinition = { - title: 'Send Custom Event', - description: 'Send a Custom Conversion Event to Reddit', - fields: { - event_at, - custom_event_name, - click_id, - products, - user, - data_processing_options, - screen_dimensions, - event_metadata, - conversion_id - }, - perform: async (request, { settings, payload }) => { - return await send(request, settings, [payload]) - }, - performBatch: async (request, { settings, payload }) => { - return await send(request, settings, payload) - } -} - -export default action +export default customEventAction('Send Custom Event', 'Send a Custom Conversion Event to Reddit') diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts index 2d1e8cf9f28..d202d08a97a 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts @@ -1,4 +1,6 @@ import { InputField } from '@segment/actions-core/destination-kit/types' +import { LEGACY_API_VERSION, LATEST_API_VERSION } from './versioning-info' +import { ACTION_SOURCE_V3_LABELS } from './v3/constants' export const event_at: InputField = { label: 'Event At', @@ -36,6 +38,45 @@ export const tracking_type: InputField = { ] } +export const api_version: InputField = { + label: 'API Version', + description: + 'The version of the Reddit Conversions API to send this event to. "V3 (Beta)" requires Action Source to be set.', + type: 'string', + required: false, + default: LEGACY_API_VERSION, + choices: [ + { label: 'V3 (Beta)', value: LATEST_API_VERSION }, + { label: 'V2', value: LEGACY_API_VERSION } + ], + disabledInputMethods: ['literal', 'variable', 'function', 'freeform', 'enrichment'] +} + +const API_VERSION_IS_V3 = { + match: 'all' as const, + conditions: [{ fieldKey: 'api_version', operator: 'is' as const, value: LATEST_API_VERSION }] +} + +export const action_source: InputField = { + label: 'Action Source', + description: + 'The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3 (Beta).', + type: 'string', + required: API_VERSION_IS_V3, + depends_on: API_VERSION_IS_V3, + choices: Object.entries(ACTION_SOURCE_V3_LABELS).map(([value, label]) => ({ label, value })) +} + +export const event_source_url: InputField = { + label: 'Event Source URL', + description: + 'The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API V3 (Beta).', + type: 'string', + required: false, + depends_on: API_VERSION_IS_V3, + default: { '@path': '$.context.page.url' } +} + export const click_id: InputField = { label: 'Click ID', description: 'The Reddit-generated id associated with a single ad click.', @@ -292,6 +333,18 @@ export const products: InputField = { description: 'The name of the product. Optional.', type: 'string', required: false + }, + quantity: { + label: 'Quantity', + description: 'The number of this product in the event. Only applies to Reddit Conversions API V3 (Beta).', + type: 'integer', + required: false + }, + item_price: { + label: 'Item Price', + description: 'The unit price of the product. Only applies to Reddit Conversions API V3 (Beta).', + type: 'number', + required: false } }, default: { @@ -300,7 +353,9 @@ export const products: InputField = { { category: { '@path': '$.category' }, id: { '@path': '$.product_id' }, - name: { '@path': '$.name' } + name: { '@path': '$.name' }, + quantity: { '@path': '$.quantity' }, + item_price: { '@path': '$.price' } } ] } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts index 39e0e7bd5fd..807a42c35d4 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/generated-types.ts @@ -10,7 +10,11 @@ export interface Settings { */ conversion_token: string /** - * Indicates if events should be treated as test events by Reddit. + * Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2. V3 (Beta) is the latest API version. To send test events on V3, set the Test ID setting instead. */ test_mode?: boolean + /** + * A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API V3 (Beta). + */ + test_id?: string } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts index 610b108d5d9..f0c027ce44f 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts @@ -3,7 +3,7 @@ import type { Settings } from './generated-types' import type { RedditConversionsTestAuthenticationError } from './types' import standardEvent from './standardEvent' import customEvent from './customEvent' -import { REDDIT_CONVERSIONS_API_VERSION } from './versioning-info' +import { LEGACY_API_VERSION } from './versioning-info' const destination: DestinationDefinition = { name: 'Reddit Conversions API', @@ -28,16 +28,24 @@ const destination: DestinationDefinition = { }, test_mode: { label: 'Test Mode', - description: 'Indicates if events should be treated as test events by Reddit.', + description: + 'Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2. V3 (Beta) is the latest API version. To send test events on V3, set the Test ID setting instead.', type: 'boolean', required: false, default: false + }, + test_id: { + label: 'Test ID', + description: + 'A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API V3 (Beta).', + type: 'string', + required: false } }, testAuthentication: async (request, { settings }) => { try { return await request( - `https://ads-api.reddit.com/api/${REDDIT_CONVERSIONS_API_VERSION}/conversions/events/${settings.ad_account_id}`, + `https://ads-api.reddit.com/api/${LEGACY_API_VERSION}/conversions/events/${settings.ad_account_id}`, { method: 'POST', headers: { @@ -100,6 +108,7 @@ const destination: DestinationDefinition = { partnerAction: 'standardEvent', mapping: { ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, tracking_type: 'PageVisit', event_metadata: {} }, @@ -111,6 +120,7 @@ const destination: DestinationDefinition = { partnerAction: 'standardEvent', mapping: { ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, tracking_type: 'ViewContent', event_metadata: {} }, @@ -122,6 +132,7 @@ const destination: DestinationDefinition = { partnerAction: 'standardEvent', mapping: { ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, tracking_type: 'Search', event_metadata: {} }, @@ -133,11 +144,12 @@ const destination: DestinationDefinition = { partnerAction: 'standardEvent', mapping: { ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, tracking_type: 'AddToCart', event_metadata: { currency: { '@path': '$.properties.currency' }, - itemCount: { '@path': '$.properties.quantity' }, - value: { '@path': '$.properties.price' } + item_count: { '@path': '$.properties.quantity' }, + value_decimal: { '@path': '$.properties.price' } } }, type: 'automatic' @@ -148,11 +160,12 @@ const destination: DestinationDefinition = { partnerAction: 'standardEvent', mapping: { ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, tracking_type: 'AddToWishlist', event_metadata: { currency: { '@path': '$.properties.currency' }, - itemCount: { '@path': '$.properties.quantity' }, - value: { '@path': '$.properties.price' } + item_count: { '@path': '$.properties.quantity' }, + value_decimal: { '@path': '$.properties.price' } } }, type: 'automatic' @@ -163,6 +176,7 @@ const destination: DestinationDefinition = { partnerAction: 'standardEvent', mapping: { ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, tracking_type: 'Purchase' }, type: 'automatic' @@ -173,6 +187,7 @@ const destination: DestinationDefinition = { partnerAction: 'standardEvent', mapping: { ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, tracking_type: 'Lead', event_metadata: { currency: { '@path': '$.properties.currency' }, @@ -187,6 +202,7 @@ const destination: DestinationDefinition = { partnerAction: 'standardEvent', mapping: { ...defaultValues(standardEvent.fields), + api_version: LEGACY_API_VERSION, tracking_type: 'SignUp', event_metadata: { currency: { '@path': '$.properties.currency' }, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json b/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json index c8d2844593f..ecaed7838bb 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json @@ -28,13 +28,23 @@ }, "test_mode": { "label": "Test Mode", - "description": "Indicates if events should be treated as test events by Reddit.", + "description": "Indicates if events should be treated as test events by Reddit. Only applies to Reddit Conversions API V2. V3 (Beta) is the latest API version. To send test events on V3, set the Test ID setting instead.", "type": "boolean", "required": false, "multiple": false, "choices": null, "default": false, "depends_on": null + }, + "test_id": { + "label": "Test ID", + "description": "A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API V3 (Beta).", + "type": "string", + "required": false, + "multiple": false, + "choices": null, + "default": null, + "depends_on": null } } }, @@ -190,6 +200,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -268,6 +284,54 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "quantity": { + "label": "Quantity", + "description": "The number of this product in the event. Only applies to Reddit Conversions API V3 (Beta).", + "type": "integer", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "item_price": { + "label": "Item Price", + "description": "The unit price of the product. Only applies to Reddit Conversions API V3 (Beta).", + "type": "number", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } }, "category": null, @@ -2538,6 +2602,139 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "api_version": { + "label": "API Version", + "description": "The version of the Reddit Conversions API to send this event to. \"V3 (Beta)\" requires Action Source to be set.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": "v2.0", + "choices": [ + { + "label": "V3 (Beta)", + "value": "v3" + }, + { + "label": "V2", + "value": "v2.0" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": [ + "literal", + "variable", + "function", + "freeform", + "enrichment" + ], + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "action_source": { + "label": "Action Source", + "description": "The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3 (Beta).", + "type": "string", + "required": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": [ + { + "label": "Website", + "value": "WEBSITE" + }, + { + "label": "App", + "value": "APP" + }, + { + "label": "Other", + "value": "OTHER" + }, + { + "label": "Offline (Physical Store)", + "value": "PHYSICAL_STORE" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "event_source_url": { + "label": "Event Source URL", + "description": "The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API V3 (Beta).", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.page.url" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } } }, @@ -2658,6 +2855,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -2736,6 +2939,54 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "quantity": { + "label": "Quantity", + "description": "The number of this product in the event. Only applies to Reddit Conversions API V3 (Beta).", + "type": "integer", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "item_price": { + "label": "Item Price", + "description": "The unit price of the product. Only applies to Reddit Conversions API V3 (Beta).", + "type": "number", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } }, "category": null, @@ -5006,6 +5257,139 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "api_version": { + "label": "API Version", + "description": "The version of the Reddit Conversions API to send this event to. \"V3 (Beta)\" requires Action Source to be set.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": "v2.0", + "choices": [ + { + "label": "V3 (Beta)", + "value": "v3" + }, + { + "label": "V2", + "value": "v2.0" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": [ + "literal", + "variable", + "function", + "freeform", + "enrichment" + ], + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "action_source": { + "label": "Action Source", + "description": "The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3 (Beta).", + "type": "string", + "required": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": [ + { + "label": "Website", + "value": "WEBSITE" + }, + { + "label": "App", + "value": "APP" + }, + { + "label": "Other", + "value": "OTHER" + }, + { + "label": "Offline (Physical Store)", + "value": "PHYSICAL_STORE" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "event_source_url": { + "label": "Event Source URL", + "description": "The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API V3 (Beta).", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.page.url" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": { + "match": "all", + "conditions": [ + { + "fieldKey": "api_version", + "operator": "is", + "value": "v3" + } + ] + }, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } } }, @@ -5065,6 +5449,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5147,6 +5537,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "PageVisit" }, "eventSlug": null @@ -5185,6 +5579,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5267,6 +5667,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "ViewContent" }, "eventSlug": null @@ -5305,6 +5709,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5387,6 +5797,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Search" }, "eventSlug": null @@ -5425,6 +5839,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5507,16 +5927,20 @@ "currency": { "@path": "$.properties.currency" }, - "itemCount": { + "item_count": { "@path": "$.properties.quantity" }, - "value": { + "value_decimal": { "@path": "$.properties.price" } }, "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "AddToCart" }, "eventSlug": null @@ -5555,6 +5979,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5637,16 +6067,20 @@ "currency": { "@path": "$.properties.currency" }, - "itemCount": { + "item_count": { "@path": "$.properties.quantity" }, - "value": { + "value_decimal": { "@path": "$.properties.price" } }, "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "AddToWishlist" }, "eventSlug": null @@ -5685,6 +6119,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5787,6 +6227,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Purchase" }, "eventSlug": null @@ -5825,6 +6269,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5914,6 +6364,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Lead" }, "eventSlug": null @@ -5952,6 +6406,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -6041,6 +6501,10 @@ "conversion_id": { "@path": "$.messageId" }, + "api_version": "v2.0", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "SignUp" }, "eventSlug": null diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap index 8dfcd05f7c1..caf7c196c97 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -9,12 +9,13 @@ Object { "event_metadata": Object { "conversion_id": "7da4d4522d67f6ca1c1b0654c2d91c916a22667a5d34fa75c7cdcbe0c1b452ce", "currency": "XPF", - "item_count": 8501184792887296, "products": Array [ Object { "category": "a@UzPN)1pp@tLb)vQZ2s", "id": "a@UzPN)1pp@tLb)vQZ2s", + "item_price": 85011847928872.95, "name": "a@UzPN)1pp@tLb)vQZ2s", + "quantity": 8501184792887296, }, ], "value_decimal": 85011847928872.95, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts index 82d3f61f89b..ab7f46a328f 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts @@ -29,6 +29,14 @@ export interface Payload { * The name of the product. Optional. */ name?: string + /** + * The number of this product in the event. Only applies to Reddit Conversions API V3 (Beta). + */ + quantity?: number + /** + * The unit price of the product. Only applies to Reddit Conversions API V3 (Beta). + */ + item_price?: number }[] /** * The identifying user parameters associated with the conversion event. @@ -118,4 +126,16 @@ export interface Payload { * The unique conversion ID that corresponds to a distinct conversion event. Use this for event deduplication. */ conversion_id?: string + /** + * The version of the Reddit Conversions API to send this event to. "V3 (Beta)" requires Action Source to be set. + */ + api_version?: string + /** + * The source/channel where the conversion occurred, used for omnichannel attribution. Only applies to, and required for, Reddit Conversions API V3 (Beta). + */ + action_source?: string + /** + * The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API V3 (Beta). + */ + event_source_url?: string } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts index a1209a791d4..f89eb350eb5 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts @@ -1,39 +1,8 @@ -import type { ActionDefinition } from '@segment/actions-core' -import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { send } from '../utils' -import { - event_at, - tracking_type, - click_id, - products, - user, - data_processing_options, - screen_dimensions, - event_metadata, - conversion_id -} from '../fields' +import { standardEventAction } from '../action' -const action: ActionDefinition = { - title: 'Send Standard Event', - description: 'Send a Standard Conversion Event to Reddit', - fields: { - event_at, - tracking_type, - click_id, - products, - user, - data_processing_options, - screen_dimensions, - event_metadata, - conversion_id - }, - perform: async (request, { settings, payload }) => { - return await send(request, settings, [payload]) - }, - performBatch: async (request, { settings, payload }) => { - return await send(request, settings, payload) - } -} - -export default action +export default standardEventAction( + undefined, + 'Send Standard Event', + 'Send a Standard Conversion Event to Reddit' +) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts index e9a6ca9f5f2..055cab4c4f5 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts @@ -13,6 +13,8 @@ export interface Product { category?: string id?: string name?: string + quantity?: number + item_price?: number } export interface EventMetadata { diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts index 4a6e6f45ad1..031909a67d0 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts @@ -11,7 +11,7 @@ import { DatapProcessingOptions } from './types' import { processHashing } from '../../lib/hashing-utils' -import { REDDIT_CONVERSIONS_API_VERSION } from './versioning-info' +import { LEGACY_API_VERSION } from './versioning-info' type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] type ProductsType = StandardEvent['products'] | CustomEvent['products'] @@ -22,14 +22,11 @@ type ScreenDimensionsType = StandardEvent['screen_dimensions'] | CustomEvent['sc export async function send(request: RequestClient, settings: Settings, payload: StandardEvent[] | CustomEvent[]) { const data = createRedditPayload(payload, settings) - return request( - `https://ads-api.reddit.com/api/${REDDIT_CONVERSIONS_API_VERSION}/conversions/events/${settings.ad_account_id}`, - { - method: 'POST', - headers: { Authorization: `Bearer ${settings.conversion_token}` }, - json: JSON.parse(JSON.stringify(data)) - } - ) + return request(`https://ads-api.reddit.com/api/${LEGACY_API_VERSION}/conversions/events/${settings.ad_account_id}`, { + method: 'POST', + headers: { Authorization: `Bearer ${settings.conversion_token}` }, + json: JSON.parse(JSON.stringify(data)) + }) } function createRedditPayload(payloads: StandardEvent[] | CustomEvent[], settings: Settings): StandardEventPayload { @@ -49,17 +46,18 @@ function createRedditPayload(payloads: StandardEvent[] | CustomEvent[], settings const custom_event_name = (payload as CustomEvent).custom_event_name const tracking_type = (payload as StandardEvent).tracking_type + const resolvedTrackingType = custom_event_name ? 'Custom' : tracking_type const payloadItem: StandardEventPayloadItem = { event_at: event_at as string, event_type: { // if custom_event_name is present, tracking_type is 'Custom' // if custom_event_name not present then we know the event is a StandardEvent - tracking_type: custom_event_name ? 'Custom' : tracking_type, + tracking_type: resolvedTrackingType, custom_event_name: clean(custom_event_name) }, click_id: clean(click_id), - event_metadata: getMetadata(event_metadata, products, conversion_id), + event_metadata: getMetadata(event_metadata, products, conversion_id, resolvedTrackingType), user: getUser(user, data_processing_options, screen_dimensions) } @@ -73,16 +71,30 @@ function createRedditPayload(payloads: StandardEvent[] | CustomEvent[], settings } } -function clean(str: string | undefined): string | undefined { +export function clean(str: string | undefined): string | undefined { if (str === undefined || str === null || str === '') return undefined return str.trim() } -function cleanNum(num: number | undefined): number | undefined { +export function cleanNum(num: number | undefined): number | undefined { if (num === undefined || num === null) return undefined return num } +// Per https://business.reddithelp.com/s/article/about-event-metadata: PageVisit/ViewContent/Search +// don't support currency/value/item_count at all (conversion_id/products are still fine), and +// Lead/SignUp support currency/value but not item_count. +const TRACKING_TYPES_WITHOUT_VALUE_METADATA = new Set(['PageVisit', 'ViewContent', 'Search']) +const TRACKING_TYPES_WITHOUT_ITEM_COUNT = new Set(['Lead', 'SignUp']) + +export function supportsValueMetadata(trackingType: string | undefined): boolean { + return !TRACKING_TYPES_WITHOUT_VALUE_METADATA.has(trackingType ?? '') +} + +export function supportsItemCount(trackingType: string | undefined): boolean { + return supportsValueMetadata(trackingType) && !TRACKING_TYPES_WITHOUT_ITEM_COUNT.has(trackingType ?? '') +} + function getProducts(products: ProductsType): Product[] | undefined { if (!products) { return undefined @@ -92,37 +104,46 @@ function getProducts(products: ProductsType): Product[] | undefined { return { category: clean(product.category), id: clean(product.id), - name: clean(product.name) + name: clean(product.name), + quantity: cleanNum(product.quantity), + item_price: cleanNum(product.item_price) } }) } -function getMetadata( +export function getMetadata( metadata: EventMetadataType, products: ProductsType, - conversion_id: ConversionIdType + conversion_id: ConversionIdType, + trackingType?: string ): EventMetadata | undefined { if (!metadata && !products && !conversion_id) { return undefined } + const valueMetadataSupported = supportsValueMetadata(trackingType) + const itemCountSupported = supportsItemCount(trackingType) + return { - currency: clean(metadata?.currency), - item_count: cleanNum(metadata?.item_count), - value_decimal: cleanNum(metadata?.value_decimal), + currency: valueMetadataSupported ? clean(metadata?.currency) : undefined, + item_count: itemCountSupported ? cleanNum(metadata?.item_count) : undefined, + value_decimal: valueMetadataSupported ? cleanNum(metadata?.value_decimal) : undefined, products: getProducts(products), conversion_id: smartHash(conversion_id, (value) => value.trim()) } } -function getAdId(device_type?: string, advertising_id?: string): { [key: string]: string | undefined } | undefined { +export function getAdId( + device_type?: string, + advertising_id?: string +): { [key: string]: string | undefined } | undefined { if (!device_type) return undefined if (!advertising_id) return undefined const hashedAdId = smartHash(advertising_id) return device_type === 'ios' ? { idfa: hashedAdId } : { aaid: hashedAdId } } -function getDataProcessingOptions( +export function getDataProcessingOptions( dataProcessingOptions: DataProcessingOptionsType ): DatapProcessingOptions | undefined { if (!dataProcessingOptions) return undefined @@ -133,7 +154,7 @@ function getDataProcessingOptions( } } -function getScreen(height?: number, width?: number): { height: number; width: number } | undefined { +export function getScreen(height?: number, width?: number): { height: number; width: number } | undefined { if (height === undefined || width === undefined) return undefined return { height, @@ -141,7 +162,7 @@ function getScreen(height?: number, width?: number): { height: number; width: nu } } -function getUser( +export function getUser( user: UserType, dataProcessingOptions: DataProcessingOptionsType, screenDimensions: ScreenDimensionsType @@ -161,19 +182,22 @@ function getUser( } } -function canonicalizeEmail(value: string): string { +export function canonicalizeEmail(value: string): string { value = value.trim() const localPartAndDomain = value.split('@') const localPart = localPartAndDomain[0].replace(/\./g, '').split('+')[0] return `${localPart.toLowerCase()}@${localPartAndDomain[1].toLowerCase()}` } -const smartHash = (value: string | undefined, cleaningFunction?: (value: string) => string): string | undefined => { +export const smartHash = ( + value: string | undefined, + cleaningFunction?: (value: string) => string +): string | undefined => { if (value === undefined) return return processHashing(value, 'sha256', 'hex', cleaningFunction) } -function cleanPhoneNumber(phoneNumber: string): string { +export function cleanPhoneNumber(phoneNumber: string): string { if (!phoneNumber) return '' phoneNumber = phoneNumber.trim() const prefix = '+' diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts new file mode 100644 index 00000000000..15ef1cd455b --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/constants.ts @@ -0,0 +1,19 @@ +export const ACTION_SOURCE_V3_LABELS = { + WEBSITE: 'Website', + APP: 'App', + OTHER: 'Other', + PHYSICAL_STORE: 'Offline (Physical Store)' +} as const + +// v2 tracking_type (mixed case) -> v3 UPPER_SNAKE_CASE. +export const TRACKING_TYPE_V3 = { + PageVisit: 'PAGE_VISIT', + ViewContent: 'VIEW_CONTENT', + Search: 'SEARCH', + AddToCart: 'ADD_TO_CART', + AddToWishlist: 'ADD_TO_WISHLIST', + Purchase: 'PURCHASE', + Lead: 'LEAD', + SignUp: 'SIGN_UP', + Custom: 'CUSTOM' +} as const diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts new file mode 100644 index 00000000000..c03208767ba --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/types-v3.ts @@ -0,0 +1,63 @@ +import { ACTION_SOURCE_V3_LABELS, TRACKING_TYPE_V3 } from './constants' + +export type ActionSourceV3 = keyof typeof ACTION_SOURCE_V3_LABELS +export type EventTypeV3 = typeof TRACKING_TYPE_V3[keyof typeof TRACKING_TYPE_V3] + +export interface ProductV3 { + category?: string + id: string + name?: string + quantity?: number + item_price?: number +} + +export interface MetadataV3 { + currency?: string + item_count?: number + value?: number + conversion_id?: string + products?: Array +} + +export interface DataProcessingOptionsV3 { + country?: string + modes?: string[] + region?: string +} + +export interface UserV3 { + idfa?: string + aaid?: string + email?: string + external_id?: string + ip_address?: string + user_agent?: string + uuid?: string + data_processing_options?: DataProcessingOptionsV3 + screen_dimensions?: { + height?: number + width?: number + } + phone_number?: string +} + +export interface EventItemV3 { + event_at: number // milliseconds + action_source: ActionSourceV3 + event_source_url?: string + click_id?: string + type: { + tracking_type: EventTypeV3 + custom_event_name?: string // required if tracking_type is CUSTOM + } + metadata?: MetadataV3 + user?: UserV3 +} + +export interface PayloadV3 { + data: { + events: EventItemV3[] + partner: 'SEGMENT' + test_id?: string + } +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts new file mode 100644 index 00000000000..2736e6c1ef8 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3/utils-v3.ts @@ -0,0 +1,172 @@ +import type { RequestClient, JSONLikeObject } from '@segment/actions-core' +import { PayloadValidationError, MultiStatusResponse } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload as StandardEvent } from '../standardEvent/generated-types' +import type { Payload as CustomEvent } from '../customEvent/generated-types' +import { EventItemV3, PayloadV3, MetadataV3, ProductV3, ActionSourceV3, EventTypeV3 } from './types-v3' +import { ACTION_SOURCE_V3_LABELS, TRACKING_TYPE_V3 } from './constants' +import { clean, cleanNum, getUser, smartHash, supportsValueMetadata, supportsItemCount } from '../utils' +import { LATEST_API_VERSION } from '../versioning-info' + +type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] +type ProductsType = StandardEvent['products'] | CustomEvent['products'] +type ConversionIdType = StandardEvent['conversion_id'] | CustomEvent['conversion_id'] + +export async function sendV3( + request: RequestClient, + settings: Settings, + payloads: (StandardEvent | CustomEvent)[], + isBatch: boolean +) { + const multiStatusResponse = new MultiStatusResponse() + const data = createRedditPayloadV3(payloads, settings, multiStatusResponse, isBatch) + + if (data.data.events.length) { + const response = await request( + `https://ads-api.reddit.com/api/${LATEST_API_VERSION}/pixels/${settings.ad_account_id}/conversion_events`, + { + method: 'POST', + headers: { Authorization: `Bearer ${settings.conversion_token}` }, + json: JSON.parse(JSON.stringify(data)) + } + ) + if (!isBatch) { + return response + } + } + + return multiStatusResponse +} + +export function createRedditPayloadV3( + payloads: (StandardEvent | CustomEvent)[], + settings: Settings, + multiStatusResponse: MultiStatusResponse, + isBatch: boolean +): PayloadV3 { + const indices: number[] = [] + const events: EventItemV3[] = [] + + payloads.forEach((payload, index) => { + try { + const { + event_at, + click_id, + products, + user, + data_processing_options, + screen_dimensions, + event_metadata, + conversion_id, + action_source, + event_source_url + } = payload + + const custom_event_name = clean((payload as CustomEvent).custom_event_name) + const tracking_type = custom_event_name ? 'Custom' : (payload as StandardEvent).tracking_type + + const event: EventItemV3 = { + event_at: toEpochMs(event_at), + action_source: toActionSourceV3(action_source), + event_source_url: clean(event_source_url), + click_id: clean(click_id), + type: { + tracking_type: toV3TrackingType(tracking_type), + custom_event_name + }, + metadata: getMetadata(event_metadata, products, conversion_id, tracking_type), + user: getUser(user, data_processing_options, screen_dimensions) + } + + indices.push(index) + events.push(event) + multiStatusResponse.setSuccessResponseAtIndex(index, { + status: 200, + sent: events[indices.indexOf(index)] as unknown as JSONLikeObject, + body: { success: true } + }) + } catch (err) { + const error = err instanceof Error ? err.message : 'Invalid payload for Reddit Conversions API v3' + if (!isBatch) { + throw new PayloadValidationError(error) + } + multiStatusResponse.setErrorResponseAtIndex(index, { status: 400, errormessage: error }) + } + }) + + return { data: { events, partner: 'SEGMENT', test_id: clean(settings.test_id) } } +} + +export function toEpochMs(value: string | number | undefined): number { + const EPOCH_MS_MIN = 1e12 + if (value === undefined || value === null || value === '') { + throw new PayloadValidationError('event_at is required') + } + if (typeof value === 'number' && Number.isInteger(value) && value >= EPOCH_MS_MIN) return value + if (typeof value === 'string') { + const trimmed = value.trim() + const isDigitsOnly = /^\d+$/.test(trimmed) + if (isDigitsOnly && Number(trimmed) >= EPOCH_MS_MIN) return Number(trimmed) + if (!isDigitsOnly) { + const ms = Date.parse(value) + if (!Number.isNaN(ms)) return ms + } + } + throw new PayloadValidationError( + `event_at must be an ISO 8601 timestamp or epoch milliseconds, received: ${String(value)}` + ) +} + +export function toV3TrackingType(tracking_type: string | undefined): EventTypeV3 { + if (!tracking_type) throw new PayloadValidationError('tracking_type is required') + const mapped = (TRACKING_TYPE_V3 as Record)[tracking_type] + if (!mapped) throw new PayloadValidationError(`Unsupported tracking_type: ${tracking_type}`) + return mapped +} + +export function toActionSourceV3(action_source: string | undefined): ActionSourceV3 { + if (!action_source) + throw new PayloadValidationError('action_source is required when sending to Reddit Conversions API v3') + if (!(action_source in ACTION_SOURCE_V3_LABELS)) { + throw new PayloadValidationError(`Unsupported action_source: ${action_source}`) + } + return action_source as ActionSourceV3 +} + +export function getProducts(products: ProductsType): ProductV3[] | undefined { + if (!products) return undefined + return products.map((product) => ({ + category: clean(product.category), + id: toProductIdV3(product.id), + name: clean(product.name), + quantity: cleanNum(product.quantity), + item_price: cleanNum(product.item_price) + })) +} + +export function toProductIdV3(id: string | undefined): string { + const cleaned = clean(id) + if (!cleaned) throw new PayloadValidationError('products.id is required when sending to Reddit Conversions API v3') + return cleaned +} + +export function getMetadata( + metadata: EventMetadataType, + products: ProductsType, + conversion_id: ConversionIdType, + trackingType?: string +): MetadataV3 | undefined { + if (!metadata && !products && !conversion_id) return undefined + const valueMetadataSupported = supportsValueMetadata(trackingType) + const itemCountSupported = supportsItemCount(trackingType) + + return { + currency: valueMetadataSupported ? clean(metadata?.currency) : undefined, + item_count: itemCountSupported ? cleanNum(metadata?.item_count) : undefined, + // The Segment-facing field is still named `value_decimal` (unchanged from v2, so existing + // mappings keep working) - only the wire-level key sent to Reddit v3 renames to `value`. + value: valueMetadataSupported ? cleanNum(metadata?.value_decimal) : undefined, + products: getProducts(products), + conversion_id: smartHash(conversion_id, (value) => value.trim()) + } +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts index 8f6633b88ed..d5572f8fbb3 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts @@ -1,5 +1,3 @@ -/** REDDIT_CONVERSIONS_API_VERSION - * Reddit conversions API version. - * API reference: https://ads-api.reddit.com/docs/v2/changelog - */ -export const REDDIT_CONVERSIONS_API_VERSION = 'v2.0' +export const LEGACY_API_VERSION = 'v2.0' +export const LATEST_API_VERSION = 'v3' +export type ApiVersion = typeof LEGACY_API_VERSION | typeof LATEST_API_VERSION \ No newline at end of file