From cd0838117d868492c2c3de8a38612818647e69af Mon Sep 17 00:00:00 2001 From: Joe Ayoub Date: Tue, 17 Jun 2025 13:11:55 +0100 Subject: [PATCH] [Roadway AI] - New Destiantion --- .../__snapshots__/snapshot.test.ts.snap | 134 ++++++ .../roadwayai/__tests__/index.test.ts | 31 ++ .../roadwayai/__tests__/snapshot.test.ts | 77 +++ .../metronome/roadwayai/generated-types.ts | 8 + .../groupUser/__tests__/index.test.ts | 234 ++++++++++ .../groupUser/__tests__/snapshot.test.ts | 75 +++ .../roadwayai/groupUser/generated-types.ts | 40 ++ .../metronome/roadwayai/groupUser/index.ts | 37 ++ .../identifyUser/__tests__/index.test.ts | 231 +++++++++ .../identifyUser/__tests__/snapshot.test.ts | 75 +++ .../roadwayai/identifyUser/generated-types.ts | 30 ++ .../metronome/roadwayai/identifyUser/index.ts | 37 ++ .../destinations/metronome/roadwayai/index.ts | 75 +++ .../roadwayai/payload-transformer.ts | 39 ++ .../metronome/roadwayai/request-types.ts | 79 ++++ .../trackEvent/__tests__/index.test.ts | 199 ++++++++ .../trackEvent/__tests__/snapshot.test.ts | 75 +++ .../roadwayai/trackEvent/generated-types.ts | 120 +++++ .../metronome/roadwayai/trackEvent/index.ts | 38 ++ .../trackPageView/__tests__/index.test.ts | 212 +++++++++ .../trackPageView/__tests__/snapshot.test.ts | 75 +++ .../trackPageView/generated-types.ts | 63 +++ .../roadwayai/trackPageView/index.ts | 37 ++ .../metronome/roadwayai/unified-fields.ts | 441 ++++++++++++++++++ 24 files changed, 2462 insertions(+) create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/__snapshots__/snapshot.test.ts.snap create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/index.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/index.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/index.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/payload-transformer.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/request-types.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/index.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/index.ts create mode 100644 packages/destination-actions/src/destinations/metronome/roadwayai/unified-fields.ts diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 00000000000..532498c38bc --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,134 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-roadwayai destination: groupUser action - all fields 1`] = ` +Array [ + Object { + "anonymous_id": "Gr3mlwQm[[m$r7XK", + "context": Object { + "testType": "Gr3mlwQm[[m$r7XK", + }, + "enable_batching": false, + "group_id": "Gr3mlwQm[[m$r7XK", + "group_name": "Gr3mlwQm[[m$r7XK", + "timestamp": "Gr3mlwQm[[m$r7XK", + "traits": Object { + "testType": "Gr3mlwQm[[m$r7XK", + }, + "user_id": "Gr3mlwQm[[m$r7XK", + }, +] +`; + +exports[`Testing snapshot for actions-roadwayai destination: groupUser action - required fields 1`] = ` +Array [ + Object { + "anonymous_id": "Gr3mlwQm[[m$r7XK", + "group_id": "Gr3mlwQm[[m$r7XK", + "timestamp": "Gr3mlwQm[[m$r7XK", + "user_id": "Gr3mlwQm[[m$r7XK", + }, +] +`; + +exports[`Testing snapshot for actions-roadwayai destination: identifyUser action - all fields 1`] = ` +Array [ + Object { + "anonymous_id": "cObWMVnZeFD", + "enable_batching": true, + "ip": "cObWMVnZeFD", + "timestamp": "cObWMVnZeFD", + "traits": Object { + "testType": "cObWMVnZeFD", + }, + "user_id": "cObWMVnZeFD", + }, +] +`; + +exports[`Testing snapshot for actions-roadwayai destination: identifyUser action - required fields 1`] = ` +Array [ + Object { + "anonymous_id": "cObWMVnZeFD", + "user_id": "cObWMVnZeFD", + }, +] +`; + +exports[`Testing snapshot for actions-roadwayai destination: trackEvent action - all fields 1`] = ` +Array [ + Object { + "anonymous_id": "WWk*FRhPkWQi", + "app_name": "WWk*FRhPkWQi", + "app_version": "WWk*FRhPkWQi", + "batch_size": -8568075683102.72, + "context": Object { + "testType": "WWk*FRhPkWQi", + }, + "country": "WWk*FRhPkWQi", + "enable_batching": true, + "event": "WWk*FRhPkWQi", + "event_properties": Object { + "testType": "WWk*FRhPkWQi", + }, + "group_id": "WWk*FRhPkWQi", + "insert_id": "WWk*FRhPkWQi", + "language": "WWk*FRhPkWQi", + "name": "WWk*FRhPkWQi", + "referrer": "WWk*FRhPkWQi", + "region": "WWk*FRhPkWQi", + "timestamp": "2021-02-01T00:00:00.000Z", + "url": "WWk*FRhPkWQi", + "user_id": "WWk*FRhPkWQi", + "utm_campaign": "WWk*FRhPkWQi", + "utm_content": "WWk*FRhPkWQi", + "utm_medium": "WWk*FRhPkWQi", + "utm_source": "WWk*FRhPkWQi", + "utm_term": "WWk*FRhPkWQi", + }, +] +`; + +exports[`Testing snapshot for actions-roadwayai destination: trackEvent action - required fields 1`] = ` +Array [ + Object { + "anonymous_id": "WWk*FRhPkWQi", + "event": "WWk*FRhPkWQi", + "group_id": "WWk*FRhPkWQi", + "insert_id": "WWk*FRhPkWQi", + "user_id": "WWk*FRhPkWQi", + }, +] +`; + +exports[`Testing snapshot for actions-roadwayai destination: trackPageView action - all fields 1`] = ` +Array [ + Object { + "anonymous_id": "(cZiDS416J(NpIYlkEs", + "data": Object { + "testType": "(cZiDS416J(NpIYlkEs", + }, + "enable_batching": false, + "event_id": "(cZiDS416J(NpIYlkEs", + "id": "(cZiDS416J(NpIYlkEs", + "referrer": "(cZiDS416J(NpIYlkEs", + "timestamp": "(cZiDS416J(NpIYlkEs", + "url": "(cZiDS416J(NpIYlkEs", + "utm_campaign": "(cZiDS416J(NpIYlkEs", + "utm_content": "(cZiDS416J(NpIYlkEs", + "utm_medium": "(cZiDS416J(NpIYlkEs", + "utm_source": "(cZiDS416J(NpIYlkEs", + "utm_term": "(cZiDS416J(NpIYlkEs", + }, +] +`; + +exports[`Testing snapshot for actions-roadwayai destination: trackPageView action - required fields 1`] = ` +Array [ + Object { + "anonymous_id": "(cZiDS416J(NpIYlkEs", + "event_id": "(cZiDS416J(NpIYlkEs", + "id": "(cZiDS416J(NpIYlkEs", + "url": "(cZiDS416J(NpIYlkEs", + }, +] +`; diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/index.test.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/index.test.ts new file mode 100644 index 00000000000..05b36f8edb1 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/index.test.ts @@ -0,0 +1,31 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe('Roadway AI', () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock('https://app.roadwayai.com').post('/api/v1/segment/validate-credentials').reply(200, { success: true }) + + const settings = { + apiKey: 'test-api-key' + } + + await expect(testDestination.testAuthentication(settings)).resolves.not.toThrowError() + }) + + it('should throw error when authentication fails', async () => { + nock('https://app.roadwayai.com') + .post('/api/v1/segment/validate-credentials') + .reply(401, { error: 'Invalid API key' }) + + const settings = { + apiKey: 'invalid-api-key' + } + + await expect(testDestination.testAuthentication(settings)).rejects.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..0e33246d4c7 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-roadwayai' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/generated-types.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/generated-types.ts new file mode 100644 index 00000000000..6d4e1923bb2 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your RoadwayAI API key for authentication + */ + apiKey: string +} diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/__tests__/index.test.ts new file mode 100644 index 00000000000..cfaeb0d1114 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/__tests__/index.test.ts @@ -0,0 +1,234 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const ROADWAY_API_KEY = 'test-api-key' +const timestamp = '2024-09-26T15:21:15.449Z' + +describe('Roadwayai.groupUser', () => { + it('should validate action fields', async () => { + const event = createTestEvent({ + type: 'group', + timestamp, + userId: 'user123', + groupId: 'group456', + traits: { + name: 'Engineering Team', + industry: 'Technology' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/group').reply(200, {}) + + const responses = await testDestination.testAction('groupUser', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + user_id: 'user123', + group_id: 'group456', + group_name: 'Engineering Team' + }) + ]) + }) + + it('should handle custom mapping', async () => { + const event = createTestEvent({ + type: 'group', + timestamp, + userId: 'customuser', + groupId: 'customgroup', + traits: { + name: 'Custom Group', + custom_field: 'custom_value' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/group').reply(200, {}) + + const responses = await testDestination.testAction('groupUser', { + event, + mapping: { + user_id: { + '@path': '$.userId' + }, + group_id: { + '@path': '$.groupId' + }, + group_name: { + '@path': '$.traits.name' + }, + traits: { + '@path': '$.traits' + }, + timestamp: { + '@path': '$.timestamp' + } + }, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + user_id: 'customuser', + group_id: 'customgroup', + group_name: 'Custom Group' + }) + ]) + }) + + it('should handle events with anonymous_id', async () => { + const event = createTestEvent({ + type: 'group', + timestamp, + anonymousId: 'anon123', + groupId: 'group456', + traits: { + name: 'Anonymous Group' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/group').reply(200, {}) + + const responses = await testDestination.testAction('groupUser', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + anonymous_id: 'anon123', + group_id: 'group456', + group_name: 'Anonymous Group' + }) + ]) + }) + + it('should handle events with context', async () => { + const event = createTestEvent({ + type: 'group', + timestamp, + userId: 'user123', + groupId: 'group456', + traits: { + name: 'Context Group' + }, + context: { + ip: '192.168.1.1', + userAgent: 'test-agent' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/group').reply(200, {}) + + const responses = await testDestination.testAction('groupUser', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + user_id: 'user123', + group_id: 'group456', + context: expect.objectContaining({ + ip: '192.168.1.1' + }) + }) + ]) + }) + + it('should require timestamp field', async () => { + const event = createTestEvent({ + type: 'group', + userId: 'user123', + groupId: 'group456', + traits: { + name: 'Test Group' + } + }) + event.timestamp = undefined + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/group').reply(200, {}) + + try { + await testDestination.testAction('groupUser', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + } catch (e) { + expect(e.message).toBe("The root value is missing the required field 'timestamp'.") + } + }) + + it('should invoke performBatch for batches', async () => { + const events = [ + createTestEvent({ + type: 'group', + timestamp, + userId: 'user1', + groupId: 'group1', + traits: { + name: 'Group One', + industry: 'Finance' + } + }), + createTestEvent({ + type: 'group', + timestamp, + userId: 'user2', + groupId: 'group2', + traits: { + name: 'Group Two', + industry: 'Healthcare' + } + }) + ] + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/group').reply(200, {}) + + const responses = await testDestination.testBatchAction('groupUser', { + events, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + user_id: 'user1', + group_id: 'group1', + group_name: 'Group One' + }), + expect.objectContaining({ + user_id: 'user2', + group_id: 'group2', + group_name: 'Group Two' + }) + ]) + }) +}) diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..e14428d5e7a --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'groupUser' +const destinationSlug = 'Roadwayai' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/generated-types.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/generated-types.ts new file mode 100644 index 00000000000..b6ad39eeeaf --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/generated-types.ts @@ -0,0 +1,40 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * When enabled, the action will use the RoadwayAI batch API. + */ + enable_batching?: boolean + /** + * The identifier of the user + */ + user_id?: string + /** + * Anonymous ID of the user + */ + anonymous_id?: string + /** + * ID of the group + */ + group_id?: string + /** + * Name of the group where user belongs to + */ + group_name?: string + /** + * The time the event occurred in UTC + */ + timestamp: string + /** + * Group traits + */ + traits?: { + [k: string]: unknown + } + /** + * Event context + */ + context?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/index.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/index.ts new file mode 100644 index 00000000000..82aac2c7ad9 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/groupUser/index.ts @@ -0,0 +1,37 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { getGroupUserFields } from '../unified-fields' +import { flattenPayload, flattenPayloadBatch } from '../payload-transformer' +import { GroupUserRequest } from '../request-types' + +const action: ActionDefinition = { + title: 'Group User', + description: 'Forward group event to RoadwayAI.', + defaultSubscription: 'type = "group"', + fields: getGroupUserFields(), + perform: async (request, { settings, payload }) => { + const flattenedPayload = flattenPayload(payload) + return request(`https://app.roadwayai.com/api/v1/segment/events/group`, { + method: 'POST', + headers: { + 'x-api-key': settings.apiKey + }, + json: [flattenedPayload] + }) + }, + + performBatch: async (request, { settings, payload }) => { + const transformedPayloads = flattenPayloadBatch(payload) + + return request(`https://app.roadwayai.com/api/v1/segment/events/group`, { + method: 'POST', + headers: { + 'x-api-key': settings.apiKey + }, + json: transformedPayloads + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/__tests__/index.test.ts new file mode 100644 index 00000000000..3aaf98fde27 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/__tests__/index.test.ts @@ -0,0 +1,231 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const ROADWAY_API_KEY = 'test-api-key' +const timestamp = '2021-08-17T15:21:15.449Z' + +describe('Roadwayai.identifyUser', () => { + it('should validate action fields', async () => { + const event = createTestEvent({ + type: 'identify', + timestamp, + userId: 'user123', + traits: { + name: 'John Doe', + email: 'john@example.com' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/identify').reply(200, {}) + + const responses = await testDestination.testAction('identifyUser', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + user_id: 'user123', + traits: expect.objectContaining({ + name: 'John Doe', + email: 'john@example.com' + }) + }) + ]) + }) + + it('should handle custom mapping', async () => { + const event = createTestEvent({ + type: 'identify', + timestamp, + userId: 'customuser', + traits: { + custom_name: 'Custom User', + custom_email: 'custom@example.com' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/identify').reply(200, {}) + + const responses = await testDestination.testAction('identifyUser', { + event, + mapping: { + user_id: { + '@path': '$.userId' + }, + traits: { + '@path': '$.traits' + }, + timestamp: { + '@path': '$.timestamp' + } + }, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + user_id: 'customuser', + traits: expect.objectContaining({ + custom_name: 'Custom User', + custom_email: 'custom@example.com' + }) + }) + ]) + }) + + it('should handle events with anonymous_id', async () => { + const event = createTestEvent({ + type: 'identify', + timestamp, + anonymousId: 'anon123', + traits: { + name: 'Anonymous User' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/identify').reply(200, {}) + + const responses = await testDestination.testAction('identifyUser', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + anonymous_id: 'anon123', + traits: expect.objectContaining({ + name: 'Anonymous User' + }) + }) + ]) + }) + + it('should handle events with IP address', async () => { + const event = createTestEvent({ + type: 'identify', + timestamp, + userId: 'user123', + traits: { + name: 'User with IP' + }, + context: { + ip: '192.168.1.1' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/identify').reply(200, {}) + + const responses = await testDestination.testAction('identifyUser', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + user_id: 'user123', + ip: '192.168.1.1' + }) + ]) + }) + + it('should handle events with both user_id and anonymous_id', async () => { + const event = createTestEvent({ + type: 'identify', + timestamp, + userId: 'user123', + anonymousId: 'anon456', + traits: { + name: 'Mixed ID User' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/identify').reply(200, {}) + + const responses = await testDestination.testAction('identifyUser', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + user_id: 'user123', + anonymous_id: 'anon456' + }) + ]) + }) + + it('should invoke performBatch for batches', async () => { + const events = [ + createTestEvent({ + type: 'identify', + timestamp, + userId: 'user1', + traits: { + name: 'User One', + email: 'user1@example.com' + } + }), + createTestEvent({ + type: 'identify', + timestamp, + userId: 'user2', + traits: { + name: 'User Two', + email: 'user2@example.com' + } + }) + ] + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/identify').reply(200, {}) + + const responses = await testDestination.testBatchAction('identifyUser', { + events, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + user_id: 'user1', + traits: expect.objectContaining({ + name: 'User One', + email: 'user1@example.com' + }) + }), + expect.objectContaining({ + user_id: 'user2', + traits: expect.objectContaining({ + name: 'User Two', + email: 'user2@example.com' + }) + }) + ]) + }) +}) diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..0d714318f69 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'identifyUser' +const destinationSlug = 'Roadwayai' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/generated-types.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/generated-types.ts new file mode 100644 index 00000000000..aee27af8947 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/generated-types.ts @@ -0,0 +1,30 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * When enabled, the action will use the RoadwayAI batch API. + */ + enable_batching?: boolean + /** + * A timestamp of when the event took place. Default is current date and time. + */ + timestamp?: string + /** + * The IP address of the user. This is only used for geolocation and won't be stored. + */ + ip?: string + /** + * The unique user identifier set by you + */ + user_id?: string | null + /** + * The generated anonymous ID for the user + */ + anonymous_id?: string | null + /** + * Properties to set on the user profile + */ + traits?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/index.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/index.ts new file mode 100644 index 00000000000..cfa0432338e --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/identifyUser/index.ts @@ -0,0 +1,37 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { getIdentifyUserFields } from '../unified-fields' +import { flattenPayload, flattenPayloadBatch } from '../payload-transformer' +import { IdentifyUserRequest } from '../request-types' + +const action: ActionDefinition = { + title: 'Identify User', + description: 'Set the user ID for a particular device ID or update user properties.', + defaultSubscription: 'type = "identify"', + fields: getIdentifyUserFields(), + perform: async (request, { settings, payload }) => { + const flattenedPayload = flattenPayload(payload) + return request(`https://app.roadwayai.com/api/v1/segment/events/identify`, { + method: 'POST', + headers: { + 'x-api-key': settings.apiKey + }, + json: [flattenedPayload] + }) + }, + + performBatch: async (request, { settings, payload }) => { + const transformedPayloads = flattenPayloadBatch(payload) + + return request(`https://app.roadwayai.com/api/v1/segment/events/identify`, { + method: 'POST', + headers: { + 'x-api-key': settings.apiKey + }, + json: transformedPayloads + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/index.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/index.ts new file mode 100644 index 00000000000..c466eb229a1 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/index.ts @@ -0,0 +1,75 @@ +import { DestinationDefinition } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import { Settings } from './generated-types' + +import trackEvent from './trackEvent' +import identifyUser from './identifyUser' +import groupUser from './groupUser' +import trackPageView from './trackPageView' + +const destination: DestinationDefinition = { + name: 'Roadway AI', + slug: 'roadwayai', + mode: 'cloud', + description: 'Send browser events (identify, group, track, etc.) to your RoadwayAI workspace.', + + presets: [ + { + name: 'Track Event', + subscribe: 'type = "track"', + partnerAction: 'trackEvent', + mapping: defaultValues(trackEvent.fields), + type: 'automatic' + }, + { + name: 'Track Page View', + subscribe: 'type = "page"', + partnerAction: 'trackPageView', + mapping: defaultValues(trackPageView.fields), + type: 'automatic' + }, + { + name: 'Identify User', + subscribe: 'type = "identify"', + partnerAction: 'identifyUser', + mapping: defaultValues(identifyUser.fields), + type: 'automatic' + }, + { + name: 'Group User', + subscribe: 'type = "group"', + partnerAction: 'groupUser', + mapping: defaultValues(groupUser.fields), + type: 'automatic' + } + ], + + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'API Key', + description: 'Your RoadwayAI API key for authentication', + type: 'string', + required: true + } + }, + testAuthentication: (request, { settings }) => { + return request(`https://app.roadwayai.com/api/v1/segment/validate-credentials`, { + method: 'POST', + body: JSON.stringify({ + api_key: settings.apiKey + }) + }) + } + }, + + actions: { + groupUser, + trackEvent, + identifyUser, + trackPageView + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/payload-transformer.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/payload-transformer.ts new file mode 100644 index 00000000000..5d7ac1baef9 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/payload-transformer.ts @@ -0,0 +1,39 @@ +import { Payload as TrackPayload } from './trackEvent/generated-types' +import { Payload as IdentifyUserPayload } from './identifyUser/generated-types' +import { Payload as GroupUserPayload } from './groupUser/generated-types' +import { Payload as TrackPageViewPayload } from './trackPageView/generated-types' + +/** + * Utility functions to transform object fields back to individual key:value pairs + * for RoadwayAI API compatibility + */ +type AllPayloads = TrackPayload | IdentifyUserPayload | GroupUserPayload | TrackPageViewPayload +/** + * Transforms a payload with object fields into a flattened payload with individual key:value pairs + * @param payload The payload containing object fields + * @returns Flattened payload with individual properties + */ +export function flattenPayload(payload: AllPayloads): T { + const { app_properties, location_properties, page_properties, utm_properties, ...otherFields } = + payload as Partial & Record + // Flatten the nested objects into individual properties + return { + ...otherFields, + // Spread app properties as individual fields (app_name, app_version) + ...(app_properties || {}), + // Spread location properties as individual fields (country, region, language) + ...(location_properties || {}), + // Spread page properties as individual fields (url, referrer) + ...(page_properties || {}), + // Spread UTM properties as individual fields (utm_source, utm_medium, etc.) + ...(utm_properties || {}) + } as T +} +/** + * Transforms an array of payloads for batch operations + * @param payloads Array of payloads containing object fields + * @returns Array of flattened payloads with individual properties + */ +export function flattenPayloadBatch(payloads: AllPayloads[]): T[] { + return payloads.map(flattenPayload) as T[] +} diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/request-types.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/request-types.ts new file mode 100644 index 00000000000..404c4d7f208 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/request-types.ts @@ -0,0 +1,79 @@ +/** + * TypeScript request types for RoadwayAI API endpoints + * These define the exact JSON structure your API receives + */ + +/** + * Request payload for POST /api/v1/segment/events/track + * After flattening object fields to individual properties + */ +export interface TrackEventRequest { + event: string + anonymous_id?: string + user_id?: string + group_id?: string + insert_id?: string + timestamp?: string | number + app_name?: string + app_version?: string + country?: string + region?: string + language?: string + url?: string + referrer?: string + utm_source?: string + utm_medium?: string + utm_campaign?: string + utm_term?: string + utm_content?: string + name?: string + event_properties?: Record + context?: Record + batch_size?: number + enable_batching?: boolean +} + +/** + * Request payload for POST /api/v1/segment/events/identify + */ +export interface IdentifyUserRequest { + timestamp?: string + ip?: string + user_id?: string | null + anonymous_id?: string | null + traits?: Record + enable_batching?: boolean +} + +/** + * Request payload for POST /api/v1/segment/events/group + */ +export interface GroupUserRequest { + user_id?: string + anonymous_id?: string + group_id?: string + group_name?: string + timestamp: string + traits?: Record + context?: Record + enable_batching?: boolean +} + +/** + * Request payload for POST /api/v1/segment/events/page + */ +export interface TrackPageViewRequest { + id?: string + anonymous_id?: string + event_id?: string + url: string + referrer?: string | null + timestamp?: string + utm_source?: string + utm_medium?: string + utm_campaign?: string + utm_term?: string + utm_content?: string + data?: Record + enable_batching?: boolean +} diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/__tests__/index.test.ts new file mode 100644 index 00000000000..0c32eaee08a --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/__tests__/index.test.ts @@ -0,0 +1,199 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const ROADWAY_API_KEY = 'test-api-key' +const timestamp = '2021-08-17T15:21:15.449Z' + +describe('Roadwayai.trackEvent', () => { + it('should validate action fields', async () => { + const event = createTestEvent({ + timestamp, + event: 'Test Event', + properties: { + test_property: 'test_value' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/track').reply(200, {}) + + const responses = await testDestination.testAction('trackEvent', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + event: 'Test Event', + event_properties: expect.objectContaining({ + test_property: 'test_value' + }) + }) + ]) + }) + + it('should handle custom mapping', async () => { + const event = createTestEvent({ + timestamp, + event: 'Custom Event', + properties: { + custom_prop: 'custom_value' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/track').reply(200, {}) + + const responses = await testDestination.testAction('trackEvent', { + event, + mapping: { + event: { + '@path': '$.event' + }, + timestamp: { + '@path': '$.timestamp' + }, + event_properties: { + '@path': '$.properties' + } + }, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + event: 'Custom Event', + event_properties: expect.objectContaining({ + custom_prop: 'custom_value' + }) + }) + ]) + }) + + it('should handle events with user_id', async () => { + const event = createTestEvent({ + timestamp, + event: 'Test Event', + userId: 'user123', + properties: { + test_property: 'test_value' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/track').reply(200, {}) + + const responses = await testDestination.testAction('trackEvent', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + event: 'Test Event', + user_id: 'user123' + }) + ]) + }) + + it('should handle events with anonymous_id', async () => { + const event = createTestEvent({ + timestamp, + event: 'Test Event', + anonymousId: 'anon123', + properties: { + test_property: 'test_value' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/track').reply(200, {}) + + const responses = await testDestination.testAction('trackEvent', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + event: 'Test Event', + anonymous_id: 'anon123' + }) + ]) + }) + + it('should require event field', async () => { + const event = createTestEvent({ timestamp }) + event.event = undefined + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/track').reply(200, {}) + + try { + await testDestination.testAction('trackEvent', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + } catch (e) { + expect(e.message).toBe("The root value is missing the required field 'event'.") + } + }) + + it('should invoke performBatch for batches', async () => { + const events = [ + createTestEvent({ + timestamp, + event: 'Test Event1', + properties: { prop1: 'value1' } + }), + createTestEvent({ + timestamp, + event: 'Test Event2', + properties: { prop2: 'value2' } + }) + ] + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/track').reply(200, {}) + + const responses = await testDestination.testBatchAction('trackEvent', { + events, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + event: 'Test Event1', + event_properties: expect.objectContaining({ + prop1: 'value1' + }) + }), + expect.objectContaining({ + event: 'Test Event2', + event_properties: expect.objectContaining({ + prop2: 'value2' + }) + }) + ]) + }) +}) diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..2356a8d5148 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'trackEvent' +const destinationSlug = 'Roadwayai' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/generated-types.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/generated-types.ts new file mode 100644 index 00000000000..12f37483884 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/generated-types.ts @@ -0,0 +1,120 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * When enabled, the action will use the RoadwayAI batch API. + */ + enable_batching?: boolean + /** + * The distinct ID after calling identify. + */ + user_id?: string + /** + * A distinct ID randomly generated prior to calling identify. + */ + anonymous_id?: string + /** + * The unique identifier of the group that performed this event. + */ + group_id?: string + /** + * A random id that is unique to an event. + */ + insert_id?: string + /** + * The timestamp of the event. + */ + timestamp?: string | number + /** + * An object of key-value pairs that represent additional data to be sent along with the event. + */ + event_properties?: { + [k: string]: unknown + } + /** + * An object of key-value pairs that provides useful context about the event. + */ + context?: { + [k: string]: unknown + } + /** + * The name of the action being performed. + */ + event: string + /** + * The Event Original Name, if applicable + */ + name?: string + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number + /** + * Application-specific properties and metadata + */ + app_properties?: { + /** + * The name of your application. + */ + app_name?: string + /** + * The current version of your application. + */ + app_version?: string + } + /** + * User location and locale information + */ + location_properties?: { + /** + * The current country of the user. + */ + country?: string + /** + * The current region of the user. + */ + region?: string + /** + * The language set by the user. + */ + language?: string + } + /** + * Web page context and navigation information + */ + page_properties?: { + /** + * The full URL of the webpage on which the event is triggered. + */ + url?: string + /** + * Referrer URL + */ + referrer?: string + } + /** + * UTM tracking and campaign attribution properties + */ + utm_properties?: { + /** + * The source of the campaign. + */ + utm_source?: string + /** + * The medium of the campaign. + */ + utm_medium?: string + /** + * The name of the campaign. + */ + utm_campaign?: string + /** + * The term of the campaign. + */ + utm_term?: string + /** + * The content of the campaign. + */ + utm_content?: string + } +} diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/index.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/index.ts new file mode 100644 index 00000000000..9a230867935 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/trackEvent/index.ts @@ -0,0 +1,38 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { getTrackEventFields } from '../unified-fields' +import { flattenPayload, flattenPayloadBatch } from '../payload-transformer' +import { TrackEventRequest } from '../request-types' + +const action: ActionDefinition = { + title: 'Track Event', + description: 'Send an event to RoadwayAI.', + defaultSubscription: 'type = "track"', + fields: getTrackEventFields(), + perform: async (request, { settings, payload }) => { + const flattenedPayload = flattenPayload(payload) + + return request(`https://app.roadwayai.com/api/v1/segment/events/track`, { + method: 'POST', + headers: { + 'x-api-key': settings.apiKey + }, + json: [flattenedPayload] + }) + }, + + performBatch: async (request, { settings, payload }) => { + const transformedPayloads = flattenPayloadBatch(payload) + + return request(`https://app.roadwayai.com/api/v1/segment/events/track`, { + method: 'POST', + headers: { + 'x-api-key': settings.apiKey + }, + json: transformedPayloads + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/__tests__/index.test.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/__tests__/index.test.ts new file mode 100644 index 00000000000..de2008e2cc0 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/__tests__/index.test.ts @@ -0,0 +1,212 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const ROADWAY_API_KEY = 'test-api-key' +const timestamp = '2021-08-17T15:21:15.449Z' + +describe('Roadwayai.trackPageView', () => { + it('should validate action fields', async () => { + const event = createTestEvent({ + type: 'page', + timestamp, + properties: { + url: 'https://example.com/page', + title: 'Test Page' + }, + userId: 'user123' + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/page').reply(200, {}) + + const responses = await testDestination.testAction('trackPageView', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + url: 'https://example.com/page', + id: 'user123' + }) + ]) + }) + + it('should handle custom mapping', async () => { + const event = createTestEvent({ + type: 'page', + timestamp, + properties: { + url: 'https://custom.com/page', + title: 'Custom Page' + }, + userId: 'customuser' + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/page').reply(200, {}) + + const responses = await testDestination.testAction('trackPageView', { + event, + mapping: { + url: { + '@path': '$.properties.url' + }, + id: { + '@path': '$.userId' + }, + data: { + '@path': '$.properties' + } + }, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + url: 'https://custom.com/page', + id: 'customuser' + }) + ]) + }) + + it('should handle events with anonymous_id', async () => { + const event = createTestEvent({ + type: 'page', + timestamp, + properties: { + url: 'https://example.com/page', + title: 'Test Page' + }, + anonymousId: 'anon123' + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/page').reply(200, {}) + + const responses = await testDestination.testAction('trackPageView', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + url: 'https://example.com/page', + anonymous_id: 'anon123' + }) + ]) + }) + + it('should handle events with referrer', async () => { + const event = createTestEvent({ + type: 'page', + timestamp, + properties: { + url: 'https://example.com/page', + referrer: 'https://google.com', + title: 'Test Page' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/page').reply(200, {}) + + const responses = await testDestination.testAction('trackPageView', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toContainEqual( + expect.objectContaining({ + url: 'https://example.com/page', + data: expect.objectContaining({ + referrer: 'https://google.com' + }) + }) + ) + }) + + it('should require url field', async () => { + const event = createTestEvent({ + type: 'page', + timestamp, + properties: { + title: 'Test Page' + } + }) + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/page').reply(200, {}) + + try { + await testDestination.testAction('trackPageView', { + event, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + } catch (e) { + expect(e.message).toBe("The root value is missing the required field 'url'.") + } + }) + + it('should invoke performBatch for batches', async () => { + const events = [ + createTestEvent({ + type: 'page', + timestamp, + properties: { + url: 'https://example.com/page1', + title: 'Page 1' + }, + userId: 'user1' + }), + createTestEvent({ + type: 'page', + timestamp, + properties: { + url: 'https://example.com/page2', + title: 'Page 2' + }, + userId: 'user2' + }) + ] + + nock('https://app.roadwayai.com').post('/api/v1/segment/events/page').reply(200, {}) + + const responses = await testDestination.testBatchAction('trackPageView', { + events, + useDefaultMappings: true, + settings: { + apiKey: ROADWAY_API_KEY + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject([ + expect.objectContaining({ + url: 'https://example.com/page1', + id: 'user1' + }), + expect.objectContaining({ + url: 'https://example.com/page2', + id: 'user2' + }) + ]) + }) +}) diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/__tests__/snapshot.test.ts new file mode 100644 index 00000000000..fa8079c277a --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'trackPageView' +const destinationSlug = 'Roadwayai' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/generated-types.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/generated-types.ts new file mode 100644 index 00000000000..4acc2224bb3 --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/generated-types.ts @@ -0,0 +1,63 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * When enabled, the action will use the RoadwayAI batch API. + */ + enable_batching?: boolean + /** + * UTM tracking and campaign attribution properties + */ + utm_properties?: { + /** + * The source of the campaign. + */ + utm_source?: string + /** + * The medium of the campaign. + */ + utm_medium?: string + /** + * The name of the campaign. + */ + utm_campaign?: string + /** + * The term of the campaign. + */ + utm_term?: string + /** + * The content of the campaign. + */ + utm_content?: string + } + /** + * The ID used to uniquely identify a person in RoadwayAI. + */ + id?: string + /** + * An anonymous ID for when no Person ID exists. + */ + anonymous_id?: string + /** + * An optional identifier used to deduplicate events. + */ + event_id?: string + /** + * The URL of the page visited. + */ + url: string + /** + * The page referrer + */ + referrer?: string | null + /** + * A timestamp of when the event took place. Default is current date and time. + */ + timestamp?: string + /** + * Optional data to include with the event. + */ + data?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/index.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/index.ts new file mode 100644 index 00000000000..47f2bf9ad4f --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/trackPageView/index.ts @@ -0,0 +1,37 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { getTrackPageViewFields } from '../unified-fields' +import { flattenPayload, flattenPayloadBatch } from '../payload-transformer' +import { TrackPageViewRequest } from '../request-types' + +const action: ActionDefinition = { + title: 'Track Page View', + description: 'Forward page view event to RoadwayAI.', + defaultSubscription: 'type = "page"', + fields: getTrackPageViewFields(), + perform: async (request, { settings, payload }) => { + const flattenedPayload = flattenPayload(payload) + return request(`https://app.roadwayai.com/api/v1/segment/events/page`, { + method: 'POST', + headers: { + 'x-api-key': settings.apiKey + }, + json: [flattenedPayload] + }) + }, + + performBatch: async (request, { settings, payload }) => { + const transformedPayloads = flattenPayloadBatch(payload) + + return request(`https://app.roadwayai.com/api/v1/segment/events/page`, { + method: 'POST', + headers: { + 'x-api-key': settings.apiKey + }, + json: transformedPayloads + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/metronome/roadwayai/unified-fields.ts b/packages/destination-actions/src/destinations/metronome/roadwayai/unified-fields.ts new file mode 100644 index 00000000000..bedaef981dd --- /dev/null +++ b/packages/destination-actions/src/destinations/metronome/roadwayai/unified-fields.ts @@ -0,0 +1,441 @@ +import { InputField } from '@segment/actions-core' + +export const commonFields: Record = { + enable_batching: { + type: 'boolean', + label: 'Batch Data to RoadwayAI', + description: 'When enabled, the action will use the RoadwayAI batch API.', + unsafe_hidden: true, + default: true + }, + user_id: { + label: 'User ID', + type: 'string', + description: 'The distinct ID after calling identify.', + default: { + '@path': '$.userId' + } + }, + + anonymous_id: { + label: 'Anonymous ID', + type: 'string', + description: 'A distinct ID randomly generated prior to calling identify.', + default: { + '@path': '$.anonymousId' + } + }, + + group_id: { + label: 'Group ID', + type: 'string', + description: 'The unique identifier of the group that performed this event.', + default: { + '@path': '$.context.groupId' + } + }, + + insert_id: { + label: 'Insert ID', + type: 'string', + description: 'A random id that is unique to an event.', + default: { + '@path': '$.messageId' + } + }, + + timestamp: { + label: 'Timestamp', + type: 'datetime', + required: false, + description: 'The timestamp of the event.', + default: { + '@path': '$.timestamp' + } + }, + + event_properties: { + label: 'Event Properties', + type: 'object', + description: 'An object of key-value pairs that represent additional data to be sent along with the event.', + default: { + '@path': '$.properties' + } + }, + + context: { + label: 'Event Context', + description: 'An object of key-value pairs that provides useful context about the event.', + type: 'object', + unsafe_hidden: true, + default: { + '@path': '$.context' + } + } +} + +export const appProperties: InputField = { + label: 'App Properties', + type: 'object', + description: 'Application-specific properties and metadata', + properties: { + app_name: { + label: 'App Name', + type: 'string', + description: 'The name of your application.' + }, + app_version: { + label: 'App Version', + type: 'string', + description: 'The current version of your application.' + } + }, + default: { + app_name: { '@path': '$.context.app.name' }, + app_version: { '@path': '$.context.app.version' } + } +} + +export const locationProperties: InputField = { + label: 'Location Properties', + type: 'object', + description: 'User location and locale information', + properties: { + country: { + label: 'Country', + type: 'string', + description: 'The current country of the user.' + }, + region: { + label: 'Region', + type: 'string', + description: 'The current region of the user.' + }, + language: { + label: 'Language', + type: 'string', + description: 'The language set by the user.' + } + }, + default: { + country: { '@path': '$.context.location.country' }, + region: { '@path': '$.context.location.region' }, + language: { '@path': '$.context.locale' } + } +} + +export const pageProperties: InputField = { + label: 'Page Properties', + type: 'object', + description: 'Web page context and navigation information', + properties: { + url: { + label: 'URL', + type: 'string', + description: 'The full URL of the webpage on which the event is triggered.' + }, + referrer: { + label: 'Referrer', + type: 'string', + description: 'Referrer URL' + } + }, + default: { + url: { '@path': '$.context.page.url' }, + referrer: { '@path': '$.context.page.referrer' } + } +} + +export const utmProperties: InputField = { + label: 'UTM Properties', + type: 'object', + description: 'UTM tracking and campaign attribution properties', + properties: { + utm_source: { + label: 'UTM Source', + type: 'string', + description: 'The source of the campaign.' + }, + utm_medium: { + label: 'UTM Medium', + type: 'string', + description: 'The medium of the campaign.' + }, + utm_campaign: { + label: 'UTM Campaign', + type: 'string', + description: 'The name of the campaign.' + }, + utm_term: { + label: 'UTM Term', + type: 'string', + description: 'The term of the campaign.' + }, + utm_content: { + label: 'UTM Content', + type: 'string', + description: 'The content of the campaign.' + } + }, + default: { + utm_source: { '@path': '$.context.campaign.source' }, + utm_medium: { '@path': '$.context.campaign.medium' }, + utm_campaign: { '@path': '$.context.campaign.name' }, + utm_term: { '@path': '$.context.campaign.term' }, + utm_content: { '@path': '$.context.campaign.content' } + } +} + +export const trackEventFields: Record = { + event: { + label: 'Event Name', + type: 'string', + description: 'The name of the action being performed.', + required: true, + default: { + '@path': '$.event' + } + }, + + name: { + label: 'Event Original Name', + type: 'string', + description: 'The Event Original Name, if applicable', + required: false, + default: { + '@if': { + exists: { '@path': '$.event' }, + then: { '@path': '$.event' }, + else: { '@path': '$.name' } + } + } + }, + + batch_size: { + label: 'Batch Size', + description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', + type: 'number', + required: false, + unsafe_hidden: true, + default: 1000 + } +} + +export const identifyUserFields: Record = { + timestamp: { + label: 'Timestamp', + description: 'A timestamp of when the event took place. Default is current date and time.', + type: 'string', + default: { + '@path': '$.timestamp' + } + }, + + ip: { + label: 'IP Address', + type: 'string', + description: "The IP address of the user. This is only used for geolocation and won't be stored.", + default: { + '@path': '$.context.ip' + } + }, + + user_id: { + label: 'User ID', + type: 'string', + allowNull: true, + description: 'The unique user identifier set by you', + default: { + '@path': '$.userId' + } + }, + + anonymous_id: { + label: 'Anonymous ID', + type: 'string', + allowNull: true, + description: 'The generated anonymous ID for the user', + default: { + '@path': '$.anonymousId' + } + }, + + traits: { + label: 'User Properties', + type: 'object', + description: 'Properties to set on the user profile', + default: { + '@path': '$.traits' + } + } +} + +export const groupUserFields: Record = { + user_id: { + type: 'string', + unsafe_hidden: true, + description: 'The identifier of the user', + label: 'User ID', + default: { + '@path': '$.userId' + } + }, + + anonymous_id: { + type: 'string', + unsafe_hidden: true, + description: 'Anonymous ID of the user', + label: 'Anonymous ID', + default: { + '@path': '$.anonymousId' + } + }, + + group_id: { + type: 'string', + unsafe_hidden: true, + description: 'ID of the group', + label: 'Group ID', + default: { + '@path': '$.groupId' + } + }, + + group_name: { + type: 'string', + description: 'Name of the group where user belongs to', + label: 'Group Name', + default: { + '@path': '$.traits.name' + } + }, + + timestamp: { + type: 'string', + unsafe_hidden: true, + required: true, + description: 'The time the event occurred in UTC', + label: 'Event Timestamp', + default: { + '@path': '$.timestamp' + } + }, + + traits: { + type: 'object', + description: 'Group traits', + label: 'Group Traits', + default: { + '@path': '$.traits' + } + }, + + context: { + type: 'object', + description: 'Event context', + label: 'Event Context', + default: { + '@path': '$.context' + } + } +} + +export const trackPageViewFields: Record = { + id: { + label: 'Person ID', + description: 'The ID used to uniquely identify a person in RoadwayAI.', + type: 'string', + default: { + '@path': '$.userId' + } + }, + + anonymous_id: { + label: 'Anonymous ID', + description: 'An anonymous ID for when no Person ID exists.', + type: 'string', + default: { + '@path': '$.anonymousId' + } + }, + + event_id: { + label: 'Event ID', + description: 'An optional identifier used to deduplicate events.', + type: 'string', + default: { + '@path': '$.messageId' + } + }, + + url: { + label: 'Page URL', + description: 'The URL of the page visited.', + type: 'string', + required: true, + default: { + '@if': { + exists: { '@path': '$.properties.url' }, + then: { '@path': '$.properties.url' }, + else: { '@path': '$.context.page.url' } + } + } + }, + + referrer: { + type: 'string', + allowNull: true, + description: 'The page referrer', + label: 'Page Referrer', + default: { + '@if': { + exists: { '@path': '$.context.page.referrer' }, + then: { '@path': '$.context.page.referrer' }, + else: { '@path': '$.properties.referrer' } + } + } + }, + + timestamp: { + label: 'Timestamp', + description: 'A timestamp of when the event took place. Default is current date and time.', + type: 'string', + default: { + '@path': '$.timestamp' + } + }, + + data: { + label: 'Event Attributes', + description: 'Optional data to include with the event.', + type: 'object', + default: { + '@path': '$.properties' + } + } +} + +export const getTrackEventFields = () => ({ + ...commonFields, + ...trackEventFields, + app_properties: appProperties, + location_properties: locationProperties, + page_properties: pageProperties, + utm_properties: utmProperties +}) + +export const getIdentifyUserFields = () => ({ + enable_batching: commonFields.enable_batching, + ...identifyUserFields +}) + +export const getGroupUserFields = () => ({ + enable_batching: commonFields.enable_batching, + ...groupUserFields +}) + +export const getTrackPageViewFields = () => ({ + enable_batching: commonFields.enable_batching, + utm_properties: utmProperties, + ...trackPageViewFields +})