diff --git a/packages/destination-actions/src/destinations/launchpad/generated-types.ts b/packages/destination-actions/src/destinations/launchpad/generated-types.ts new file mode 100644 index 0000000000..ff3cfaa2ed --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Launchpad project secret. You can find that in the settings in your Launchpad.pm account. + */ + apiSecret: string + /** + * Learn about [EU data residency](https://help.launchpad.pm). + */ + apiRegion?: string + /** + * This value, if it's not blank, will be sent as segment_source_name to Launchpad for every event/page/screen call. + */ + sourceName?: string +} diff --git a/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..950688a978 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Launchpad's groupIdentifyUser destination action: all fields 1`] = ` +Object { + "$set": Object { + "group_id": "$WBBWnR", + "group_testType": "$WBBWnR", + }, + "anonymoud_id": "$WBBWnR", + "api_key": "$WBBWnR", + "distinct_id": "$WBBWnR", + "event": "$identify", + "type": "screen", + "user_id": "$WBBWnR", +} +`; + +exports[`Testing snapshot for Launchpad's groupIdentifyUser destination action: required fields 1`] = ` +Object { + "$set": Object { + "group_id": "$WBBWnR", + }, + "api_key": "$WBBWnR", + "event": "$identify", + "type": "screen", +} +`; diff --git a/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/__tests__/index.test.ts new file mode 100644 index 0000000000..f654b72e86 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/__tests__/index.test.ts @@ -0,0 +1,55 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { SegmentEvent } from '@segment/actions-core' + +const launchpadAPISecret = 'lp-api-key' +const timestamp = '2023-01-28T15:21:15.449Z' + +const testDestination = createTestIntegration(Destination) + +const expectedTraits = { + group_name: 'Launchpad', + group_industry: 'Technology', + group_employees: 3, + group_plan: '1', + 'group_ARR(m)': 1503 +} + +const testGroupIdentify: SegmentEvent = { + messageId: 'test-message-t73406chv4', + timestamp: timestamp, + type: 'group', + groupId: '12381923812', + userId: 'stephen@launchpad.pm', + traits: { + name: 'Launchpad', + industry: 'Technology', + employees: 3, + plan: '1', + 'ARR(m)': 1503 + } +} + +describe('Launchpad.groupIdentifyUser', () => { + it('should convert the type and event name', async () => { + nock('https://data.launchpad.pm').post('/capture').reply(200, {}) + + const responses = await testDestination.testAction('groupIdentifyUser', { + event: testGroupIdentify, + useDefaultMappings: true, + settings: { + apiSecret: launchpadAPISecret, + sourceName: 'example segment source name' + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject({ + event: '$identify', + type: 'screen', + $set: expect.objectContaining(expectedTraits) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..d539e3259c --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/__tests__/snapshot.test.ts @@ -0,0 +1,76 @@ +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 = 'groupIdentifyUser' +const destinationSlug = 'Launchpad' +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 + }) + event.userId = 'user1234' + + 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/launchpad/groupIdentifyUser/generated-types.ts b/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/generated-types.ts new file mode 100644 index 0000000000..5888ff90b7 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/generated-types.ts @@ -0,0 +1,26 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The group key you specified in Launchpad under the company corresponding to the group. If this is not specified, it will be defaulted to "$group_id". This is helpful when you have a group of companies that should be joined together as in when you have a multinational. + */ + groupKey?: string + /** + * The unique identifier of the group. If there is a trait that matches the group key, it will override this value. + */ + groupId: string + /** + * The properties to set on the group profile. + */ + traits?: { + [k: string]: unknown + } + /** + * A unique ID for a known user. This will be used as the Distinct ID. This field is required if the Anonymous ID field is empty + */ + userId?: string + /** + * A unique ID for an anonymous user. This will be used as the Distinct ID if the User ID field is empty. This field is required if the User ID field is empty + */ + anonymousId?: string +} diff --git a/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/index.ts b/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/index.ts new file mode 100644 index 0000000000..4a577cc550 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/groupIdentifyUser/index.ts @@ -0,0 +1,89 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import { getApiServerUrl } from '../utils' +import type { Payload } from './generated-types' + +const groupIdentifyUser: ActionDefinition = { + title: 'Group Identify User', + description: + 'Updates or adds properties to a group profile. The profile is created if it does not exist. [Learn more about Group Analytics.](https://help.Launchpad.pm)', + defaultSubscription: 'type = "group"', + fields: { + groupKey: { + label: 'Group Key', + type: 'string', + required: false, + description: + 'The group key you specified in Launchpad under the company corresponding to the group. If this is not specified, it will be defaulted to "$group_id". This is helpful when you have a group of companies that should be joined together as in when you have a multinational.' + }, + groupId: { + label: 'Group ID', + type: 'string', + description: + 'The unique identifier of the group. If there is a trait that matches the group key, it will override this value.', + required: true, + default: { + '@path': '$.groupId' + } + }, + traits: { + label: 'Group Properties', + type: 'object', + description: 'The properties to set on the group profile.', + required: false, + default: { + '@path': '$.traits' + } + }, + userId: { + label: 'User ID', + type: 'string', + description: + 'A unique ID for a known user. This will be used as the Distinct ID. This field is required if the Anonymous ID field is empty', + default: { + '@path': '$.userId' + } + }, + anonymousId: { + label: 'Anonymous ID', + type: 'string', + description: + 'A unique ID for an anonymous user. This will be used as the Distinct ID if the User ID field is empty. This field is required if the User ID field is empty', + default: { + '@path': '$.anonymousId' + } + } + }, + + perform: async (request, { payload, settings }) => { + const groupId = payload.groupId + const apiServerUrl = getApiServerUrl(settings.apiRegion) + let transformed_traits + + if (payload.traits) { + transformed_traits = { + ...Object.fromEntries(Object.entries(payload.traits).map(([k, v]) => [`group_${k}`, v])) + } + } + const groupIdentifyEvent = { + event: '$identify', + type: 'screen', + $set: { + group_id: groupId, + ...transformed_traits + }, + distinct_id: payload.userId ? payload.userId : payload.anonymousId, + user_id: payload.userId, + anonymoud_id: payload.anonymousId, + api_key: settings.apiSecret + } + const groupIdentifyResponse = await request(`${apiServerUrl}capture`, { + method: 'post', + json: groupIdentifyEvent + }) + + return groupIdentifyResponse + } +} + +export default groupIdentifyUser diff --git a/packages/destination-actions/src/destinations/launchpad/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/launchpad/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..d48811b5c9 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Launchpad's identifyUser destination action: all fields 1`] = ` +Object { + "$ip": "5SpoCjYek1jtNwUC", + "$set": Object { + "testType": "5SpoCjYek1jtNwUC", + }, + "anonymous_id": "5SpoCjYek1jtNwUC", + "api_key": "5SpoCjYek1jtNwUC", + "distinct_id": "5SpoCjYek1jtNwUC", + "event": "$identify", + "type": "screen", + "user_id": "5SpoCjYek1jtNwUC", +} +`; + +exports[`Testing snapshot for Launchpad's identifyUser destination action: required fields 1`] = ` +Object { + "$set": Object { + "testType": "5SpoCjYek1jtNwUC", + }, + "api_key": "5SpoCjYek1jtNwUC", + "distinct_id": "user1234", + "event": "$identify", + "type": "screen", + "user_id": "user1234", +} +`; diff --git a/packages/destination-actions/src/destinations/launchpad/identifyUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/launchpad/identifyUser/__tests__/index.test.ts new file mode 100644 index 0000000000..d9cb02658d --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/identifyUser/__tests__/index.test.ts @@ -0,0 +1,76 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { SegmentEvent } from '@segment/actions-core' + +const launchpadAPISecret = 'lp-api-key' +const timestamp = '2023-01-28T15:21:15.449Z' + +const testDestination = createTestIntegration(Destination) + +const expectedTraits = { + email: 'steve@launchpad.pm', + name: 'Steve Jobs', + plan: '1', + group: 'Launchpad', + title: 'CEO' +} +const testIdentify: SegmentEvent = { + anonymousId: '947336ec-2294-4813-92e6-fe4a01efbf63', + integrations: {}, + messageId: 'ajs-next-11e27cb3f0a3e4e8074e9965ed19a151', + timestamp: timestamp, + traits: { + email: 'steve@launchpad.pm', + name: 'Steve Jobs', + plan: '1', + group: 'Launchpad', + title: 'CEO' + }, + type: 'identify', + userId: 'steve@launchpad.pm' +} + +describe('Launchpad.identifyUser', () => { + it('should convert the type and event name', async () => { + nock('https://data.launchpad.pm').post('/capture').reply(200, {}) + + const responses = await testDestination.testAction('identifyUser', { + event: testIdentify, + useDefaultMappings: true, + settings: { + apiSecret: launchpadAPISecret, + sourceName: 'example segment source name' + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject({ + event: '$identify', + type: 'screen', + $set: expect.objectContaining(expectedTraits) + }) + }) + + it('should send segment_source_name property if sourceName setting is defined', async () => { + nock('https://data.launchpad.pm').post('/capture').reply(200, {}) + + const responses = await testDestination.testAction('identifyUser', { + event: testIdentify, + useDefaultMappings: true, + settings: { + apiSecret: launchpadAPISecret, + sourceName: 'example segment source name' + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject({ + event: '$identify', + type: 'screen', + $set: expect.objectContaining(expectedTraits) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/launchpad/identifyUser/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/launchpad/identifyUser/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..3ea86934b0 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/identifyUser/__tests__/snapshot.test.ts @@ -0,0 +1,76 @@ +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 = 'Launchpad' +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 + }) + if (event && event.properties) event.properties.userId = 'user1234' + + 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/launchpad/identifyUser/generated-types.ts b/packages/destination-actions/src/destinations/launchpad/identifyUser/generated-types.ts new file mode 100644 index 0000000000..ea79c85680 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/identifyUser/generated-types.ts @@ -0,0 +1,22 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The IP address of the user. This is only used for geolocation and won't be stored. + */ + ip?: string + /** + * A unique ID for a known user. This will be used as the Distinct ID. This field is required if the Anonymous ID field is empty + */ + userId?: string + /** + * A unique ID for an anonymous user. This will be used as the Distinct ID if the User ID field is empty. This field is required if the User ID field is empty + */ + anonymousId?: string + /** + * Properties that you want to set on the user profile and you would want to segment by later. + */ + traits: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/launchpad/identifyUser/index.ts b/packages/destination-actions/src/destinations/launchpad/identifyUser/index.ts new file mode 100644 index 0000000000..536430ae64 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/identifyUser/index.ts @@ -0,0 +1,92 @@ +import { ActionDefinition, omit } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +import { getApiServerUrl, getConcatenatedName } from '../utils' + +const identifyUser: ActionDefinition = { + title: 'Identify User', + description: + '“Creates or updates a user profile, and adds or updates trait values on the user profile that you can use for segmentation within the Launchpad platform.', + defaultSubscription: 'type = "identify"', + fields: { + 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' + } + }, + userId: { + label: 'User ID', + type: 'string', + description: + 'A unique ID for a known user. This will be used as the Distinct ID. This field is required if the Anonymous ID field is empty', + default: { + '@path': '$.userId' + } + }, + anonymousId: { + label: 'Anonymous ID', + type: 'string', + description: + 'A unique ID for an anonymous user. This will be used as the Distinct ID if the User ID field is empty. This field is required if the User ID field is empty', + default: { + '@path': '$.anonymousId' + } + }, + traits: { + label: 'User Properties', + type: 'object', + required: true, + description: 'Properties that you want to set on the user profile and you would want to segment by later.', + default: { + '@path': '$.traits' + } + } + }, + + perform: async (request, { payload, settings }) => { + const apiServerUrl = getApiServerUrl(settings.apiRegion) + let traits + + if (payload.traits && Object.keys(payload.traits).length > 0) { + const concatenatedName = getConcatenatedName( + payload.traits.firstName, + payload.traits.lastName, + payload.traits.name + ) + traits = { + ...omit(payload.traits, ['created', 'email', 'firstName', 'lastName', 'name', 'username', 'phone']), + // to fit the Launchpad expectations, transform the special traits to Launchpad reserved property + $created: payload.traits.created, + $email: payload.traits.email, + $first_name: payload.traits.firstName, + $last_name: payload.traits.lastName, + $name: concatenatedName, + $username: payload.traits.username, + $phone: payload.traits.phone, + ...payload.traits + } + } + + const data = { + distinct_id: payload.userId ? payload.userId : payload.anonymousId, + user_id: payload.userId, + anonymous_id: payload.anonymousId, + $ip: payload.ip, + $set: traits ? traits : {}, + event: '$identify', + type: 'screen', + api_key: settings.apiSecret + } + const identifyResponse = request(`${apiServerUrl}capture`, { + method: 'post', + json: data + }) + return identifyResponse + } +} + +export default identifyUser diff --git a/packages/destination-actions/src/destinations/launchpad/index.ts b/packages/destination-actions/src/destinations/launchpad/index.ts new file mode 100644 index 0000000000..ef33a3defc --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/index.ts @@ -0,0 +1,99 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import trackEvent from './trackEvent' +import identifyUser from './identifyUser' +import groupIdentifyUser from './groupIdentifyUser' + +import { ApiRegions } from './utils' + +/** used in the quick setup */ +const presets: DestinationDefinition['presets'] = [ + { + name: 'Track Calls', + subscribe: 'type = "track" and event != "Order Completed"', + partnerAction: 'trackEvent', + mapping: defaultValues(trackEvent.fields) + }, + { + name: 'Page Calls', + subscribe: 'type = "page"', + partnerAction: 'trackEvent', + mapping: { + ...defaultValues(trackEvent.fields), + event: { + '@template': 'Viewed {{name}}' + } + } + }, + { + name: 'Screen Calls', + subscribe: 'type = "screen"', + partnerAction: 'trackEvent', + mapping: { + ...defaultValues(trackEvent.fields), + event: { + '@template': 'Viewed {{name}}' + } + } + }, + { + name: 'Identify Calls', + subscribe: 'type = "identify"', + partnerAction: 'identifyUser', + mapping: defaultValues(identifyUser.fields) + }, + { + name: 'Group Calls', + subscribe: 'type = "group"', + partnerAction: 'groupIdentifyUser', + mapping: defaultValues(groupIdentifyUser.fields) + } +] + +const destination: DestinationDefinition = { + name: 'Launchpad (Actions)', + slug: 'actions-launchpad', + mode: 'cloud', + authentication: { + scheme: 'custom', + fields: { + apiSecret: { + label: 'Secret Key', + description: 'Launchpad project secret. You can find that in the settings in your Launchpad.pm account.', + type: 'password', + required: true + }, + apiRegion: { + label: 'Data Residency', + description: 'Learn about [EU data residency](https://help.launchpad.pm).', + type: 'string', + choices: Object.values(ApiRegions).map((apiRegion) => ({ label: apiRegion, value: apiRegion })), + default: ApiRegions.US + }, + sourceName: { + label: 'Source Name', + description: + "This value, if it's not blank, will be sent as segment_source_name to Launchpad for every event/page/screen call.", + type: 'string' + } + }, + testAuthentication: (request, { settings }) => { + return request(`https://backend.launchpad.pm/api/data/validate-project-credentials/`, { + method: 'post', + body: JSON.stringify({ + api_secret: settings.apiSecret + }) + }) + } + }, + presets, + actions: { + trackEvent, + identifyUser, + groupIdentifyUser + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/launchpad/launchpad-types.ts b/packages/destination-actions/src/destinations/launchpad/launchpad-types.ts new file mode 100644 index 0000000000..f580ab4a1b --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/launchpad-types.ts @@ -0,0 +1,29 @@ +export type LaunchpadEventProperties = { + anonymous_id?: string // 'anon-2134' + browser_version?: string // '9.0' + browser?: string // 'Mobile Safari' + current_url?: string // 'https?://segment.com/academy/' + device?: string // 'maguro' + group_id?: string // 'groupId123' + identified_id?: string // 'user1234' + messageId?: string // '859d3955-363f-590a-9aa4-b4f49b582437' + ip?: string | unknown // '192.168.1.1' + os_version?: string // '8.1.3' + os?: string // 'iPhone OS' + referrer?: string + user_id?: string + source: string // 'segment' + distinct_id: string | undefined // 'test_segment_user' + id?: string | null // this is just to maintain backwards compatibility with the classic segment integration, I'm not completely sure what the purpose of this was. + segment_source_name?: string // 'readme' + time?: string | number | undefined + properties?: [k: string] | unknown //event props + traits?: [k: string] | unknown + context?: [k: string] | unknown +} + +export type LaunchpadEvent = { + event?: string + properties?: LaunchpadEventProperties + api_key: string +} diff --git a/packages/destination-actions/src/destinations/launchpad/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/launchpad/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..6cef94104d --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Launchpad's trackEvent destination action: all fields 1`] = ` +Object { + "api_key": "8I03tIp&]#vR", + "event": "8I03tIp&]#vR", + "properties": Object { + "anonymous_id": "8I03tIp&]#vR", + "context": Object { + "testType": "8I03tIp&]#vR", + }, + "distinct_id": "8I03tIp&]#vR", + "group_id": "8I03tIp&]#vR", + "messageId": "8I03tIp&]#vR", + "properties": Object { + "testType": "8I03tIp&]#vR", + }, + "segment_source_name": "8I03tIp&]#vR", + "time": "2021-02-01T00:00:00.000Z", + "traits": Object { + "testType": "8I03tIp&]#vR", + }, + "user_id": "8I03tIp&]#vR", + }, +} +`; + +exports[`Testing snapshot for Launchpad's trackEvent destination action: required fields 1`] = ` +Object { + "api_key": "8I03tIp&]#vR", + "event": "8I03tIp&]#vR", + "properties": Object { + "messageId": "8I03tIp&]#vR", + "segment_source_name": "8I03tIp&]#vR", + "time": "2021-02-01T00:00:00.000Z", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/launchpad/trackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/launchpad/trackEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..35d4d41828 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/trackEvent/__tests__/index.test.ts @@ -0,0 +1,63 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { ApiRegions } from '../../utils' + +const testDestination = createTestIntegration(Destination) + +const launchpadAPISecret = 'lp-api-key' +const timestamp = '2023-01-28T15:21:15.449Z' + +const mustHaveProps = { + distinct_id: 'user1234', + ip: '8.8.8.8', + properties: {}, + traits: {}, + user_id: 'user1234' +} + +describe('Launchpad.trackEvent', () => { + it('should always return distinct id', async () => { + const event = createTestEvent({ timestamp, event: 'Test Event' }) + + nock('https://data.launchpad.pm').post('/capture').reply(200, {}) + + const responses = await testDestination.testAction('trackEvent', { + event, + useDefaultMappings: true, + settings: { + apiSecret: launchpadAPISecret, + apiRegion: ApiRegions.EU, + sourceName: 'segment' + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject({ + event: 'Test Event', + properties: expect.objectContaining(mustHaveProps) + }) + }) + + it('should default to the EU endpoint if apiRegion setting is undefined', async () => { + const event = createTestEvent({ timestamp, event: 'Test Event' }) + + nock('https://data.launchpad.pm').post('/capture').reply(200, {}) + + const responses = await testDestination.testAction('trackEvent', { + event, + useDefaultMappings: true, + settings: { + apiSecret: launchpadAPISecret, + sourceName: 'segment' + } + }) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toMatchObject({ + event: 'Test Event', + properties: expect.objectContaining(mustHaveProps) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/launchpad/trackEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/launchpad/trackEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..3fbd0d4868 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/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 = 'Launchpad' +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/launchpad/trackEvent/generated-types.ts b/packages/destination-actions/src/destinations/launchpad/trackEvent/generated-types.ts new file mode 100644 index 0000000000..fefc33d628 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/trackEvent/generated-types.ts @@ -0,0 +1,50 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the action being performed. + */ + event: string + /** + * A unique ID for a known user. This will be used as the Distinct ID. This field is required if the Anonymous ID field is empty + */ + userId?: string + /** + * A unique ID for an anonymous user. This will be used as the Distinct ID if the User ID field is empty. This field is required if the User ID field is empty + */ + anonymousId?: string + /** + * The unique identifier of the group that performed this event. + */ + groupId?: string + /** + * A random id that is unique to an event. Launchpad uses $insert_id to deduplicate events. + */ + messageId: string + /** + * The timestamp of the event. Launchpad expects epoch timestamp in millisecond or second. Please note, Launchpad only accepts this field as the timestamp. If the field is empty, it will be set to the time Launchpad servers receive it. + */ + timestamp: string | number + /** + * An object of key-value pairs that represent additional data to be sent along with the event. + */ + properties?: { + [k: string]: unknown + } + /** + * An object of key-value pairs that represent additional data tied to the user. This is used for segmentation within the platform. + */ + traits?: { + [k: string]: unknown + } + /** + * An object of key-value pairs that provides useful context about the event. + */ + context?: { + [k: string]: unknown + } + /** + * Set as true to ensure Segment sends data to Launchpad in batches. + */ + enable_batching?: boolean +} diff --git a/packages/destination-actions/src/destinations/launchpad/trackEvent/index.ts b/packages/destination-actions/src/destinations/launchpad/trackEvent/index.ts new file mode 100644 index 0000000000..769a6dbdce --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/trackEvent/index.ts @@ -0,0 +1,72 @@ +import { ActionDefinition, RequestClient } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { LaunchpadEvent } from '../launchpad-types' +import { getApiServerUrl } from '../utils' +import { LaunchpadEventProperties } from '../launchpad-types' +import { eventProperties } from './launchpad-properties' + +function getEventProperties(payload: Payload, settings: Settings): LaunchpadEventProperties { + const integration = payload.context?.integration as Record + return { + time: payload.timestamp, + ip: payload.context?.ip, + anonymous_id: payload.anonymousId, + distinct_id: payload.userId ? payload.userId : payload.anonymousId, + context: payload.context, + group_id: payload.groupId, + properties: payload.properties, + traits: payload.traits ? payload.traits : payload.context?.traits, + messageId: payload.messageId, + source: integration?.name != 'Segment' ? integration?.name : 'Segment', + user_id: payload.userId, + segment_source_name: settings.sourceName + } +} + +const getEventFromPayload = (payload: Payload, settings: Settings): LaunchpadEvent => { + const event: LaunchpadEvent = { + event: payload.event, + properties: { + ...getEventProperties(payload, settings) + }, + api_key: settings.apiSecret + } + return event +} + +const processData = async (request: RequestClient, settings: Settings, payload: Payload) => { + const event = getEventFromPayload(payload, settings) + const urlAddendum = 'capture' + + const requestURL: string = `${getApiServerUrl(settings.apiRegion)}` + urlAddendum + + return request(requestURL, { + method: 'post', + json: event + }) +} + +const trackEvent: ActionDefinition = { + title: 'Track Event', + description: 'Send an event to Launchpad. [Learn more about Events in Launchpad](https://help.launchpad.pm)', + defaultSubscription: 'type = "track"', + fields: { + event: { + label: 'Event Name', + type: 'string', + description: 'The name of the action being performed.', + required: true, + default: { + '@path': '$.event' + } + }, + ...eventProperties + }, + + perform: async (request, { settings, payload }) => { + return processData(request, settings, payload) + } +} + +export default trackEvent diff --git a/packages/destination-actions/src/destinations/launchpad/trackEvent/launchpad-properties.ts b/packages/destination-actions/src/destinations/launchpad/trackEvent/launchpad-properties.ts new file mode 100644 index 0000000000..1476ec9bb3 --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/trackEvent/launchpad-properties.ts @@ -0,0 +1,89 @@ +import { InputField } from '@segment/actions-core' + +export const eventProperties: Record = { + userId: { + label: 'User ID', + type: 'string', + description: + 'A unique ID for a known user. This will be used as the Distinct ID. This field is required if the Anonymous ID field is empty', + default: { + '@path': '$.userId' + } + }, + anonymousId: { + label: 'Anonymous ID', + type: 'string', + description: + 'A unique ID for an anonymous user. This will be used as the Distinct ID if the User ID field is empty. This field is required if the User ID field is empty', + default: { + '@path': '$.anonymousId' + } + }, + groupId: { + label: 'Group ID', + type: 'string', + required: false, + description: 'The unique identifier of the group that performed this event.', + default: { + '@path': '$.context.groupId' + } + }, + messageId: { + label: 'Insert ID', + type: 'string', + required: true, + description: 'A random id that is unique to an event. Launchpad uses $insert_id to deduplicate events.', + default: { + '@path': '$.messageId' + } + }, + timestamp: { + label: 'Timestamp', + type: 'datetime', + required: true, + description: + 'The timestamp of the event. Launchpad expects epoch timestamp in millisecond or second. Please note, Launchpad only accepts this field as the timestamp. If the field is empty, it will be set to the time Launchpad servers receive it.', + default: { + '@path': '$.timestamp' + } + }, + properties: { + required: false, + 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' + } + }, + traits: { + required: false, + label: 'User Properties', + type: 'object', + description: + 'An object of key-value pairs that represent additional data tied to the user. This is used for segmentation within the platform.', + default: { + '@if': { + exists: { '@path': '$.traits' }, + then: { '@path': '$.traits' }, + else: { '@path': '$.context.traits' } + } + } + }, + context: { + label: 'Event context', + required: false, + description: 'An object of key-value pairs that provides useful context about the event.', + type: 'object', + default: { + '@path': '$.context' + } + }, + enable_batching: { + required: false, + type: 'boolean', + label: 'Batch Data to Launchpad', + description: 'Set as true to ensure Segment sends data to Launchpad in batches.', + default: true + } +} diff --git a/packages/destination-actions/src/destinations/launchpad/utils.ts b/packages/destination-actions/src/destinations/launchpad/utils.ts new file mode 100644 index 0000000000..e88b4d572e --- /dev/null +++ b/packages/destination-actions/src/destinations/launchpad/utils.ts @@ -0,0 +1,20 @@ +export enum ApiRegions { + US = 'US 🇺🇸', + EU = 'EU 🇪🇺' +} + +// export enum StrictMode { +// ON = '1', +// OFF = '0' +// } + +export function getConcatenatedName(firstName: unknown, lastName: unknown, name: unknown): unknown { + return name ?? (firstName && lastName ? `${firstName} ${lastName}` : undefined) +} + +export function getApiServerUrl(apiRegion: string | unknown) { + if (apiRegion == ApiRegions.EU) { + return 'https://data.launchpad.pm/' + } + return 'https://data.launchpad.pm/' +}