diff --git a/packages/destination-actions/src/destinations/memora/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/memora/__tests__/snapshot.test.ts index 3cfbd6e4ee..c63a88f72c 100644 --- a/packages/destination-actions/src/destinations/memora/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/memora/__tests__/snapshot.test.ts @@ -36,7 +36,7 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { ...event.properties, memora_store: 'test-store-id', profile_identifiers: { - email: 'test@example.com' + 'Contact.$.email': 'test@example.com' }, profile_traits: { 'Contact.$.firstName': 'Test' @@ -81,7 +81,7 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { ...event.properties, memora_store: 'test-store-id', profile_identifiers: { - email: 'test@example.com' + 'Contact.$.email': 'test@example.com' }, profile_traits: { 'Contact.$.firstName': 'Test', diff --git a/packages/destination-actions/src/destinations/memora/upsertProfile/__tests__/index.test.ts b/packages/destination-actions/src/destinations/memora/upsertProfile/__tests__/index.test.ts index ad75302830..1f85a31bba 100644 --- a/packages/destination-actions/src/destinations/memora/upsertProfile/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/memora/upsertProfile/__tests__/index.test.ts @@ -1,11 +1,10 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import type { RequestClient, ExecuteInput, Logger } from '@segment/actions-core' +import type { RequestClient, Logger } from '@segment/actions-core' import Destination from '../../index' import { API_VERSION } from '../../versioning-info' import { BASE_URL } from '../../constants' import type { Payload } from '../generated-types' -import type { Settings } from '../../generated-types' const testDestination = createTestIntegration(Destination) @@ -18,8 +17,8 @@ const defaultSettings = { const defaultMapping = { memora_store: 'test-store-id', profile_identifiers: { - email: { '@path': '$.traits.email' }, - phone: { '@path': '$.traits.phone' } + 'Contact.$.email': { '@path': '$.traits.email' }, + 'Contact.$.phone': { '@path': '$.traits.phone' } }, profile_traits: { 'Contact.$.firstName': { '@path': '$.traits.first_name' }, @@ -141,7 +140,7 @@ describe('Memora.upsertProfile', () => { profile_traits: { 'Contact.$.firstName': 'Test' } } - const executeInput: ExecuteInput = { + const executeInput = { payload, settings: defaultSettings } @@ -153,26 +152,53 @@ describe('Memora.upsertProfile', () => { expect(mockRequest).not.toHaveBeenCalled() }) - it('should throw error when profile has no traits', async () => { + it('should throw error when profile has only one identifier and no traits', async () => { const mockRequest = jest.fn() as unknown as RequestClient const action = Destination.actions.upsertProfile const payload: Payload = { memora_store: 'test-store-id', - profile_identifiers: { email: 'test@example.com' }, + profile_identifiers: { 'Contact.$.email': 'test@example.com' }, profile_traits: {} } - const executeInput: ExecuteInput = { + const executeInput = { payload, settings: defaultSettings } - await expect(action.perform(mockRequest, executeInput)).rejects.toThrow('at least one trait') + await expect(action.perform(mockRequest, executeInput)).rejects.toThrow('at least two total fields') expect(mockRequest).not.toHaveBeenCalled() }) + it('should succeed with two identifiers and no traits', async () => { + const mockRequest = jest.fn().mockResolvedValue({ + status: 202, + data: { success: true }, + headers: { 'content-type': 'application/json' }, + content: '{"success":true}' + }) as unknown as RequestClient + const action = Destination.actions.upsertProfile + + const payload: Payload = { + memora_store: 'test-store-id', + profile_identifiers: { + 'Contact.$.email': 'test@example.com', + 'Contact.$.phone': '+1-555-0100' + } + } + + const executeInput = { + payload, + settings: defaultSettings + } + + const result = await action.perform(mockRequest, executeInput) + expect(result).toHaveProperty('status', 202) + expect(mockRequest).toHaveBeenCalledTimes(1) + }) + it('should succeed with only email provided', async () => { const event = createTestEvent({ type: 'identify', @@ -191,7 +217,7 @@ describe('Memora.upsertProfile', () => { mapping: { memora_store: 'test-store-id', profile_identifiers: { - email: { '@path': '$.properties.email' } + 'Contact.$.email': { '@path': '$.properties.email' } }, profile_traits: { 'Contact.$.firstName': { '@path': '$.properties.first_name' } @@ -222,7 +248,7 @@ describe('Memora.upsertProfile', () => { mapping: { memora_store: 'test-store-id', profile_identifiers: { - phone: { '@path': '$.properties.phone' } + 'Contact.$.phone': { '@path': '$.properties.phone' } }, profile_traits: { 'Contact.$.firstName': { '@path': '$.properties.first_name' } @@ -254,8 +280,8 @@ describe('Memora.upsertProfile', () => { mapping: { memora_store: 'test-store-id', profile_identifiers: { - email: { '@path': '$.properties.email' }, - phone: { '@path': '$.properties.phone' } + 'Contact.$.email': { '@path': '$.properties.email' }, + 'Contact.$.phone': { '@path': '$.properties.phone' } }, profile_traits: { 'Contact.$.firstName': { '@path': '$.properties.first_name' } @@ -381,7 +407,7 @@ describe('Memora.upsertProfile', () => { mapping: { memora_store: 'test-store-id', profile_identifiers: { - email: { '@path': '$.properties.email' } + 'Contact.$.email': { '@path': '$.properties.email' } }, profile_traits: { 'Contact.$.first,name': { '@path': '$.properties.special_field' }, @@ -424,8 +450,8 @@ describe('Memora.upsertProfile', () => { mapping: { memora_store: 'test-store-id', profile_identifiers: { - email: { '@path': '$.traits.email' }, - phone: { '@path': '$.traits.phone' } + 'Contact.$.email': { '@path': '$.traits.email' }, + 'Contact.$.phone': { '@path': '$.traits.phone' } }, profile_traits: { 'Contact.$.firstName': { '@path': '$.traits.first_name' }, @@ -471,7 +497,7 @@ describe('Memora.upsertProfile', () => { mapping: { memora_store: 'test-store-id', profile_identifiers: { - email: { '@path': '$.traits.email' } + 'Contact.$.email': { '@path': '$.traits.email' } }, profile_traits: { 'Contact.$.firstName': { '@path': '$.traits.first_name' }, @@ -520,7 +546,7 @@ describe('Memora.upsertProfile', () => { mapping: { memora_store: 'test-store-id', profile_identifiers: { - email: { '@path': '$.traits.email' } + 'Contact.$.email': { '@path': '$.traits.email' } }, profile_traits: { 'Contact.$.firstName': { '@path': '$.traits.first_name' }, @@ -568,8 +594,8 @@ describe('Memora.upsertProfile', () => { mapping: { memora_store: 'test-store-id', profile_identifiers: { - email: { '@path': '$.traits.email' }, - phone: { '@path': '$.traits.phone' } + 'Contact.$.email': { '@path': '$.traits.email' }, + 'Contact.$.phone': { '@path': '$.traits.phone' } }, profile_traits: { // Only non-Contact traits - no Contact.$.* fields @@ -597,14 +623,14 @@ describe('Memora.upsertProfile', () => { const payload: Payload = { memora_store: 'test-store-id', - profile_identifiers: { email: 'invalid@example.com' }, + profile_identifiers: { 'Contact.$.email': 'invalid@example.com' }, profile_traits: { 'Contact.firstName': 'InvalidFormat1', // Missing ".$." ContactlastName: 'InvalidFormat2' // Missing separators } } - const executeInput: ExecuteInput = { + const executeInput = { payload, settings: defaultSettings } @@ -619,6 +645,33 @@ describe('Memora.upsertProfile', () => { expect(mockRequest).not.toHaveBeenCalled() }) + it('should throw error for invalid identifier key formats in single profile', async () => { + const mockRequest = jest.fn() as unknown as RequestClient + const action = Destination.actions.upsertProfile + + const payload: Payload = { + memora_store: 'test-store-id', + profile_identifiers: { + email: 'test@example.com', // Missing "TraitGroupName.$." + 'Contact.phone': '+1-555-0100' // Missing ".$." + }, + profile_traits: { 'Contact.$.firstName': 'Test' } + } + + const executeInput = { + payload, + settings: defaultSettings + } + + if (!action.perform) { + throw new Error('perform is not defined') + } + + await expect(action.perform(mockRequest, executeInput)).rejects.toThrow('Invalid identifier key format detected') + + expect(mockRequest).not.toHaveBeenCalled() + }) + it('should return raw ModifiedResponse when perform succeeds', async () => { const mockRequest = jest.fn().mockResolvedValue({ status: 202, @@ -630,11 +683,11 @@ describe('Memora.upsertProfile', () => { const payload: Payload = { memora_store: 'test-store-id', - profile_identifiers: { email: 'success@example.com' }, + profile_identifiers: { 'Contact.$.email': 'success@example.com' }, profile_traits: { 'Contact.$.firstName': 'John' } } - const executeInput: ExecuteInput = { + const executeInput = { payload, settings: defaultSettings } @@ -716,7 +769,7 @@ describe('Memora.upsertProfile', () => { throw new Error('performBatch is not defined') } - const executeInput: ExecuteInput = { + const executeInput = { payload: [], settings: defaultSettings } @@ -757,7 +810,7 @@ describe('Memora.upsertProfile', () => { mapping: { memora_store: 'test-store-id', profile_identifiers: { - email: { '@path': '$.properties.email' } + 'Contact.$.email': { '@path': '$.properties.email' } }, profile_traits: { 'Contact.$.firstName': { '@path': '$.properties.first_name' } @@ -791,17 +844,16 @@ describe('Memora.upsertProfile', () => { }, { memora_store: 'test-store-id', - profile_identifiers: { email: 'valid@example.com' }, + profile_identifiers: { 'Contact.$.email': 'valid@example.com' }, profile_traits: { 'Contact.$.firstName': 'Valid' } }, { memora_store: 'test-store-id', - profile_identifiers: { email: 'another@example.com' }, - profile_traits: {} + profile_identifiers: { 'Contact.$.email': 'another@example.com' } } ] - const executeInput: ExecuteInput = { + const executeInput = { payload: payloads, settings: defaultSettings } @@ -826,7 +878,7 @@ describe('Memora.upsertProfile', () => { expect(success1.status).toBe(202) expect(success1.body).toBe('accepted') - // Index 2: invalid (no traits) + // Index 2: invalid (only 1 identifier, no traits - needs at least 2 total fields) expect(result.isErrorResponseAtIndex(2)).toBe(true) const error2 = result.getResponseAtIndex(2).value() expect(error2.status).toBe(400) @@ -861,22 +913,21 @@ describe('Memora.upsertProfile', () => { profile_traits: { 'Contact.$.firstName': undefined } }, { - // Invalid: no traits + // Invalid: only 1 identifier, no traits (needs at least 2 total fields) memora_store: 'test-store-id', - profile_identifiers: { email: 'test@example.com' }, - profile_traits: {} + profile_identifiers: { 'Contact.$.email': 'test@example.com' } }, { // Invalid: bad trait key format memora_store: 'test-store-id', - profile_identifiers: { email: 'another@example.com' }, + profile_identifiers: { 'Contact.$.email': 'another@example.com' }, profile_traits: { 'Contact.firstName': 'InvalidFormat' // Missing ".$." } } ] - const executeInput: ExecuteInput = { + const executeInput = { payload: payloads, settings: defaultSettings, logger: mockLogger @@ -901,7 +952,7 @@ describe('Memora.upsertProfile', () => { const error1 = result.getResponseAtIndex(1).value() expect(error1.status).toBe(400) - expect(error1.errormessage).toContain('at least one trait') + expect(error1.errormessage).toContain('at least two total fields') const error2 = result.getResponseAtIndex(2).value() expect(error2.status).toBe(400) @@ -924,12 +975,11 @@ describe('Memora.upsertProfile', () => { const payloads: Payload[] = [ { memora_store: 'test-store-id', - profile_identifiers: {}, - profile_traits: {} + profile_identifiers: {} } ] - const executeInput: ExecuteInput = { + const executeInput = { payload: payloads, settings: defaultSettings } @@ -995,8 +1045,8 @@ describe('Memora.upsertProfile', () => { mapping: { memora_store: 'test-store-id', profile_identifiers: { - email: { '@path': '$.properties.email' }, - phone: { '@path': '$.properties.phone' } + 'Contact.$.email': { '@path': '$.properties.email' }, + 'Contact.$.phone': { '@path': '$.properties.phone' } }, profile_traits: { 'Contact.$.firstName': { '@path': '$.properties.first_name' } @@ -1063,13 +1113,13 @@ describe('Memora.upsertProfile', () => { { // Valid profile memora_store: 'test-store-id', - profile_identifiers: { email: 'valid@example.com' }, + profile_identifiers: { 'Contact.$.email': 'valid@example.com' }, profile_traits: { 'Contact.$.firstName': 'Valid' } }, { // Invalid profile - bad trait key format memora_store: 'test-store-id', - profile_identifiers: { email: 'invalid@example.com' }, + profile_identifiers: { 'Contact.$.email': 'invalid@example.com' }, profile_traits: { 'Contact.firstName': 'Missing$', // Invalid: missing ".$." badKey: 'value' // Invalid: wrong format @@ -1078,12 +1128,12 @@ describe('Memora.upsertProfile', () => { { // Valid profile memora_store: 'test-store-id', - profile_identifiers: { phone: '+1-555-1234' }, + profile_identifiers: { 'Contact.$.phone': '+1-555-1234' }, profile_traits: { 'PurchaseHistory.$.lastPurchase': '2024-01-01' } } ] - const executeInput: ExecuteInput = { + const executeInput = { payload: payloads, settings: defaultSettings } @@ -1281,6 +1331,106 @@ describe('Memora.upsertProfile', () => { }) }) + describe('profile_identifiers (dynamic identifiers from all trait groups)', () => { + it('should fetch and return identifier traits from all trait groups', async () => { + nock(BASE_URL) + .get(`/${API_VERSION}/ControlPlane/Stores/test-store-id/TraitGroups?pageSize=100&includeTraits=true`) + .matchHeader('X-Pre-Auth-Context', 'AC1234567890') + .reply(200, { + traitGroups: [ + { + displayName: 'Contact', + description: '', + traits: { + email: { + dataType: 'STRING', + description: '', + displayName: 'email', + idTypePromotion: 'email', + validationRule: null + }, + phone: { + dataType: 'STRING', + description: '', + displayName: 'phone', + idTypePromotion: 'phone', + validationRule: null + }, + firstName: { + dataType: 'STRING', + description: '', + displayName: 'firstName', + idTypePromotion: null, + validationRule: null + } + }, + version: 1 + }, + { + displayName: 'Loyalty', + description: 'Loyalty traits', + traits: { + memberId: { + dataType: 'STRING', + description: 'Loyalty member ID', + displayName: 'Member ID', + idTypePromotion: 'loyalty_id', + validationRule: null + }, + tier: { + dataType: 'STRING', + description: 'Loyalty tier', + displayName: 'Tier', + idTypePromotion: null, + validationRule: null + } + }, + version: 1 + } + ] + }) + + const result = (await testDestination.testDynamicField('upsertProfile', 'profile_identifiers.__keys__', { + settings: defaultSettings, + payload: { memora_store: 'test-store-id' } + })) as any + + // Should return only traits with idTypePromotion set + expect(result?.choices).toEqual([ + { label: 'Contact.email', value: 'Contact.$.email', description: 'Contact - email (email)' }, + { label: 'Contact.phone', value: 'Contact.$.phone', description: 'Contact - phone (phone)' }, + { label: 'Loyalty.Member ID', value: 'Loyalty.$.memberId', description: 'Loyalty member ID' } + ]) + }) + + it('should return error when memora_store is not selected', async () => { + const result = (await testDestination.testDynamicField('upsertProfile', 'profile_identifiers.__keys__', { + settings: defaultSettings, + payload: {} + })) as any + + expect(result?.choices).toEqual([]) + expect(result?.error?.message).toBe('Please select a Memora Store first') + expect(result?.error?.code).toBe('STORE_REQUIRED') + }) + + it('should return error message when API call fails', async () => { + nock(BASE_URL) + .get(`/${API_VERSION}/ControlPlane/Stores/test-store-id/TraitGroups?pageSize=100&includeTraits=true`) + .reply(500, { message: 'Internal server error' }) + + const result = (await testDestination.testDynamicField('upsertProfile', 'profile_identifiers.__keys__', { + settings: defaultSettings, + payload: { memora_store: 'test-store-id' } + })) as any + + expect(result?.choices).toEqual([]) + expect(result?.error).toBeDefined() + expect(result?.error?.message).toContain('Unable to fetch identifiers') + expect(result?.error?.code).toBe('FETCH_ERROR') + }) + }) + describe('profile_traits (dynamic traits from all trait groups)', () => { it('should fetch and return traits from all trait groups', async () => { // Mock listing trait groups (includes traits in response) @@ -1367,7 +1517,7 @@ describe('Memora.upsertProfile', () => { payload: { memora_store: 'test-store-id' } })) as any - // Should exclude email and phone (identifiers) and non-STRING traits + // Should exclude identifiers (traits with idTypePromotion) and non-STRING traits // All trait groups use traitGroupName.$.traitName format expect(result?.choices).toEqual([ { label: 'Contact.firstName', value: 'Contact.$.firstName', description: 'Contact - firstName (STRING)' }, diff --git a/packages/destination-actions/src/destinations/memora/upsertProfile/generated-types.ts b/packages/destination-actions/src/destinations/memora/upsertProfile/generated-types.ts index 858861f208..76a1d9914d 100644 --- a/packages/destination-actions/src/destinations/memora/upsertProfile/generated-types.ts +++ b/packages/destination-actions/src/destinations/memora/upsertProfile/generated-types.ts @@ -14,22 +14,15 @@ export interface Payload { */ memora_store: string /** - * Profile identifiers (email and/or phone). At least one identifier is required. These identifiers are stored in the Contact trait group. + * Profile identifiers from all trait groups. At least one identifier is required, and at least two total fields (identifiers + traits) must be mapped. These fields are dynamically loaded from the selected Memora Store. When manually entering keys, use the format "TraitGroupName.$.traitName" (e.g., "Contact.$.email", "Contact.$.phone"). */ profile_identifiers: { - /** - * User email address - */ - email?: string - /** - * User phone number - */ - phone?: string + [k: string]: unknown } /** - * Traits for the profile from all trait groups. At least one trait is required. These fields are dynamically loaded from the selected Memora Store. When manually entering keys, use the format "TraitGroupName.$.traitName" (e.g., "Contact.$.firstName", "PurchaseHistory.$.lastPurchaseDate"). + * Traits for the profile from all trait groups. These fields are dynamically loaded from the selected Memora Store. When manually entering keys, use the format "TraitGroupName.$.traitName" (e.g., "Contact.$.firstName", "PurchaseHistory.$.lastPurchaseDate"). */ - profile_traits: { + profile_traits?: { [k: string]: unknown } } diff --git a/packages/destination-actions/src/destinations/memora/upsertProfile/index.ts b/packages/destination-actions/src/destinations/memora/upsertProfile/index.ts index a615d66f5c..e97fb85a82 100644 --- a/packages/destination-actions/src/destinations/memora/upsertProfile/index.ts +++ b/packages/destination-actions/src/destinations/memora/upsertProfile/index.ts @@ -38,34 +38,19 @@ const action: ActionDefinition = { profile_identifiers: { label: 'Profile Identifiers', description: - 'Profile identifiers (email and/or phone). At least one identifier is required. These identifiers are stored in the Contact trait group.', + 'Profile identifiers from all trait groups. At least one identifier is required, and at least two total fields (identifiers + traits) must be mapped. These fields are dynamically loaded from the selected Memora Store. When manually entering keys, use the format "TraitGroupName.$.traitName" (e.g., "Contact.$.email", "Contact.$.phone").', type: 'object', required: true, - additionalProperties: false, - properties: { - email: { - label: 'Email', - description: 'User email address', - type: 'string', - format: 'email' - }, - phone: { - label: 'Phone', - description: 'User phone number', - type: 'string' - } - }, - default: { - email: { '@path': '$.traits.email' }, - phone: { '@path': '$.traits.phone' } - } + additionalProperties: true, + dynamic: true, + defaultObjectUI: 'keyvalue' }, profile_traits: { label: 'Profile Traits', description: - 'Traits for the profile from all trait groups. At least one trait is required. These fields are dynamically loaded from the selected Memora Store. When manually entering keys, use the format "TraitGroupName.$.traitName" (e.g., "Contact.$.firstName", "PurchaseHistory.$.lastPurchaseDate").', + 'Traits for the profile from all trait groups. These fields are dynamically loaded from the selected Memora Store. When manually entering keys, use the format "TraitGroupName.$.traitName" (e.g., "Contact.$.firstName", "PurchaseHistory.$.lastPurchaseDate").', type: 'object', - required: true, + required: false, additionalProperties: true, dynamic: true, defaultObjectUI: 'keyvalue' @@ -75,12 +60,22 @@ const action: ActionDefinition = { memora_store: async (request, { settings }) => { return fetchMemoraStores(request, settings) }, + profile_identifiers: { + __keys__: async (request, { settings, payload }) => { + if (!payload.memora_store) { + return { choices: [], error: { message: 'Please select a Memora Store first', code: 'STORE_REQUIRED' } } + } + const result = await fetchTraitGroupFields(request, settings, payload.memora_store) + return result.identifiers + } + }, profile_traits: { __keys__: async (request, { settings, payload }) => { if (!payload.memora_store) { return { choices: [], error: { message: 'Please select a Memora Store first', code: 'STORE_REQUIRED' } } } - return fetchAllTraits(request, settings, payload.memora_store) + const result = await fetchTraitGroupFields(request, settings, payload.memora_store) + return result.traits } } }, @@ -128,20 +123,28 @@ async function upsertProfiles( const validationErrors: Map = new Map() payloads.forEach((payload, index) => { - // Validate that profile has at least one identifier AND at least one trait + // Validate: at least one identifier is required and at least two total fields (identifiers + traits) must be mapped const identifiers = payload.profile_identifiers || {} - const hasIdentifier = !!(identifiers.email || identifiers.phone) + const identifierCount = Object.values(identifiers).filter((v) => v !== undefined && v !== null).length + const hasIdentifier = identifierCount > 0 const traits = ( payload.profile_traits && typeof payload.profile_traits === 'object' ? payload.profile_traits : {} ) as Record - const hasTraits = Object.keys(traits).some((key) => traits[key] !== undefined && traits[key] !== null) + const traitCount = Object.values(traits).filter((v) => v !== undefined && v !== null).length + const totalFields = identifierCount + traitCount + + if (!hasIdentifier) { + invalidIndices.push(index) + validationErrors.set(index, 'Profile must contain at least one identifier') + return + } - if (!hasIdentifier || !hasTraits) { + if (totalFields < 2) { invalidIndices.push(index) validationErrors.set( index, - 'Profile must contain at least one identifier (email or phone) and at least one trait' + 'Profile must contain at least two total fields (identifiers + traits). It could be two identifiers, or one identifier and one trait.' ) return } @@ -259,17 +262,33 @@ function buildTraitGroups(payload: Payload): Record + const invalidIdentifierKeys: string[] = [] Object.entries(identifiers).forEach(([key, value]) => { if (value !== undefined && value !== null) { - if (!traitGroups.Contact) { - traitGroups.Contact = {} + const match = key.match(/^([^.]+)\.\$\.(.+)$/) + if (match) { + const traitGroupName = match[1] + const traitName = match[2] + if (!traitGroups[traitGroupName]) { + traitGroups[traitGroupName] = {} + } + traitGroups[traitGroupName][traitName] = value + } else { + invalidIdentifierKeys.push(key) } - traitGroups.Contact[key] = value } }) + + if (invalidIdentifierKeys.length > 0) { + throw new PayloadValidationError( + `Invalid identifier key format detected. The following keys do not match the expected format: ${invalidIdentifierKeys.join( + ', ' + )}. ` + `Expected format: "TraitGroupName.$.traitName" (e.g., "Contact.$.email", "Contact.$.phone").` + ) + } } return traitGroups @@ -310,10 +329,19 @@ interface TraitGroupsListResponse { } } -// Fetch all trait group definitions for dynamic fields -async function fetchAllTraits(request: RequestClient, settings: Settings, storeId: string) { +type DynamicFieldResult = { + choices: Array<{ label: string; value: string; description: string }> + error?: { message: string; code: string } +} + +// Fetch all trait group fields and return identifiers and traits separately. +// Identifiers are traits with idTypePromotion set; traits are non-identifier STRING traits. +async function fetchTraitGroupFields( + request: RequestClient, + settings: Settings, + storeId: string +): Promise<{ identifiers: DynamicFieldResult; traits: DynamicFieldResult }> { try { - // Fetch list of all trait groups (includes traits in the response) const traitGroupsResponse = await request( `${BASE_URL}/${API_VERSION}/ControlPlane/Stores/${storeId}/TraitGroups?pageSize=100&includeTraits=true`, { @@ -329,54 +357,48 @@ async function fetchAllTraits(request: RequestClient, settings: Settings, storeI const traitGroupObjects = traitGroupsResponse?.data?.traitGroups || [] - // Map the response to the format we need - const traitGroups = traitGroupObjects.map((traitGroup) => ({ - traitGroupName: traitGroup.displayName, - traits: traitGroup.traits || {} - })) + const identifierChoices: DynamicFieldResult['choices'] = [] + const traitChoices: DynamicFieldResult['choices'] = [] - // Build choices from all trait groups - const choices: Array<{ label: string; value: string; description: string }> = [] + for (const traitGroup of traitGroupObjects) { + const traitGroupName = traitGroup.displayName + const traits = traitGroup.traits || {} - for (const { traitGroupName, traits } of traitGroups) { Object.entries(traits).forEach(([traitName, trait]) => { - // For Contact trait group, exclude identifiers (email/phone) as they're handled separately - if (traitGroupName === 'Contact' && (trait.idTypePromotion === 'email' || trait.idTypePromotion === 'phone')) { - return - } + const value = `${traitGroupName}.$.${traitName}` + const label = `${traitGroupName}.${trait.displayName || traitName}` - // Only include STRING type traits - if (trait.dataType === 'STRING') { - // All trait groups use traitGroupName.$.traitName format - const value = `${traitGroupName}.$.${traitName}` - const label = `${traitGroupName}.${trait.displayName || traitName}` - - // Use custom description if available, otherwise generate one + if (trait.idTypePromotion && trait.dataType === 'STRING') { + const description = trait.description + ? trait.description + : `${traitGroupName} - ${trait.displayName} (${trait.idTypePromotion})` + identifierChoices.push({ label, value, description }) + } else if (!trait.idTypePromotion && trait.dataType === 'STRING') { const description = trait.description ? trait.description : `${traitGroupName} - ${trait.displayName} (${trait.dataType})` - - choices.push({ - label, - value, - description - }) + traitChoices.push({ label, value, description }) } }) } return { - choices + identifiers: { choices: identifierChoices }, + traits: { choices: traitChoices } } } catch (error) { const statusCode = error?.response?.status || 'unknown' const errorMsg = error?.response?.data?.message || (error instanceof Error ? error.message : String(error)) - return { + const errorResult = (fieldType: string): DynamicFieldResult => ({ choices: [], error: { - message: `Unable to fetch traits (HTTP ${statusCode}: ${errorMsg}). You can still manually enter field names.`, + message: `Unable to fetch ${fieldType} (HTTP ${statusCode}: ${errorMsg}). You can still manually enter field names.`, code: 'FETCH_ERROR' } + }) + return { + identifiers: errorResult('identifiers'), + traits: errorResult('traits') } } }