diff --git a/packages/destination-actions/src/destinations/kevel/generated-types.ts b/packages/destination-actions/src/destinations/kevel/generated-types.ts new file mode 100644 index 0000000000..6a0d02c1d4 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Kevel Network ID + */ + networkId: string + /** + * Your Kevel API Key + */ + apiKey: string +} diff --git a/packages/destination-actions/src/destinations/kevel/index.ts b/packages/destination-actions/src/destinations/kevel/index.ts new file mode 100644 index 0000000000..5fbb158a64 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/index.ts @@ -0,0 +1,47 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import syncAudience from './syncAudience' + +import syncTraits from './syncTraits' + +const destination: DestinationDefinition = { + name: 'Kevel', + slug: 'actions-kevel', + description: + 'Send Segment user profiles and Segment Audiences to Kevel. Only users with a Segment userId will be synced.', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + networkId: { + label: 'Kevel Network ID', + description: 'Your Kevel Network ID', + type: 'string', + required: true + }, + apiKey: { + label: 'Kevel API Key', + description: 'Your Kevel API Key', + type: 'string', + required: true + } + } + }, + extendRequest({ settings }) { + return { + headers: { + 'X-Adzerk-ApiKey': settings.apiKey, + 'Content-Type': 'application/json', + 'X-Adzerk-Sdk-Version': 'adzerk-segment-integration:v1.0' + } + } + }, + actions: { + syncAudience, + syncTraits + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/kevel/syncAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/kevel/syncAudience/__tests__/index.test.ts new file mode 100644 index 0000000000..0b027ec2b2 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncAudience/__tests__/index.test.ts @@ -0,0 +1,108 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const goodTrackEvent = createTestEvent({ + type: 'track', + userId: 'uid1', + context: { + personas: { + computation_class: 'audience', + computation_key: 'kevel_segment_test_name' + }, + traits: { + email: 'test@email.com' + } + }, + properties: { + audience_key: 'kevel_segment_test_name', + kevel_segment_test_name: true + } +}) + +const goodIdentifyEvent = createTestEvent({ + type: 'identify', + userId: 'uid1', + context: { + personas: { + computation_class: 'audience', + computation_key: 'kevel_segment_test_name' + } + }, + traits: { + audience_key: 'kevel_segment_test_name', + kevel_segment_test_name: true + }, + properties: undefined +}) + +const badEvent = createTestEvent({ + userId: 'uid1', + context: { + personas: { + computation_key: 'kevel_segment_test_name' + }, + traits: { + email: 'test@email.com' + } + }, + properties: { + audience_key: 'kevel_segment_test_name', + kevel_segment_test_name: true + } +}) + +describe('Kevel.syncAudience', () => { + it('should not throw an error if the audience creation succeed - track', async () => { + const userId = 'uid1' + const networkId1 = 'networkId1' + const baseUrl = `https://e-${networkId1}.adzerk.net/udb/${networkId1}` + + nock(baseUrl) + .post(`/interests?userKey=${userId}`, JSON.stringify(['kevel_segment_test_name'])) + .reply(200) + + await expect( + testDestination.testAction('syncAudience', { + event: goodTrackEvent, + settings: { + networkId: networkId1, + apiKey: 'apiKey1' + }, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) + + it('should not throw an error if the audience creation succeed - track', async () => { + const userId = 'uid1' + const networkId1 = 'networkId1' + const baseUrl = `https://e-${networkId1}.adzerk.net/udb/${networkId1}` + + nock(baseUrl) + .post(`/interests?userKey=${userId}`, JSON.stringify(['kevel_segment_test_name'])) + .reply(200) + + await expect( + testDestination.testAction('syncAudience', { + event: goodIdentifyEvent, + settings: { + networkId: networkId1, + apiKey: 'apiKey1' + }, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) + + it('should throw an error if audience creation event missing mandatory field', async () => { + await expect( + testDestination.testAction('syncAudience', { + event: badEvent, + useDefaultMappings: true + }) + ).rejects.toThrowError("The root value is missing the required field 'segment_computation_action'") + }) +}) diff --git a/packages/destination-actions/src/destinations/kevel/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/kevel/syncAudience/generated-types.ts new file mode 100644 index 0000000000..4277d10241 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncAudience/generated-types.ts @@ -0,0 +1,22 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Segment Audience name to which user identifier should be added or removed + */ + segment_computation_key: string + /** + * Segment computation class used to determine if input event is from an Engage Audience'. Value must be = 'audience'. + */ + segment_computation_action: string + /** + * The user's unique ID + */ + segment_user_id: string + /** + * A computed object for track and identify events. This field should not need to be edited. + */ + traits_or_props: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/kevel/syncAudience/index.ts b/packages/destination-actions/src/destinations/kevel/syncAudience/index.ts new file mode 100644 index 0000000000..59bee19d7b --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncAudience/index.ts @@ -0,0 +1,71 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Sync Audience', + description: 'Sync a Segment Engage Audience to a Kevel Segment. Only users with a Segment userId will be synced.', + defaultSubscription: 'type = "track" or type = "identify"', + fields: { + segment_computation_key: { + label: 'Audience Key', + description: 'Segment Audience name to which user identifier should be added or removed', + type: 'string', + unsafe_hidden: true, + required: true, + default: { + '@path': '$.context.personas.computation_key' + } + }, + segment_computation_action: { + label: 'Segment Computation Action', + description: + "Segment computation class used to determine if input event is from an Engage Audience'. Value must be = 'audience'.", + type: 'string', + unsafe_hidden: true, + required: true, + default: { + '@path': '$.context.personas.computation_class' + }, + choices: [{ label: 'audience', value: 'audience' }] + }, + segment_user_id: { + label: 'User ID', + description: "The user's unique ID", + type: 'string', + unsafe_hidden: true, + required: true, + default: { '@path': '$.userId' } + }, + traits_or_props: { + label: 'Traits or properties object', + description: 'A computed object for track and identify events. This field should not need to be edited.', + type: 'object', + required: true, + unsafe_hidden: true, + default: { + '@if': { + exists: { '@path': '$.properties' }, + then: { '@path': '$.properties' }, + else: { '@path': '$.traits' } + } + } + } + }, + perform: async (request, data) => { + const settings = data.settings + + const baseUrl = `https://e-${settings.networkId}.adzerk.net/udb/${settings.networkId}` + + const payload = data.payload + + const audienceValue = payload.traits_or_props[payload.segment_computation_key] + + return request(`${baseUrl}/interests?userKey=${payload.segment_user_id}`, { + json: [payload.segment_computation_key], + method: audienceValue ? 'POST' : 'DELETE' + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/kevel/syncTraits/__tests__/index.test.ts b/packages/destination-actions/src/destinations/kevel/syncTraits/__tests__/index.test.ts new file mode 100644 index 0000000000..a588212b51 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncTraits/__tests__/index.test.ts @@ -0,0 +1,53 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const goodIdentifyEvent = createTestEvent({ + type: 'identify', + userId: 'uid1', + + traits: { + first_name: 'Billy', + last_name: 'Bob' + } +}) + +describe('Kevel.syncTraits', () => { + it('should fetch and merge traits, and then not throw an error - track', async () => { + const userId = 'uid1' + const networkId1 = 'networkId1' + const baseUrl = `https://e-${networkId1}.adzerk.net/udb/${networkId1}` + + const allTraits = { + age: 24, + first_name: 'Billy', + last_name: 'Bob' + } + + nock(baseUrl) + .get(`/read?userKey=${userId}`) + .reply( + 200, + JSON.stringify({ + custom: { + age: 24 + } + }) + ) + + nock(baseUrl).post(`/customProperties?userKey=${userId}`, JSON.stringify(allTraits)).reply(200) + + await expect( + testDestination.testAction('syncTraits', { + event: goodIdentifyEvent, + settings: { + networkId: networkId1, + apiKey: 'apiKey1' + }, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) +}) diff --git a/packages/destination-actions/src/destinations/kevel/syncTraits/generated-types.ts b/packages/destination-actions/src/destinations/kevel/syncTraits/generated-types.ts new file mode 100644 index 0000000000..3941ff5219 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncTraits/generated-types.ts @@ -0,0 +1,14 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user's unique ID + */ + segment_user_id: string + /** + * The user's profile traits / attributes + */ + traits: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/kevel/syncTraits/index.ts b/packages/destination-actions/src/destinations/kevel/syncTraits/index.ts new file mode 100644 index 0000000000..7c51e40c92 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncTraits/index.ts @@ -0,0 +1,47 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Sync Traits', + description: 'Sync user profile traits from Segment to Kevel', + defaultSubscription: 'type = "identify"', + fields: { + segment_user_id: { + label: 'User ID', + description: "The user's unique ID", + type: 'string', + required: true, + default: { '@path': '$.userId' } + }, + traits: { + label: 'Traits', + description: "The user's profile traits / attributes", + type: 'object', + required: true, + default: { '@path': '$.traits' } + } + }, + perform: async (request, data) => { + const settings = data.settings + + const baseUrl = `https://e-${settings.networkId}.adzerk.net/udb/${settings.networkId}` + + const payload = data.payload + + const existingResponse = await request(`${baseUrl}/read?userKey=${payload.segment_user_id}`, { + method: 'GET' + }) + + const existingRecord = await existingResponse.json() + + const mergedTraits = { ...existingRecord?.custom, ...payload.traits } + + return request(`${baseUrl}/customProperties?userKey=${payload.segment_user_id}`, { + json: mergedTraits, + method: 'POST' + }) + } +} + +export default action