diff --git a/.husky/gitleaks-rules.toml b/.husky/gitleaks-rules.toml index c9419a61ab..b2a92791aa 100644 --- a/.husky/gitleaks-rules.toml +++ b/.husky/gitleaks-rules.toml @@ -12,6 +12,7 @@ paths = [ regexes = ['''219-09-9999''', '''078-05-1120''', '''(9[0-9]{2}|666)-\d{2}-\d{4}'''] [[rules]] +id = "facebook_access_token" description = "Facebook System User Access Token" regex = '''EAA[0-9A-Za-z]{100,}''' tags = ["token", "Facebook Token"] diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/__tests__/onDelete.test.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/__tests__/onDelete.test.ts new file mode 100644 index 0000000000..2d45459d99 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/__tests__/onDelete.test.ts @@ -0,0 +1,182 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../../index' +import { SegmentEvent } from '@segment/actions-core/*' + +const testDestination = createTestIntegration(Definition) +const mockGqlKey = 'test-graphql-key' + +const gqlHostUrl = 'https://api.stackadapt.com' +const gqlPath = '/graphql' +const mockUserId = 'user-id' +const mockAdvertiserId = '23' +const mockMappings = { advertiser_id: mockAdvertiserId } + +const deleteEventPayload: Partial = { + userId: mockUserId, + type: 'identify', + context: { + personas: { + computation_class: 'audience', + computation_key: 'first_time_buyer', + computation_id: 'aud_123' + } + } +} + +// Helper function to mock the token query response +const mockTokenQueryResponse = (nodes: Array<{ advertiser: { id: string } }>) => { + nock(gqlHostUrl) + .post(gqlPath) + .reply(200, { + data: { + tokenInfo: { + scopesByAdvertiser: { + nodes: nodes + } + } + } + }) +} + +// Helper function to mock the profile deletion mutation +const mockDeleteProfilesMutation = ( + deleteRequestBodyRef: { body?: any }, + userErrors: Array<{ message: string }> = [] +) => { + nock(gqlHostUrl) + .post(gqlPath, (body) => { + deleteRequestBodyRef.body = body + return body + }) + .reply(200, { + data: { + deleteProfilesWithExternalIds: { + userErrors: userErrors + } + } + }) +} +// helper for expected delete profiles mutation +const expectDeleteProfilesMutation = ( + deleteRequestBody: { body?: any }, + expectedExternalIds: string[], + expectedAdvertiserIds: string[] +) => { + expect(deleteRequestBody.body).toMatchInlineSnapshot(` + Object { + "query": "mutation { + deleteProfilesWithExternalIds( + externalIds: [\\"${expectedExternalIds.join('\\", \\"')}\\"], + advertiserIDs: [\\"${expectedAdvertiserIds.join('\\", \\"')}\\"], + externalProvider: \\"segmentio\\" + ) { + userErrors { + message + path + } + } + }", + } + `) +} + +describe('onDelete action', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('should delete a profile successfully', async () => { + const deleteRequestBody: { body?: any } = {} + + mockTokenQueryResponse([{ advertiser: { id: mockAdvertiserId } }]) + mockDeleteProfilesMutation(deleteRequestBody) + + const event = createTestEvent({ + userId: mockUserId, + type: 'identify', + context: { + personas: { + computation_class: 'audience', + computation_key: 'first_time_buyer', + computation_id: 'aud_123' + } + } + }) + + const responses = await testDestination.testAction('onDelete', { + event, + useDefaultMappings: true, + mapping: { userId: mockUserId }, + settings: { apiKey: mockGqlKey } + }) + + expect(responses.length).toBe(2) + expectDeleteProfilesMutation(deleteRequestBody, ['user-id'], ['23']) + }) + + it('should throw error if no advertiser ID is found', async () => { + mockTokenQueryResponse([]) // Pass an empty array to mock no advertiser IDs + + const event = createTestEvent(deleteEventPayload) + + await expect( + testDestination.testAction('onDelete', { + event, + useDefaultMappings: true, + mapping: mockMappings, + settings: { apiKey: mockGqlKey } + }) + ).rejects.toThrow('No advertiser ID found.') + }) + + it('should throw error if profile deletion fails', async () => { + const deleteRequestBody: { body?: any } = {} + + mockTokenQueryResponse([{ advertiser: { id: mockAdvertiserId } }]) + mockDeleteProfilesMutation(deleteRequestBody, [{ message: 'Deletion failed' }]) + + const event = createTestEvent(deleteEventPayload) + + await expect( + testDestination.testAction('onDelete', { + event, + useDefaultMappings: true, + mapping: mockMappings, + settings: { apiKey: mockGqlKey } + }) + ).rejects.toThrow('Profile deletion was not successful: Deletion failed') + }) + + it('should perform onDelete with a userID and two advertiserIDs from a single token request', async () => { + const deleteRequestBody: { body?: any } = {} + + const event: Partial = { + userId: 'user-id-1', + type: 'identify', + traits: { + first_time_buyer: true + }, + context: { + personas: { + computation_class: 'audience', + computation_key: 'first_time_buyer', + computation_id: 'aud_123' + } + } + } + + mockTokenQueryResponse([{ advertiser: { id: 'advertiser-id-1' } }, { advertiser: { id: 'advertiser-id-2' } }]) + mockDeleteProfilesMutation(deleteRequestBody) + + const responses = await testDestination.testAction('onDelete', { + event, + useDefaultMappings: true, + mapping: { userId: 'user-id-1' }, + settings: { apiKey: mockGqlKey } + }) + + expect(responses[0].status).toBe(200) + expectDeleteProfilesMutation(deleteRequestBody, ['user-id-1'], ['advertiser-id-1', 'advertiser-id-2']) + }) +}) diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/functions.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/functions.ts new file mode 100644 index 0000000000..f02610c54c --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/functions.ts @@ -0,0 +1,73 @@ +import { GQL_ENDPOINT, EXTERNAL_PROVIDER } from '../functions' + +export async function onDelete(request: any, payload: any[]) { + return (async () => { + const userId = payload[0].userId ?? payload[0].anonymousId + const TokenQuery = `query TokenInfo { + tokenInfo { + scopesByAdvertiser { + nodes { + advertiser { + id + } + } + totalCount + } + } + }` + + const res_token = await request(GQL_ENDPOINT, { + body: JSON.stringify({ query: TokenQuery }), + throwHttpErrors: false + }) + + if (res_token.status !== 200) { + throw new Error('Failed to fetch advertiser information: ' + res_token.statusText) + } + + const result_token = await res_token.json() + const advertiserNode = result_token.data?.tokenInfo?.scopesByAdvertiser?.nodes + + if (!advertiserNode || advertiserNode.length === 0) { + throw new Error('No advertiser ID found.') + } + + const advertiserIds = advertiserNode.map((node: { advertiser: { id: string } }) => node.advertiser.id) + + const formattedExternalIds = `["${userId}"]` + const formattedAdvertiserIds = `[${advertiserIds.map((id: string) => `"${id}"`).join(', ')}]` + + const query = `mutation { + deleteProfilesWithExternalIds( + externalIds: ${formattedExternalIds}, + advertiserIDs: ${formattedAdvertiserIds}, + externalProvider: "${EXTERNAL_PROVIDER}" + ) { + userErrors { + message + path + } + } + }` + + const res = await request(GQL_ENDPOINT, { + body: JSON.stringify({ query }), + throwHttpErrors: false + }) + + if (res.status !== 200) { + throw new Error('Failed to delete profile: ' + res.statusText) + } + + const result = await res.json() + + if (result.data.deleteProfilesWithExternalIds.userErrors.length > 0) { + throw new Error( + 'Profile deletion was not successful: ' + + result.data.deleteProfilesWithExternalIds.userErrors.map((e: any) => e.message).join(', ') + ) + } + + return result + })() +} diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/generated-types.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/generated-types.ts new file mode 100644 index 0000000000..e770d449ca --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/generated-types.ts @@ -0,0 +1,20 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user ID to delete. + */ + userId?: string + /** + * The anonymous ID to delete. + */ + anonymousId?: string + /** + * Comma-separated list of advertiser IDs. If not provided, it will query the token info. + */ + advertiserId?: string + /** + * The external provider to delete the profile from. + */ + externalProvider?: string +} diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/index.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/index.ts new file mode 100644 index 0000000000..f264857669 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/deleteProfile/index.ts @@ -0,0 +1,45 @@ +import { ActionDefinition } from '@segment/actions-core' +import { Settings } from '../generated-types' +import { onDelete } from './functions' +import { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Delete Profiles', + description: 'Deletes a profile by userId or anonymousId and advertiser IDs.', + fields: { + userId: { + label: 'User ID', + description: 'The user ID to delete.', + type: 'string', + required: false + }, + anonymousId: { + label: 'Anonymous ID', + description: 'The anonymous ID to delete.', + type: 'string', + required: false + }, + advertiserId: { + label: 'Advertiser IDs', + description: 'Comma-separated list of advertiser IDs. If not provided, it will query the token info.', + type: 'string', + required: false + }, + externalProvider: { + label: 'External Provider', + description: 'The external provider to delete the profile from.', + type: 'string', + required: false + } + }, + perform: async (request, { payload }) => { + // For single profile deletion + return await onDelete(request, [payload]) + }, + performBatch: async (request, { payload }) => { + // For batch profile deletion + return await onDelete(request, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/stackadapt-audiences/index.ts b/packages/destination-actions/src/destinations/stackadapt-audiences/index.ts index 97abdf7278..a5b82f0e88 100644 --- a/packages/destination-actions/src/destinations/stackadapt-audiences/index.ts +++ b/packages/destination-actions/src/destinations/stackadapt-audiences/index.ts @@ -3,6 +3,7 @@ import type { Settings } from './generated-types' import forwardProfile from './forwardProfile' import forwardAudienceEvent from './forwardAudienceEvent' +import onDelete from './deleteProfile' import { AdvertiserScopesResponse } from './types' import { GQL_ENDPOINT } from './functions' @@ -61,7 +62,8 @@ const destination: DestinationDefinition = { }, actions: { forwardProfile, - forwardAudienceEvent + forwardAudienceEvent, + onDelete } }