diff --git a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts new file mode 100644 index 00000000000..e58c92e967a --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/destination.test.ts @@ -0,0 +1,158 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Destination from '../index' +import { MNTN_API_VERSION } from '../versioning-info' + +const testDestination = createTestIntegration(Destination as any) + +const MNTN_BASE = 'https://integrations.ex.mountain.com' +const TEST_SETTINGS = { + api_key: 'test-api-key-secret' +} + +beforeEach(() => { + nock.cleanAll() +}) + +afterAll(() => { + nock.cleanAll() + nock.restore() +}) + +// ─── testAuthentication ─────────────────────────────────────────────────────── + +describe('testAuthentication', () => { + it('succeeds when the API responds 200', async () => { + nock(MNTN_BASE, { reqheaders: { authorization: `Bearer ${TEST_SETTINGS.api_key}` } }) + .get(`/${MNTN_API_VERSION}/audience/segments`) + .query({ limit: '1' }) + .reply(200, { segments: [] }) + + await expect(testDestination.testAuthentication(TEST_SETTINGS)).resolves.not.toThrow() + }) + + it('throws when the API responds 401', async () => { + nock(MNTN_BASE) + .get(`/${MNTN_API_VERSION}/audience/segments`) + .query({ limit: '1' }) + .reply(401, { error: { code: 'Unauthenticated' } }) + + await expect(testDestination.testAuthentication(TEST_SETTINGS)).rejects.toThrow(/Credentials are invalid: 401/) + }) +}) + +// ─── createAudience ─────────────────────────────────────────────────────────── + +describe('createAudience', () => { + it('POSTs to create a new segment and returns its ID as externalId', async () => { + nock(MNTN_BASE) + .post(`/${MNTN_API_VERSION}/audience/segments`, { segment: { name: 'High Value Users' } }) + .reply(200, { segment: { id: 'mntn-new-id', name: 'High Value Users' } }) + + const result = await testDestination.createAudience({ + audienceName: 'High Value Users', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + + expect(result).toEqual({ externalId: 'mntn-new-id' }) + }) + + it('uses pre-configured segment_id without calling the API', async () => { + const result = await testDestination.createAudience({ + audienceName: 'My Audience', + settings: TEST_SETTINGS, + audienceSettings: { segment_id: 'pre-existing-001' } + }) + + expect(result).toEqual({ externalId: 'pre-existing-001' }) + }) + + it('throws PayloadValidationError when audienceName is missing and no segment_id configured', async () => { + await expect( + testDestination.createAudience({ + audienceName: '', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + ).rejects.toThrow(/missing audience name/i) + }) + + it('throws IntegrationError if the API response has no segment.id', async () => { + nock(MNTN_BASE).post(`/${MNTN_API_VERSION}/audience/segments`).reply(200, { segment: {} }) + + await expect( + testDestination.createAudience({ + audienceName: 'Broken Audience', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + ).rejects.toThrow(/unexpected response/i) + }) +}) + +// ─── getAudience ────────────────────────────────────────────────────────────── + +describe('getAudience', () => { + it('GETs the segment by externalId and returns it', async () => { + const segmentId = 'existing-seg-abc' + + nock(MNTN_BASE).get(`/${MNTN_API_VERSION}/audience/segments/${segmentId}`).reply(200, { segment: { id: segmentId } }) + + const result = await testDestination.getAudience({ + externalId: segmentId, + settings: TEST_SETTINGS, + audienceSettings: {} + }) + + expect(result).toEqual({ externalId: segmentId }) + }) + + it('prefers audienceSettings.segment_id over externalId', async () => { + const overrideId = 'override-seg-999' + + nock(MNTN_BASE).get(`/${MNTN_API_VERSION}/audience/segments/${overrideId}`).reply(200, { segment: { id: overrideId } }) + + const result = await testDestination.getAudience({ + externalId: 'stale-id', + settings: TEST_SETTINGS, + audienceSettings: { segment_id: overrideId } + }) + + expect(result).toEqual({ externalId: overrideId }) + }) + + it('throws when neither externalId nor segment_id is provided', async () => { + await expect( + testDestination.getAudience({ + externalId: '', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + ).rejects.toThrow(/no mntn segment id found/i) + }) + + it('throws when the API responds 404', async () => { + nock(MNTN_BASE).get(`/${MNTN_API_VERSION}/audience/segments/does-not-exist`).reply(404, { error: { code: 'NotFound' } }) + + await expect( + testDestination.getAudience({ + externalId: 'does-not-exist', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + ).rejects.toThrow() + }) + + it('throws IntegrationError if the API response has no segment.id', async () => { + nock(MNTN_BASE).get(`/${MNTN_API_VERSION}/audience/segments/seg-abc`).reply(200, { segment: {} }) + + await expect( + testDestination.getAudience({ + externalId: 'seg-abc', + settings: TEST_SETTINGS, + audienceSettings: {} + }) + ).rejects.toThrow(/unexpected response/i) + }) +}) diff --git a/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts new file mode 100644 index 00000000000..9d45cf4734a --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/__tests__/syncAudience.test.ts @@ -0,0 +1,582 @@ +/** + * Tests for the syncAudience action. + * + * Covers: + * - Add path (perform): audienceMembership=true → POST with { identity: ... } + * - Remove path (perform): audienceMembership=false → DELETE with identity_id in path + * - Phone normalization: non-numeric chars including + are stripped before sending/hashing + * - Email normalization: lowercased before sending/hashing + * - Batch adds (performBatch): POST with { identities: [...] } + * - Batch removes (performBatch): DELETE with comma-separated URL-encoded IDs + * - Duplicate identity_id detection + * - MultiStatusResponse shape on success and error + */ + +import nock from 'nock' +import { createHash } from 'crypto' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../index' +import { MNTN_API_VERSION } from '../versioning-info' + +const testDestination = createTestIntegration(Destination as any) + +const MNTN_BASE = 'https://integrations.ex.mountain.com' +const SEGMENT_ID = 'seg-abc-123' +const USER_ID = 'user-123' +const ANON_ID = 'anon-456' +const AUDIENCE_KEY = 'my_audience' +const EMAIL = 'user@example.com' +const PHONE_E164 = '+15556004638' +const PHONE_NORMALIZED = '15556004638' // + and non-numeric stripped +const IP = '9.165.155.19' +const TIMESTAMP = '2026-03-25T10:00:00.000Z' + +const TEST_SETTINGS = { + api_key: 'test-api-key-secret' +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +// Pull the action directly for testing performBatch without the test harness +const syncAudienceAction = (Destination as any).actions.syncAudience + +beforeEach(() => { + nock.cleanAll() +}) + +afterAll(() => { + nock.cleanAll() + nock.restore() +}) + +// ─── perform: add path ──────────────────────────────────────────────────────── + +describe('syncAudience — perform: add', () => { + it('sends POST when audienceMembership is true', async () => { + const scope = nock(MNTN_BASE) + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { email: EMAIL, [AUDIENCE_KEY]: true }, + context: { + ip: IP, + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + useDefaultMappings: true + }) + + expect(scope.isDone()).toBe(true) + }) + + it('sends identity.id, source, and identifiers in POST body', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { email: EMAIL, [AUDIENCE_KEY]: true }, + context: { + ip: IP, + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + useDefaultMappings: true + }) + + expect(capturedBody.identity.id).toBe(USER_ID) + expect(capturedBody.identity.source).toBe('segment') + expect(capturedBody.identity.identifiers).toBeDefined() + }) + + it('wraps single-event body in { identity } not { identities }', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { email: EMAIL, [AUDIENCE_KEY]: true }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + useDefaultMappings: true + }) + + expect(capturedBody.identity).toBeDefined() + expect(capturedBody.identities).toBeUndefined() + }) + + it('includes source_time when timestamp is present', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + timestamp: TIMESTAMP, + traits: { [AUDIENCE_KEY]: true }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + mapping: { + segment_id: SEGMENT_ID, + identity_id: USER_ID, + timestamp: TIMESTAMP + } + }) + + expect(capturedBody.identity.source_time).toEqual({ rfc3339: TIMESTAMP }) + }) +}) + +// ─── perform: remove path ───────────────────────────────────────────────────── + +describe('syncAudience — perform: remove', () => { + it('sends DELETE when audienceMembership is false', async () => { + const scope = nock(MNTN_BASE) + .delete(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities/${USER_ID}`) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { [AUDIENCE_KEY]: false }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + useDefaultMappings: true + }) + + expect(scope.isDone()).toBe(true) + }) + + it('falls back to anonymousId when userId is absent', async () => { + const scope = nock(MNTN_BASE) + .delete(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities/${ANON_ID}`) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: undefined, + anonymousId: ANON_ID, + traits: { [AUDIENCE_KEY]: false }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + useDefaultMappings: true + }) + + expect(scope.isDone()).toBe(true) + }) +}) + +// ─── Phone normalization ────────────────────────────────────────────────────── + +describe('syncAudience — phone normalization', () => { + it('strips + and non-numeric chars from phone before sending and hashing', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { [AUDIENCE_KEY]: true }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + mapping: { + segment_id: SEGMENT_ID, + identity_id: USER_ID, + phone: PHONE_E164 + } + }) + + const phoneEntry = capturedBody.identity.identifiers.find((id: any) => id.kind === 'phone') + const hashEntry = capturedBody.identity.identifiers.find((id: any) => id.kind === 'phone_sha256') + + expect(phoneEntry.value).toBe(PHONE_NORMALIZED) + expect(hashEntry.value).toBe(sha256(PHONE_NORMALIZED)) + }) + + it('strips spaces and dashes from phone numbers', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { [AUDIENCE_KEY]: true }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + mapping: { + segment_id: SEGMENT_ID, + identity_id: USER_ID, + phone: '1 (555) 600-4638' + } + }) + + const phoneEntry = capturedBody.identity.identifiers.find((id: any) => id.kind === 'phone') + expect(phoneEntry.value).toBe('15556004638') + }) +}) + +// ─── Email normalization ────────────────────────────────────────────────────── + +describe('syncAudience — email normalization', () => { + it('lowercases email and hashes the normalized value', async () => { + let capturedBody: any + + nock(MNTN_BASE) + .post(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`, (body) => { + capturedBody = body + return true + }) + .reply(202, {}) + + await testDestination.testAction('syncAudience', { + event: createTestEvent({ + type: 'identify', + userId: USER_ID, + traits: { [AUDIENCE_KEY]: true }, + context: { + personas: { + computation_class: 'audience', + computation_key: AUDIENCE_KEY, + external_audience_id: SEGMENT_ID + } + } + }), + settings: TEST_SETTINGS, + mapping: { + segment_id: SEGMENT_ID, + identity_id: USER_ID, + email: 'User@Example.COM' + } + }) + + const emailEntry = capturedBody.identity.identifiers.find((id: any) => id.kind === 'email') + const hashEntry = capturedBody.identity.identifiers.find((id: any) => id.kind === 'email_sha256') + + expect(emailEntry.value).toBe('user@example.com') + expect(hashEntry.value).toBe(sha256('user@example.com')) + }) +}) + +// ─── performBatch: adds ─────────────────────────────────────────────────────── + +describe('syncAudience — performBatch: adds', () => { + it('sends POST with { identities: [...] } array for batch adds', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user-1', + email: 'user1@example.com' + }, + { + segment_id: SEGMENT_ID, + identity_id: 'user-2', + email: 'user2@example.com' + } + ], + audienceMembership: [true, true], + settings: TEST_SETTINGS + }) + + expect(mockRequest).toHaveBeenCalledTimes(1) + const [url, options] = mockRequest.mock.calls[0] + expect(url).toContain(`/${MNTN_API_VERSION}/audience/segments/${SEGMENT_ID}/identities`) + expect(options.method).toBe('POST') + expect(options.json.identities).toHaveLength(2) + expect(options.json.identity).toBeUndefined() + }) + + it('returns MultiStatusResponse with 202 for each added identity', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + const result = await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user-1' + }, + { + segment_id: SEGMENT_ID, + identity_id: 'user-2' + } + ], + audienceMembership: [true, true], + settings: TEST_SETTINGS + }) + + expect(result.getResponseAtIndex(0).value().status).toBe(202) + expect(result.getResponseAtIndex(1).value().status).toBe(202) + }) +}) + +// ─── performBatch: removes ──────────────────────────────────────────────────── + +describe('syncAudience — performBatch: removes', () => { + it('sends DELETE with comma-separated URL-encoded IDs', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user-1' + }, + { + segment_id: SEGMENT_ID, + identity_id: 'user-2' + } + ], + audienceMembership: [false, false], + settings: TEST_SETTINGS + }) + + expect(mockRequest).toHaveBeenCalledTimes(1) + const [url, options] = mockRequest.mock.calls[0] + expect(options.method).toBe('DELETE') + expect(url).toContain('user-1%2Cuser-2') + }) + + it('URL-encodes special characters in identity IDs', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user@example.com' + } + ], + audienceMembership: [false], + settings: TEST_SETTINGS + }) + + const [url] = mockRequest.mock.calls[0] + expect(url).toContain(encodeURIComponent('user@example.com')) + }) +}) + +// ─── performBatch: mixed ───────────────────────────────────────────────────── + +describe('syncAudience — performBatch: mixed', () => { + it('sends separate POST and DELETE for mixed add/remove in same segment', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user-add' + }, + { + segment_id: SEGMENT_ID, + identity_id: 'user-remove' + } + ], + audienceMembership: [true, false], + settings: TEST_SETTINGS + }) + + expect(mockRequest).toHaveBeenCalledTimes(2) + const methods = mockRequest.mock.calls.map(([, opts]) => opts.method) + expect(methods).toContain('POST') + expect(methods).toContain('DELETE') + }) + + it('returns error responses in MultiStatusResponse when API fails', async () => { + const mockRequest = jest.fn().mockRejectedValue( + Object.assign(new Error('Service Unavailable'), { response: { status: 503 } }) + ) + + const result = await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { + segment_id: SEGMENT_ID, + identity_id: 'user-1' + } + ], + audienceMembership: [true], + settings: TEST_SETTINGS + }) + + expect(result.getResponseAtIndex(0).value().status).toBe(503) + }) +}) + +// ─── missing audience membership: fail fast ───────────────────────────────── + +describe('syncAudience — missing audience membership', () => { + it('perform throws instead of issuing a DELETE when audienceMembership is undefined', async () => { + const mockRequest = jest.fn() + + await expect( + syncAudienceAction.perform(mockRequest, { + payload: { segment_id: SEGMENT_ID, identity_id: 'user-1' }, + settings: TEST_SETTINGS + }) + ).rejects.toThrow(/audience membership/i) + + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('performBatch marks entries with missing membership as errors and does not DELETE them', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + const result = await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { segment_id: SEGMENT_ID, identity_id: 'user-1' }, + { segment_id: SEGMENT_ID, identity_id: 'user-2' } + ], + audienceMembership: [true], + settings: TEST_SETTINGS + }) + + expect(result.getResponseAtIndex(0).value().status).toBe(202) + expect(result.getResponseAtIndex(1).value().status).toBe(400) + expect(result.getResponseAtIndex(1).value().errormessage).toMatch(/audience membership/i) + + expect(mockRequest).toHaveBeenCalledTimes(1) + expect(mockRequest.mock.calls[0][1].method).toBe('POST') + }) + + it('performBatch issues no DELETE when audienceMembership is entirely missing', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + const result = await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { segment_id: SEGMENT_ID, identity_id: 'user-1' }, + { segment_id: SEGMENT_ID, identity_id: 'user-2' } + ], + settings: TEST_SETTINGS + }) + + expect(result.getResponseAtIndex(0).value().status).toBe(400) + expect(result.getResponseAtIndex(1).value().status).toBe(400) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) + +// ─── performBatch: duplicate detection ─────────────────────────────────────── + +describe('syncAudience — performBatch: duplicate detection', () => { + it('marks duplicate identity_id entries as errors and only sends unique identities', async () => { + const mockRequest = jest.fn().mockResolvedValue({ status: 202, data: {} }) + + const result = await syncAudienceAction.performBatch(mockRequest, { + payload: [ + { segment_id: SEGMENT_ID, identity_id: 'user-1', email: 'a@example.com' }, + { segment_id: SEGMENT_ID, identity_id: 'user-1', email: 'b@example.com' }, + { segment_id: SEGMENT_ID, identity_id: 'user-2', email: 'c@example.com' } + ], + audienceMembership: [true, true, true], + settings: TEST_SETTINGS + }) + + expect(result.getResponseAtIndex(0).value().status).toBe(202) + expect(result.getResponseAtIndex(1).value().status).toBe(400) + expect(result.getResponseAtIndex(1).value().errormessage).toMatch(/duplicate identity_id/i) + expect(result.getResponseAtIndex(2).value().status).toBe(202) + + expect(mockRequest).toHaveBeenCalledTimes(1) + const [, options] = mockRequest.mock.calls[0] + expect(options.json.identities).toHaveLength(2) + }) +}) diff --git a/packages/destination-actions/src/destinations/mntn-audiences/constants.ts b/packages/destination-actions/src/destinations/mntn-audiences/constants.ts new file mode 100644 index 00000000000..fa93e7c7e3b --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/constants.ts @@ -0,0 +1 @@ +export const MNTN_API_BASE = 'https://integrations.ex.mountain.com' diff --git a/packages/destination-actions/src/destinations/mntn-audiences/functions.ts b/packages/destination-actions/src/destinations/mntn-audiences/functions.ts new file mode 100644 index 00000000000..92c89e11e4c --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/functions.ts @@ -0,0 +1,76 @@ +import { IntegrationError, PayloadValidationError, RequestClient } from '@segment/actions-core' +import { MNTN_API_BASE } from './constants' +import { MNTN_API_VERSION } from './versioning-info' +import type { CreateAudienceInput, GetAudienceInput, SegmentResponse } from './types' + +export async function testAuthentication(request: RequestClient) { + return request(`${MNTN_API_BASE}/${MNTN_API_VERSION}/audience/segments?limit=1`, { + method: 'GET' + }) +} + +export async function createAudience(request: RequestClient, createAudienceInput: CreateAudienceInput) { + const { audienceName, audienceSettings: { segment_id } = {} } = createAudienceInput + + if (segment_id) { + return { externalId: segment_id } + } + + if (!audienceName) { + throw new PayloadValidationError( + 'Missing audience name. Provide an audience name or supply a pre-existing MNTN Segment ID in the audience settings.' + ) + } + + const response = await request(`${MNTN_API_BASE}/${MNTN_API_VERSION}/audience/segments`, { + method: 'POST', + json: { + segment: { + name: audienceName + } + } + }) + + const id = response.data?.segment?.id + + if (!id) { + throw new IntegrationError( + 'MNTN returned an unexpected response when creating the audience segment. Please try again or contact MNTN support.', + 'INVALID_RESPONSE', + 500 + ) + } + + return { externalId: id } +} + +export async function getAudience(request: RequestClient, getAudienceInput: GetAudienceInput) { + const { externalId, audienceSettings: { segment_id } = {} } = getAudienceInput + + const segmentId = segment_id || externalId + + if (!segmentId) { + throw new IntegrationError( + 'No MNTN Segment ID found. Ensure the destination was properly initialized, or provide a Segment ID in the audience settings.', + 'MISSING_SEGMENT_ID', + 400 + ) + } + + const response = await request( + `${MNTN_API_BASE}/${MNTN_API_VERSION}/audience/segments/${encodeURIComponent(segmentId)}`, + { method: 'GET' } + ) + + const id = response.data?.segment?.id + + if (!id) { + throw new IntegrationError( + 'MNTN returned an unexpected response when verifying the audience segment. Please try again or contact MNTN support.', + 'INVALID_RESPONSE', + 500 + ) + } + + return { externalId: id } +} diff --git a/packages/destination-actions/src/destinations/mntn-audiences/generated-types.ts b/packages/destination-actions/src/destinations/mntn-audiences/generated-types.ts new file mode 100644 index 00000000000..415f3d12e21 --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your MNTN Audience API key, issued via the MNTN Integrations Marketplace. Treat this value as a secret — do not share it or commit it to source control. + */ + api_key: string +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface AudienceSettings { + /** + * The ID of a pre-existing MNTN audience segment to sync to. If left blank, a new MNTN segment will be created automatically when this destination is enabled for an audience. + */ + segment_id?: string +} diff --git a/packages/destination-actions/src/destinations/mntn-audiences/index.ts b/packages/destination-actions/src/destinations/mntn-audiences/index.ts new file mode 100644 index 00000000000..f1377ab5610 --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/index.ts @@ -0,0 +1,70 @@ +import type { AudienceDestinationDefinition } from '@segment/actions-core' +import type { Settings, AudienceSettings } from './generated-types' +import syncAudience from './syncAudience' +import { testAuthentication, createAudience, getAudience } from './functions' + +const destination: AudienceDestinationDefinition = { + name: 'MNTN Audiences', + slug: 'actions-mntn-audiences', + mode: 'cloud', + + description: + 'Send Segment Engage audience membership data to MNTN. Syncs users into and out of MNTN audience segments using identity signals including email, phone, IP address, and Mobile Advertising ID (MAID).', + + authentication: { + scheme: 'custom', + fields: { + api_key: { + label: 'API Key', + description: + 'Your MNTN Audience API key, issued via the MNTN Integrations Marketplace. Treat this value as a secret — do not share it or commit it to source control.', + type: 'password', + required: true + } + }, + + testAuthentication: (request) => { + return testAuthentication(request) + } + }, + + extendRequest({ settings: { api_key } }) { + return { + headers: { + Authorization: `Bearer ${api_key}`, + 'Content-Type': 'application/json', + Accept: 'application/json' + } + } + }, + + audienceFields: { + segment_id: { + label: 'MNTN Segment ID', + description: + 'The ID of a pre-existing MNTN audience segment to sync to. If left blank, a new MNTN segment will be created automatically when this destination is enabled for an audience.', + type: 'string' + } + }, + + audienceConfig: { + mode: { + type: 'synced', + full_audience_sync: false + }, + + async createAudience(request, createAudienceInput) { + return createAudience(request, createAudienceInput) + }, + + async getAudience(request, getAudienceInput) { + return getAudience(request, getAudienceInput) + } + }, + + actions: { + syncAudience + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts new file mode 100644 index 00000000000..369be8c9b97 --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/functions.ts @@ -0,0 +1,178 @@ +import { + RequestClient, + MultiStatusResponse, + JSONLikeObject, + AudienceMembership, + HTTPError, + PayloadValidationError +} from '@segment/actions-core' +import { processHashing } from '../../../lib/hashing-utils' +import type { Payload } from './generated-types' +import type { IdentifierKind, IdentityPayload, PayloadWithIndex } from './types' +import { MNTN_API_BASE } from '../constants' +import { MNTN_API_VERSION } from '../versioning-info' + +function sha256(value: string): string { + return processHashing(value, 'sha256', 'hex') +} + +export function buildIdentity(payload: Payload): IdentityPayload { + const { email, phone, ip, maid, identity_id, timestamp } = payload + const identifiers: Array<{ kind: IdentifierKind; value: string }> = [] + + if (email) { + const normalized = email.toLowerCase().trim() + if (normalized) { + identifiers.push({ kind: 'email', value: normalized }) + identifiers.push({ kind: 'email_sha256', value: sha256(normalized) }) + } + } + + if (phone) { + const normalized = phone.replace(/\D/g, '') + if (normalized) { + identifiers.push({ kind: 'phone', value: normalized }) + identifiers.push({ kind: 'phone_sha256', value: sha256(normalized) }) + } + } + + if (ip) { + const normalized = ip.trim() + if (normalized) { + identifiers.push({ kind: 'ipv4', value: normalized }) + } + } + + if (maid) { + const normalized = maid.trim() + if (normalized) { + identifiers.push({ kind: 'maid', value: normalized }) + } + } + + return { + id: identity_id, + source: 'segment', + ...(timestamp ? { source_time: { rfc3339: timestamp } } : {}), + identifiers + } +} + +function markSuccess(msResponse: MultiStatusResponse | undefined, entries: PayloadWithIndex[]) { + entries.forEach(({ index, p, identity }) => { + msResponse?.setSuccessResponseAtIndex(index, { + status: 202, + sent: p as unknown as JSONLikeObject, + body: identity as unknown as JSONLikeObject + }) + }) +} + +function markError(msResponse: MultiStatusResponse | undefined, entries: PayloadWithIndex[], error: unknown) { + const status = (error as HTTPError)?.response?.status ?? 500 + const errormessage = error instanceof Error ? error.message : 'Request to MNTN failed.' + entries.forEach(({ index, p, identity }) => { + msResponse?.setErrorResponseAtIndex(index, { + status, + errormessage, + sent: p as unknown as JSONLikeObject, + body: identity as unknown as JSONLikeObject + }) + }) +} + +export async function syncAudience( + request: RequestClient, + payloads: Payload[], + audienceMemberships: AudienceMembership[], + isBatch: boolean +) { + const msResponse = isBatch ? new MultiStatusResponse() : undefined + const adds: PayloadWithIndex[] = [] + const removes: PayloadWithIndex[] = [] + const seen = new Set() + const segment_id = payloads[0].segment_id // batch_keys ensure this is static for a batch of events + + payloads.forEach((p, index) => { + const { identity_id } = p + + if (seen.has(identity_id)) { + msResponse?.setErrorResponseAtIndex(index, { + status: 400, + errormessage: `Duplicate identity_id "${identity_id}".`, + sent: p as unknown as JSONLikeObject, + body: {} + }) + return + } + seen.add(identity_id) + + const membership = audienceMemberships[index] + if (typeof membership !== 'boolean') { + const errormessage = `Missing audience membership for identity_id "${identity_id}". Expected a boolean indicating whether the user was added to or removed from the audience.` + if (!isBatch) { + throw new PayloadValidationError(errormessage) + } + msResponse?.setErrorResponseAtIndex(index, { + status: 400, + errormessage, + sent: p as unknown as JSONLikeObject, + body: {} + }) + return + } + + const identity = buildIdentity(p) + if (membership) { + adds.push({ index, p, identity }) + } else { + removes.push({ index, p, identity }) + } + }) + + const encodedSegmentId = encodeURIComponent(segment_id) + + if (adds.length > 0) { + const json = adds.length === 1 + ? { identity: adds[0].identity } + : { identities: adds.map(({ identity }) => identity) } + + try { + const response = await request(`${MNTN_API_BASE}/${MNTN_API_VERSION}/audience/segments/${encodedSegmentId}/identities`, { + method: 'POST', + json + }) + if (!isBatch) { + return response + } + markSuccess(msResponse, adds) + } catch (error) { + if (!isBatch) { + throw error + } + markError(msResponse, adds, error) + } + } + + if (removes.length > 0) { + const encodedIds = encodeURIComponent(removes.map(({ identity: { id } }) => id).join(',')) + + try { + const response = await request( + `${MNTN_API_BASE}/${MNTN_API_VERSION}/audience/segments/${encodedSegmentId}/identities/${encodedIds}`, + { method: 'DELETE' } + ) + if (!isBatch) { + return response + } + markSuccess(msResponse, removes) + } catch (error) { + if (!isBatch) { + throw error + } + markError(msResponse, removes, error) + } + } + + return msResponse +} diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/generated-types.ts new file mode 100644 index 00000000000..247787c48f6 --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/generated-types.ts @@ -0,0 +1,32 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The ID of the MNTN audience segment to sync to. Customers should not need to edit this field. + */ + segment_id: string + /** + * A stable identifier for this user in MNTN. Defaults to userId, falling back to anonymousId. + */ + identity_id: string + /** + * The user's email address. Sent to MNTN in plaintext and as a SHA-256 hash for audience matching. + */ + email?: string + /** + * The user's phone number. Non-numeric characters (including the + prefix) are removed. Sent to MNTN in normalized plaintext and as a SHA-256 hash for audience matching. + */ + phone?: string + /** + * The user's IPv4 address. Used for probabilistic audience matching in MNTN campaigns. + */ + ip?: string + /** + * The user's Mobile Advertising ID — IDFA on iOS or GAID on Android. + */ + maid?: string + /** + * ISO 8601 timestamp of when this audience membership event occurred. Sent to MNTN as source_time. + */ + timestamp?: string +} diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts new file mode 100644 index 00000000000..2b67c71bb7d --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/index.ts @@ -0,0 +1,100 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { syncAudience } from './functions' + +const action: ActionDefinition = { + title: 'Sync Audience', + description: 'Sync a Segment Engage audience to an MNTN audience segment.', + defaultSubscription: 'type = "identify" or type = "track"', + fields: { + segment_id: { + label: 'MNTN Segment ID', + description: 'The ID of the MNTN audience segment to sync to. Customers should not need to edit this field.', + type: 'string', + required: true, + default: { + '@path': '$.context.personas.external_audience_id' + } + }, + identity_id: { + label: 'Identity ID', + description: 'A stable identifier for this user in MNTN. Defaults to userId, falling back to anonymousId.', + type: 'string', + required: true, + default: { + '@if': { + exists: { '@path': '$.userId' }, + then: { '@path': '$.userId' }, + else: { '@path': '$.anonymousId' } + } + } + }, + email: { + label: 'Email Address', + description: "The user's email address. Sent to MNTN in plaintext and as a SHA-256 hash for audience matching.", + type: 'string', + format: 'email', + default: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.context.traits.email' } + } + } + }, + phone: { + label: 'Phone Number', + description: + "The user's phone number. Non-numeric characters (including the + prefix) are removed. Sent to MNTN in normalized plaintext and as a SHA-256 hash for audience matching.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.phone' }, + then: { '@path': '$.traits.phone' }, + else: { '@path': '$.properties.phone' } + } + } + }, + ip: { + label: 'IP Address', + description: "The user's IPv4 address. Used for probabilistic audience matching in MNTN campaigns.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.ip' }, + then: { '@path': '$.traits.ip' }, + else: { '@path': '$.properties.ip' } + } + } + }, + maid: { + label: 'Mobile Advertising ID (MAID)', + description: "The user's Mobile Advertising ID — IDFA on iOS or GAID on Android.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.advertisingId' }, + then: { '@path': '$.traits.advertisingId' }, + else: { '@path': '$.properties.advertisingId' } + } + } + }, + timestamp: { + label: 'Event Timestamp', + description: 'ISO 8601 timestamp of when this audience membership event occurred. Sent to MNTN as source_time.', + type: 'string', + default: { + '@path': '$.timestamp' + } + } + }, + perform: (request, { payload, audienceMembership }) => { + return syncAudience(request, [payload], [audienceMembership], false) + }, + performBatch: (request, { payload, audienceMembership }) => { + return syncAudience(request, payload, Array.isArray(audienceMembership) ? audienceMembership : [], true) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/types.ts b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/types.ts new file mode 100644 index 00000000000..1e541d0e848 --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/syncAudience/types.ts @@ -0,0 +1,12 @@ +import type { Payload } from './generated-types' + +export type IdentifierKind = 'email' | 'email_sha256' | 'phone' | 'phone_sha256' | 'ipv4' | 'maid' + +export interface IdentityPayload { + id: string + source: string + source_time?: { rfc3339: string } + identifiers: Array<{ kind: IdentifierKind; value: string }> +} + +export type PayloadWithIndex = { index: number; p: Payload; identity: IdentityPayload } diff --git a/packages/destination-actions/src/destinations/mntn-audiences/types.ts b/packages/destination-actions/src/destinations/mntn-audiences/types.ts new file mode 100644 index 00000000000..f9c3179a06f --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/types.ts @@ -0,0 +1,20 @@ +import type { Settings, AudienceSettings } from './generated-types' + +export interface CreateAudienceInput { + audienceName?: string + settings: Settings + audienceSettings?: AudienceSettings +} + +export interface GetAudienceInput { + externalId: string + settings: Settings + audienceSettings?: AudienceSettings +} + +export interface SegmentResponse { + segment?: { + id?: string + name?: string + } +} diff --git a/packages/destination-actions/src/destinations/mntn-audiences/versioning-info.ts b/packages/destination-actions/src/destinations/mntn-audiences/versioning-info.ts new file mode 100644 index 00000000000..162ac85429c --- /dev/null +++ b/packages/destination-actions/src/destinations/mntn-audiences/versioning-info.ts @@ -0,0 +1 @@ +export const MNTN_API_VERSION = 'v2026'