From 31b8be7a4ca8705603a4db23af46909673c40171 Mon Sep 17 00:00:00 2001 From: Preston Starkey Date: Mon, 26 Jan 2026 15:11:14 -0500 Subject: [PATCH] LE - add placement event mapping rules on attributes --- src/Rokt-Kit.js | 163 +++++++++++++- test/src/tests.js | 546 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 706 insertions(+), 3 deletions(-) diff --git a/src/Rokt-Kit.js b/src/Rokt-Kit.js index 1e11709..e24ceb2 100644 --- a/src/Rokt-Kit.js +++ b/src/Rokt-Kit.js @@ -36,8 +36,125 @@ var constructor = function () { self.userAttributes = {}; self.testHelpers = null; self.placementEventMappingLookup = {}; + self.placementEventMappingRulesLookup = {}; self.eventQueue = []; + /** + * Evaluates a condition against an mParticle event.s + * @param {Object} event - The mParticle event to evaluate the condition against. + * @param {Object} condition - The condition to evaluate from mParticle UI config. + * @returns {boolean} True if the condition is met, false otherwise. + */ + function doesConditionMatch(event, condition) { + if (!condition || typeof condition.operator !== 'string') { + return false; + } + + var attribute = condition.attribute; + var operator = condition.operator.toLowerCase(); + var expectedValue = condition.attributeValue; + + var actualValue = + event && event.EventAttributes && event.EventAttributes[attribute]; + + if (operator === 'exists') { + return typeof actualValue !== 'undefined' && actualValue !== null; + } + + // Equals check (type-sensitive) + if (operator === 'equals') { + return actualValue === expectedValue; + } + + // Only explicitly supported string operator + if (operator !== 'contains') { + return false; + } + + // Contains check (string-only, case-sensitive) + if ( + typeof actualValue !== 'string' || + typeof expectedValue !== 'string' + ) { + return false; + } + + return actualValue.indexOf(expectedValue) !== -1; + } + + function doesRuleMatch(event, rule) { + var conditions = rule.conditions; + if (!Array.isArray(conditions)) { + return false; + } + if (conditions.length === 0) { + return true; + } + for (var i = 0; i < conditions.length; i++) { + if (!doesConditionMatch(event, conditions[i])) { + return false; + } + } + return true; + } + + function buildPlacementEventMappingRulesLookup(rules) { + var index = {}; + if (!Array.isArray(rules)) { + return index; + } + for (var i = 0; i < rules.length; i++) { + var rule = rules[i]; + + var jsmapKey = rule.jsmap; + var value = rule.value; + + if (!index[jsmapKey]) index[jsmapKey] = {}; + if (!index[jsmapKey][value]) index[jsmapKey][value] = []; + + index[jsmapKey][value].push({ + jsmap: jsmapKey, + value: value, + conditions: rule.conditions, + }); + } + return index; + } + + function applyEventMapping(event, jsmap) { + var mapped = self.placementEventMappingLookup[jsmap]; + if (!mapped) { + return; + } + var keys = Array.isArray(mapped) ? mapped : [mapped]; + + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var rulesForKey = + self.placementEventMappingRulesLookup && + self.placementEventMappingRulesLookup[jsmap] && + self.placementEventMappingRulesLookup[jsmap][key] + ? self.placementEventMappingRulesLookup[jsmap][key] + : null; + + // If there are rules for (jsmap,key), only set when ALL rules match (AND). + if (rulesForKey && rulesForKey.length) { + var allMatch = true; + for (var j = 0; j < rulesForKey.length; j++) { + if (!doesRuleMatch(event, rulesForKey[j])) { + allMatch = false; + break; + } + } + if (!allMatch) { + continue; + } + } + + window.mParticle.Rokt.setLocalSessionAttribute(key, true); + } + } + /** * Generates the Rokt launcher script URL with optional domain override and extensions * @param {string} domain - The CNAME domain to use for overriding the launcher url @@ -92,6 +209,12 @@ var constructor = function () { placementEventMapping ); + var placementEventMappingRules = parseSettingsString( + settings.placementEventMappingRules + ); + self.placementEventMappingRulesLookup = + buildPlacementEventMappingRulesLookup(placementEventMappingRules); + // Set dynamic OTHER_IDENTITY based on server settings // Convert to lowercase since server sends TitleCase (e.g., 'Other' -> 'other') if (settings.hashedEmailUserIdentityType) { @@ -115,6 +238,9 @@ var constructor = function () { hashEventMessage: hashEventMessage, parseSettingsString: parseSettingsString, generateMappedEventLookup: generateMappedEventLookup, + buildPlacementEventMappingRulesLookup: + buildPlacementEventMappingRulesLookup, + doesConditionMatch: doesConditionMatch, }; attachLauncher(accountId, launcherOptions); return; @@ -319,8 +445,21 @@ var constructor = function () { ); if (self.placementEventMappingLookup[hashedEvent]) { - var mappedValue = self.placementEventMappingLookup[hashedEvent]; - window.mParticle.Rokt.setLocalSessionAttribute(mappedValue, true); + applyEventMapping(event, hashedEvent); + } + + // Allow wildcard event-name mapping (e.g., any ScreenView for a given type/category). + var hashedEventWildcard = hashEventMessage( + event.EventDataType, + event.EventCategory, + '*' + ); + + if ( + hashedEventWildcard !== hashedEvent && + self.placementEventMappingLookup[hashedEventWildcard] + ) { + applyEventMapping(event, hashedEventWildcard); } } @@ -565,8 +704,26 @@ function generateMappedEventLookup(placementEventMapping) { var mappedEvents = {}; for (var i = 0; i < placementEventMapping.length; i++) { var mapping = placementEventMapping[i]; - mappedEvents[mapping.jsmap] = mapping.value; + + var jsmap = mapping.jsmap; + var value = mapping.value; + if (!mappedEvents[jsmap]) { + mappedEvents[jsmap] = []; + } + // Avoid duplicates + if (mappedEvents[jsmap].indexOf(value) === -1) { + mappedEvents[jsmap].push(value); + } } + + var jsmapKeys = Object.keys(mappedEvents); + for (var j = 0; j < jsmapKeys.length; j++) { + var key = jsmapKeys[j]; + if (mappedEvents[key] && mappedEvents[key].length === 1) { + mappedEvents[key] = mappedEvents[key][0]; + } + } + return mappedEvents; } diff --git a/test/src/tests.js b/test/src/tests.js index 0b75846..2029634 100644 --- a/test/src/tests.js +++ b/test/src/tests.js @@ -3148,6 +3148,552 @@ describe('Rokt Forwarder', () => { }); }); + it('should set local session attribute only when rule conditions match (URL contains)', async () => { + const placementEventMapping = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + map: 'any', + maptype: 'EventClass.Id', + value: 'saleSeeker', + }, + ]); + + const placementEventMappingRules = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + value: 'saleSeeker', + conditions: [ + { + attribute: 'URL', + operator: 'contains', + attributeValue: 'sale', + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventMapping, + placementEventMappingRules, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + // Non-matching URL => should NOT set saleSeeker + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/home', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + + // Matching URL => should set saleSeeker + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/sale/items', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({ + saleSeeker: true, + }); + }); + + it('should apply both exact and wildcard mappings when both are configured', async () => { + const placementEventMapping = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + map: 'any', + maptype: 'EventClass.Id', + value: 'exactFlag', + }, + { + jsmap: 'hashed-<30*>-value', + map: 'any', + maptype: 'EventClass.Id', + value: 'wildcardFlag', + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventMapping, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + }); + + window.mParticle._Store.localSessionAttributes.should.deepEqual({ + exactFlag: true, + wildcardFlag: true, + }); + }); + + it('should evaluate contains and equals case-sensitively', async () => { + const placementEventMapping = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + map: 'any', + maptype: 'EventClass.Id', + value: 'caseSensitive', + }, + ]); + + const placementEventMappingRules = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + value: 'caseSensitive', + conditions: [ + { + attribute: 'URL', + operator: 'contains', + attributeValue: 'Sale', + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventMapping, + placementEventMappingRules, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + // URL contains "sale" but NOT "Sale" => should NOT set + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/sale/items', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + }); + + it('should evaluate EventAttributes keys case-sensitively', async () => { + const placementEventMapping = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + map: 'any', + maptype: 'EventClass.Id', + value: 'attrCaseSensitive', + }, + ]); + + const placementEventMappingRules = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + value: 'attrCaseSensitive', + conditions: [{ attribute: 'url', operator: 'exists' }], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventMapping, + placementEventMappingRules, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + // Event has URL, rule checks "url" => should NOT match + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/anything', + }, + }); + + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + }); + + it('should require ALL rules for the same (jsmap,key) to match (AND across rules)', async () => { + const placementEventMapping = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + map: 'any', + maptype: 'EventClass.Id', + value: 'saleSeeker', + }, + ]); + + // Two separate rules for the same (jsmap,value). Both must match. + const placementEventMappingRules = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + value: 'saleSeeker', + conditions: [ + { + attribute: 'URL', + operator: 'contains', + attributeValue: 'sale', + }, + ], + }, + { + jsmap: 'hashed-<30Browse>-value', + value: 'saleSeeker', + conditions: [ + { + attribute: 'URL', + operator: 'contains', + attributeValue: 'items', + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventMapping, + placementEventMappingRules, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + // Matches only 1/2 rules => should NOT set + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/sale', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + + // Matches both rules => should set + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/sale/items', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({ + saleSeeker: true, + }); + }); + + it('should support exists operator for rule conditions', async () => { + const placementEventMapping = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + map: 'any', + maptype: 'EventClass.Id', + value: 'hasUrl', + }, + ]); + + const placementEventMappingRules = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + value: 'hasUrl', + conditions: [{ attribute: 'URL', operator: 'exists' }], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventMapping, + placementEventMappingRules, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/anything', + }, + }); + + window.mParticle._Store.localSessionAttributes.should.deepEqual({ + hasUrl: true, + }); + }); + + it('should treat invalid rule operators as non-matching', async () => { + const placementEventMapping = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + map: 'any', + maptype: 'EventClass.Id', + value: 'badOperator', + }, + ]); + + const placementEventMappingRules = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + value: 'badOperator', + conditions: [ + { + attribute: 'URL', + operator: null, + attributeValue: 'sale', + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventMapping, + placementEventMappingRules, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/sale/items', + }, + }); + + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + }); + + it('should treat unknown string operators as non-matching', async () => { + const placementEventMapping = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + map: 'any', + maptype: 'EventClass.Id', + value: 'unknownOperator', + }, + ]); + + const placementEventMappingRules = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + value: 'unknownOperator', + conditions: [ + { + attribute: 'URL', + operator: 'starts_with', + attributeValue: 'https://example.com/sale', + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventMapping, + placementEventMappingRules, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + URL: 'https://example.com/sale/items', + }, + }); + + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + }); + + it('should evaluate equals type-sensitively (numeric)', async () => { + const placementEventMapping = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + map: 'any', + maptype: 'EventClass.Id', + value: 'multipleproducts', + }, + ]); + + const placementEventMappingRules = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + value: 'multipleproducts', + conditions: [ + { + attribute: 'number_of_products', + operator: 'equals', + attributeValue: 2, + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventMapping, + placementEventMappingRules, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + // number => should match + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + number_of_products: 2, + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({ + multipleproducts: true, + }); + + // string => should NOT match numeric attributeValue + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + number_of_products: '2', + }, + }); + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + }); + + it('should treat contains as string-only (non-strings do not match)', async () => { + const placementEventMapping = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + map: 'any', + maptype: 'EventClass.Id', + value: 'containsNumber', + }, + ]); + + const placementEventMappingRules = JSON.stringify([ + { + jsmap: 'hashed-<30Browse>-value', + value: 'containsNumber', + conditions: [ + { + attribute: 'number_of_products', + operator: 'contains', + attributeValue: '2', + }, + ], + }, + ]); + + await window.mParticle.forwarder.init( + { + accountId: '123456', + placementEventMapping, + placementEventMappingRules, + }, + reportService.cb, + true, + null, + {} + ); + + await waitForCondition(() => window.mParticle.Rokt.attachKitCalled); + + window.mParticle._Store.localSessionAttributes = {}; + window.mParticle.forwarder.process({ + EventName: 'Browse', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + EventAttributes: { + number_of_products: 2, + }, + }); + + window.mParticle._Store.localSessionAttributes.should.deepEqual({}); + }); + it('should add the event to the event queue if the kit is not initialized', async () => { await window.mParticle.forwarder.init( {