diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 2af1cab..adc3e03 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -21,6 +21,10 @@ import type { IUserIdentities } from '@mparticle/web-sdk'; // BaseEvent not re-exported from @mparticle/web-sdk/internal, so we import directly from @mparticle/event-models. import { BaseEvent, CommerceEvent } from '@mparticle/event-models'; +import { + isSelectPlacementsAttributePersistenceDenied, + removeSelectPlacementsAttributePersistenceDeniedAttributes, +} from './selectPlacementsAttributePersistence'; interface RoktKitSettings { accountId: string; @@ -1091,7 +1095,7 @@ class RoktKit implements KitInterface { ): string { const kitSettings = settings as unknown as RoktKitSettings; const accountId = kitSettings.accountId; - this.userAttributes = filteredUserAttributes || {}; + this.userAttributes = removeSelectPlacementsAttributePersistenceDeniedAttributes(filteredUserAttributes); this._onboardingExpProvider = kitSettings.onboardingExpProvider; const placementEventMapping = parseSettingsString(kitSettings.placementEventMapping); @@ -1245,7 +1249,9 @@ class RoktKit implements KitInterface { } public setUserAttribute(key: string, value: unknown): string { - this.userAttributes[key] = value; + if (!isSelectPlacementsAttributePersistenceDenied(key)) { + this.userAttributes[key] = value; + } return 'Successfully set user attribute for forwarder: ' + name; } @@ -1256,7 +1262,7 @@ class RoktKit implements KitInterface { private handleIdentityComplete(user: IMParticleUser, eventType: RoktIdentityEventType, callbackName: string): string { const filteredUser = user as FilteredUser; - this.userAttributes = user.getAllUserAttributes(); + this.userAttributes = removeSelectPlacementsAttributePersistenceDeniedAttributes(user.getAllUserAttributes()); this.pendingIdentityEvents.push(this.buildIdentityEvent(eventType, filteredUser)); return 'Successfully called ' + callbackName + ' for forwarder: ' + name; } @@ -1402,7 +1408,8 @@ class RoktKit implements KitInterface { private _dispatchPlacements(options: Record): RoktSelection | Promise | undefined { const attributes = ((options && (options.attributes as Record)) || {}) as Record; - const placementAttributes: Record = { ...this.userAttributes, ...attributes }; + const cachedUserAttributes = removeSelectPlacementsAttributePersistenceDeniedAttributes(this.userAttributes); + const placementAttributes: Record = { ...cachedUserAttributes, ...attributes }; const filters = this.filters || {}; const userAttributeFilters = (filters.userAttributeFilters as string[]) || []; @@ -1420,7 +1427,7 @@ class RoktKit implements KitInterface { filteredAttributes = placementAttributes; } - this.userAttributes = filteredAttributes; + this.userAttributes = removeSelectPlacementsAttributePersistenceDeniedAttributes(filteredAttributes); const optimizelyAttributes = this._onboardingExpProvider === 'Optimizely' ? this.fetchOptimizely() : {}; diff --git a/src/selectPlacementsAttributePersistence.ts b/src/selectPlacementsAttributePersistence.ts new file mode 100644 index 0000000..3d3ccf6 --- /dev/null +++ b/src/selectPlacementsAttributePersistence.ts @@ -0,0 +1,47 @@ +const SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_LIST = [ + 'billingaddress1', + 'billingaddress2', + 'billingcity', + 'billingstate', + 'billingzipcode', + 'cartitems', + 'ccbin', + 'confirmationref', + 'conversiontype', + 'country', + 'couponcode', + 'currency', + 'language', + 'paymentserviceprovider', + 'paymentserviceproviderattribute', + 'paymenttype', + 'shippingaddress1', + 'shippingcity', + 'shippingcountry', + 'shippingmethod', + 'shippingstate', + 'shippingzipcode', + 'totalprice', +]; +const SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_SET = new Set(SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_LIST); + +export function isSelectPlacementsAttributePersistenceDenied(key: string): boolean { + return SELECT_PLACEMENTS_ATTRIBUTE_PERSISTENCE_DENY_SET.has(key.toLowerCase()); +} + +export function removeSelectPlacementsAttributePersistenceDeniedAttributes( + attributes: Record | null | undefined, +): Record { + const filteredAttributes: Record = {}; + const sourceAttributes = attributes || {}; + const attributeKeys = Object.keys(sourceAttributes); + + for (let i = 0; i < attributeKeys.length; i++) { + const key = attributeKeys[i]; + if (!isSelectPlacementsAttributePersistenceDenied(key)) { + filteredAttributes[key] = sourceAttributes[key]; + } + } + + return filteredAttributes; +} diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index d542657..4cb02ed 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -1,6 +1,10 @@ import packageJson from '../../package.json'; const packageVersion = packageJson.version; import '../../src/Rokt-Kit'; +import { + isSelectPlacementsAttributePersistenceDenied, + removeSelectPlacementsAttributePersistenceDeniedAttributes, +} from '../../src/selectPlacementsAttributePersistence'; import { Batch } from '@mparticle/web-sdk/internal'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -1098,6 +1102,126 @@ describe('Rokt Forwarder', () => { }, }); }); + + it('should not send denylisted commerce attributes from the cached user attributes', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + { + confirmationRef: 'previous-order', + conversionType: 'purchase', + PaymentServiceProviderAttribute: 'cached-provider', + totalPrice: '10.00', + couponCode: 'SAVE10', + shippingMethod: 'ground', + loyaltyTier: 'gold', + }, + ); + + await (window as any).mParticle.forwarder.selectPlacements({ + identifier: 'test-placement', + attributes: { + page: 'checkout', + }, + }); + + expect((window as any).Rokt.selectPlacementsCalled).toBe(true); + expect((window as any).Rokt.selectPlacementsOptions).toEqual({ + identifier: 'test-placement', + attributes: { + loyaltyTier: 'gold', + page: 'checkout', + mpid: '123', + }, + }); + expect((window as any).mParticle.forwarder.userAttributes).toEqual({ + loyaltyTier: 'gold', + page: 'checkout', + }); + }); + + it('should allow explicit commerce attributes for the current call without caching them', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + { + loyaltyTier: 'gold', + }, + ); + + await (window as any).mParticle.forwarder.selectPlacements({ + identifier: 'test-placement', + attributes: { + confirmationRef: 'current-order', + conversionType: 'purchase', + paymentServiceProviderAttribute: 'current-provider', + totalPrice: '10.00', + couponCode: 'SAVE10', + shippingMethod: 'ground', + page: 'checkout', + }, + }); + + expect((window as any).Rokt.selectPlacementsCalled).toBe(true); + expect((window as any).Rokt.selectPlacementsOptions).toEqual({ + identifier: 'test-placement', + attributes: { + loyaltyTier: 'gold', + confirmationRef: 'current-order', + conversionType: 'purchase', + paymentServiceProviderAttribute: 'current-provider', + totalPrice: '10.00', + couponCode: 'SAVE10', + shippingMethod: 'ground', + page: 'checkout', + mpid: '123', + }, + }); + expect((window as any).mParticle.forwarder.userAttributes).toEqual({ + loyaltyTier: 'gold', + page: 'checkout', + }); + }); + + it('should not cache denylisted commerce attributes set through setUserAttribute', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + (window as any).mParticle.forwarder.setUserAttribute('paymentServiceProviderAttribute', 'cached-provider'); + (window as any).mParticle.forwarder.setUserAttribute('favoriteStore', 'test-store'); + + await (window as any).mParticle.forwarder.selectPlacements({ + identifier: 'test-placement', + attributes: {}, + }); + + expect((window as any).Rokt.selectPlacementsCalled).toBe(true); + expect((window as any).Rokt.selectPlacementsOptions).toEqual({ + identifier: 'test-placement', + attributes: { + favoriteStore: 'test-store', + mpid: '123', + }, + }); + expect((window as any).mParticle.forwarder.userAttributes).toEqual({ + favoriteStore: 'test-store', + }); + }); }); describe('Identity handling', () => { @@ -2989,6 +3113,30 @@ describe('Rokt Forwarder', () => { }); expect((window as any).mParticle.forwarder.filters.filteredUser.getMPID()).toBe('123'); }); + + it('should not cache denylisted commerce attributes from the filtered user', () => { + (window as any).mParticle.forwarder.onUserIdentified({ + getAllUserAttributes: function () { + return { + confirmationRef: 'previous-order', + conversionType: 'purchase', + currency: 'USD', + paymentServiceProvider: 'test-provider', + 'test-attribute': 'test-value', + }; + }, + getMPID: function () { + return '123'; + }, + getUserIdentities: function () { + return { userIdentities: {} }; + }, + }); + + expect((window as any).mParticle.forwarder.userAttributes).toEqual({ + 'test-attribute': 'test-value', + }); + }); }); describe('#workspaceIdSync', () => { @@ -6189,6 +6337,50 @@ describe('Rokt Forwarder', () => { }); }); + describe('#isSelectPlacementsAttributePersistenceDenied', () => { + it('should identify denylisted attributes case-insensitively', () => { + expect(isSelectPlacementsAttributePersistenceDenied('confirmationref')).toBe(true); + expect(isSelectPlacementsAttributePersistenceDenied('confirmationRef')).toBe(true); + expect(isSelectPlacementsAttributePersistenceDenied('CONFIRMATIONREF')).toBe(true); + expect(isSelectPlacementsAttributePersistenceDenied('paymentServiceProvider')).toBe(true); + expect(isSelectPlacementsAttributePersistenceDenied('cartItems')).toBe(true); + expect(isSelectPlacementsAttributePersistenceDenied('conversionType')).toBe(true); + }); + + it('should return false for attributes that are not denylisted', () => { + expect(isSelectPlacementsAttributePersistenceDenied('loyaltyTier')).toBe(false); + expect(isSelectPlacementsAttributePersistenceDenied('favoriteStore')).toBe(false); + }); + }); + + describe('#removeSelectPlacementsAttributePersistenceDeniedAttributes', () => { + it('should remove denylisted attributes case-insensitively', () => { + const attributes = { + confirmationRef: 'previous-order', + PaymentServiceProvider: 'test-provider', + cartItems: [{ sku: 'test-sku' }], + conversionType: 'purchase', + loyaltyTier: 'gold', + }; + + expect(removeSelectPlacementsAttributePersistenceDeniedAttributes(attributes)).toEqual({ + loyaltyTier: 'gold', + }); + expect(attributes).toEqual({ + confirmationRef: 'previous-order', + PaymentServiceProvider: 'test-provider', + cartItems: [{ sku: 'test-sku' }], + conversionType: 'purchase', + loyaltyTier: 'gold', + }); + }); + + it('should return an empty object for null or undefined attributes', () => { + expect(removeSelectPlacementsAttributePersistenceDeniedAttributes(null)).toEqual({}); + expect(removeSelectPlacementsAttributePersistenceDeniedAttributes(undefined)).toEqual({}); + }); + }); + describe('#hashEventMessage', () => { it('should hash event message using generateHash in the proper order', () => { const eventName = 'Test Event';