import { Utils } from '../common/utils'; import type { EventProcessorFn, MapLike, TypedAny } from '../common/app-types'; import { Logger } from '../common/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { ulid } from 'ulid'; const eventsToPersist: Record = { user_info: '_mpPendingUserInfo', mp_purchase: '_mpPendingPurchase', }; // Maximum number of events to keep in memory for state tracking and queues const MAX_TRACKED_EVENTS = 500; export class MpDataLayerHelper { isReady = false; _masterDataLayer: MapLike = {}; _persistedVars: MapLike = {}; eventProcessors: Record = {}; stateTracker: Array = []; dlInitEvent: string; receivedInitialEvent = false; eventQueue: Array = []; private isProcessing = false; private isEntryPoint = 0; // Event deduplication private eventDedupWindowMs = 5000; // Default 5 seconds private eventDeduplicationCache: Map = new Map(); private purchaseTransactionCache: Map = new Map(); constructor( private readonly dlEventName: string, private readonly dlInitMode: string, dlInitEvent: string, private readonly storageKeyName: string = '_mpdls' ) { this.dlInitEvent = dlInitEvent; this.resetEntryPointInfo(); if (this.dlInitMode !== 'manual') { this.isReady = true; } if (this.isReady) { this.ready(); } } async init( globalEventListeners: Record ): Promise { // register any global event listeners passed through to the init command const globalEventListenerKeys = Object.keys(globalEventListeners || {}); if (globalEventListenerKeys?.length > 0) { for (const key of globalEventListenerKeys) { if (typeof globalEventListeners[key] !== 'undefined') { this.registerProcessor(key, globalEventListeners[key] as any); } } } // Load persisted events from AsyncStorage for (const key of Object.keys(eventsToPersist)) { try { const persistedEvent = await AsyncStorage.getItem(eventsToPersist[key]); if (persistedEvent) { const event: { eventName: string; eventPayload: any } = JSON.parse(persistedEvent); this.pushEvent(event.eventName, event.eventPayload); } } catch (err) { Logger.logError('Error parsing persisted event', err); } } } /** * Set the event deduplication window * @param windowMs Window in milliseconds (0 to disable) */ setDeduplicationWindow(windowMs: number): void { this.eventDedupWindowMs = windowMs; Logger.logDbg(`Event deduplication window set to: ${windowMs}ms`); } /** * Generate a unique fingerprint for an event * @param eventName Event name * @param payload Event payload * @returns Fingerprint string */ private getEventFingerprint(eventName: string, payload: MapLike): string { try { // Use JSON.stringify for deterministic payload representation const payloadStr = JSON.stringify(payload); return `${eventName}::${payloadStr}`; } catch (err) { Logger.logError('Error generating event fingerprint', err); return `${eventName}::${Date.now()}`; // Fallback to never match } } /** * Extract transaction_id from purchase event payload * @param payload Event payload * @returns transaction_id or null */ private extractTransactionId(payload: MapLike): string | null { // Check common field names for transaction_id return ( payload.transaction_id || payload.transactionId || payload.orderId || payload.order_id || null ); } /** * Check if event is a duplicate within the deduplication window * @param eventName Event name * @param payload Event payload * @returns true if duplicate, false otherwise */ private isDuplicateEvent(eventName: string, payload: MapLike): boolean { // If deduplication is disabled (window = 0), never treat as duplicate if (this.eventDedupWindowMs === 0) { return false; } const now = Date.now(); // Special handling for purchase events - deduplicate by transaction_id if (eventName === 'purchase' || eventName === 'mp_purchase') { const transactionId = this.extractTransactionId(payload); if (transactionId) { const lastSeenTs = this.purchaseTransactionCache.get(transactionId); if (lastSeenTs && now - lastSeenTs < this.eventDedupWindowMs) { Logger.logDbg( `Duplicate purchase event detected (transaction_id: ${transactionId}) within ${this.eventDedupWindowMs}ms window, skipping` ); return true; // Duplicate } // Record this transaction this.purchaseTransactionCache.set(transactionId, now); } else { Logger.logDbg( 'Purchase event without transaction_id, cannot deduplicate by transaction' ); } } // General event deduplication by full fingerprint const fingerprint = this.getEventFingerprint(eventName, payload); const lastSeenTs = this.eventDeduplicationCache.get(fingerprint); if (lastSeenTs && now - lastSeenTs < this.eventDedupWindowMs) { Logger.logDbg( `Duplicate event detected (${eventName}) within ${this.eventDedupWindowMs}ms window, skipping` ); return true; // Duplicate } // Record this event this.eventDeduplicationCache.set(fingerprint, now); // Cleanup old entries periodically this.cleanupDeduplicationCache(now); return false; } /** * Remove expired entries from deduplication caches * @param now Current timestamp */ private cleanupDeduplicationCache(now: number): void { // Cleanup general event cache for (const [ fingerprint, timestamp, ] of this.eventDeduplicationCache.entries()) { if (now - timestamp > this.eventDedupWindowMs) { this.eventDeduplicationCache.delete(fingerprint); } } // Cleanup purchase transaction cache for (const [ transactionId, timestamp, ] of this.purchaseTransactionCache.entries()) { if (now - timestamp > this.eventDedupWindowMs) { this.purchaseTransactionCache.delete(transactionId); } } } pushEvent(eventName: string, payload: MapLike): void { Logger.logDbg('EV Push Event:: ', JSON.stringify(payload)); // Special events bypass queue and process immediately (no deduplication) if ( eventName === 'set' || eventName === 'persist' || eventName === 'clear' || eventName === 'user_info' ) { // process regardless - fire and forget for async operations this.processQItems(eventName, payload).catch((err) => Logger.logError('Error processing event', err) ); return; } // Check for duplicate events BEFORE queuing if (this.isDuplicateEvent(eventName, payload)) { // Duplicate detected, skip this event return; } // Track if we received the initial event if (!this.receivedInitialEvent) { this.receivedInitialEvent = eventName === this.dlInitEvent; } // Always queue events for sequential processing (with eviction of oldest if at capacity) if (this.eventQueue.length >= MAX_TRACKED_EVENTS) { const evictedEvent = this.eventQueue.shift(); Logger.logDbg( `Event queue at capacity (${MAX_TRACKED_EVENTS}), evicting oldest event:`, evictedEvent?.[0] ); } this.eventQueue.push([eventName, payload]); // Trigger processing if ready and initial event received if (this.isReady && this.receivedInitialEvent) { this.processNext(); } } async processQItems(eventName: string, payload: MapLike): Promise { try { if (eventName === 'set') { this.setDl(payload); } else if (eventName === 'persist') { await this.storeData(payload?.key, payload?.value); } else if (eventName === 'clear') { if (Array.isArray(payload) && payload.length > 0) { await this.deleteData(payload as string[]); } } else if (eventName === 'user_info') { this.setUserData(payload); } else { await this.eventProcessor(eventName, payload); } /* Make sure nothing is written below this line. If you do so, remember to return on error conditions above */ } catch (err) { Logger.logError('Error performing init setup on dl', err); } } /** * Private Processor for 'set' command object */ setDl(obj: TypedAny | undefined): boolean { if (!obj) { return false; } const _tmp = Utils.flattenObject(obj); this._masterDataLayer = Object.assign({}, this._masterDataLayer, _tmp); return true; } getDl(): TypedAny { return this._masterDataLayer; } private setUserData(payload: { pid?: string; email?: string; phone?: string; country?: string; city?: string; state?: string; fName?: string; lName?: string; zip?: string; }): void { if ( payload?.zip && payload?.zip?.length > 0 && payload?.zip?.indexOf('-') > -1 ) { payload.zip = payload.zip.split('-')[0].trim(); } const uif: any = {}; this._persistedVars = this._persistedVars || {}; this._persistedVars.uif = this._persistedVars.uif || {}; if (payload.pid) { this._persistedVars.uif.user_pid = payload.pid; uif.pid = payload.pid; } if (payload.email) { this._persistedVars.uif.user_email = payload.email; uif.e = payload.email; } if (payload.phone) { this._persistedVars.uif.user_phone = payload.phone; uif.p = payload.phone; } if (payload.country) { this._persistedVars.uif.user_country = payload.country; uif.cy = payload.country; } if (payload.city) { this._persistedVars.uif.user_city = payload.city; uif.ct = payload.city; } if (payload.state) { this._persistedVars.uif.user_state = payload.state; uif.st = payload.state; } if (payload.fName) { this._persistedVars.uif.fn = payload.fName; uif.fn = payload.fName; } if (payload.lName) { this._persistedVars.uif.ln = payload.lName; uif.ln = payload.lName; } if (payload.zip) { this._persistedVars.uif.zip_code = payload.zip; uif.z = payload.zip; } if (Object.keys(uif).length > 0) { this.storeData('uif', uif); } } private async deleteData(chips: string[]): Promise { const fullData = await this.getFullData(); for (const chip of chips) { if (typeof chip === 'string') { delete fullData[chip]; } } await this.storeFullData(fullData); } private async storeData(chip: string, value: any): Promise { if (typeof chip !== 'undefined' && typeof value !== 'undefined') { const fullData = await this.getFullData(); fullData[chip] = value; await this.storeFullData(fullData); this._persistedVars[chip] = value; } else { Logger.logError('Both key and value are required for persisting data'); } } private async reloadPersistedVars(): Promise { this._persistedVars = await this.getFullData(); } private async getFullData(): Promise { try { const storedData = await AsyncStorage.getItem(this.storageKeyName); if (storedData) { return JSON.parse(storedData); } } catch (err) { Logger.logError('Error reloading persisted variables', err); } return {}; } private async storeFullData(fullData: MapLike): Promise { this._persistedVars = fullData; try { await AsyncStorage.setItem(this.storageKeyName, JSON.stringify(fullData)); } catch (err) { Logger.logError('Error storing data', err); } } // private async getStoredData(chip: string, defValue: T): Promise { // let value: T; // try { // const storedData = await AsyncStorage.getItem(this.storageKeyName); // if (storedData) { // const parsedData = JSON.parse(storedData); // value = parsedData[chip]; // } // } catch (err) { // Logger.logError('Error parsing stored data', err); // } // return value ?? defValue; // } /** * Private event processor. Idea is that whenever an event is detected, we create a copy of the master data layer, * add the event variables, and trigger the event requested when initializing the data layer helper * @param eventName * @param model */ async eventProcessor( eventName: string, model: TypedAny | undefined ): Promise { model = model ?? {}; /** * The following If block clears the data layer when the initial event is received. * This is done to prevent value carry overs from one page to another in single page apps */ if (eventName === 'reset' || eventName === this.dlInitEvent) { this.reset(); // add an init event based ulid to the core data model model['page_load_uid'] = ulid(); model['is_entry_point'] = this.getIsEntryPointValue(); if (model['is_entry_point'] === 1) { // Note: In React Native, we don't have document.referrer, so we'll use a placeholder model['entry_point_referrer'] = ''; } this.setDl(model); model = undefined; } if (model) { model = { ev: (model || {}) as any, }; } else { model = {}; } await this.reloadPersistedVars(); // get persisted values and add them into the event model model['p'] = this._persistedVars ?? {}; // generate a unique id for each event automatically model['ev._id'] = Utils.getUniqueID(); // add the event ts also for every event model['ev._tsMs'] = Date.now(); model['ev._eventName'] = eventName; model['ev._persistKey'] = eventsToPersist[eventName] || null; model = Utils.flattenObject(model); // create a clone of master data layer and merge event model to it const _clone = Object.assign({}, this._masterDataLayer, model); const eventTs = Date.now(); const eventPayload = JSON.parse( JSON.stringify({ eventName: eventName, eventTs: eventTs, model: _clone, }) ); // add to state tracker (with eviction of oldest events if at capacity) if (this.stateTracker.length >= MAX_TRACKED_EVENTS) { this.stateTracker.shift(); // Remove oldest event } this.stateTracker.push(eventPayload); // trigger an event that can be listened to by other listeners Utils.triggerEvent(this.dlEventName, eventPayload); // see if there are any other registered processors and invoke them if (this.eventProcessors[eventName]) { for (const processor of this.eventProcessors[eventName]) { Utils.safeExecute(processor, eventPayload); } } } overrideDlInitEvent(eventName: string): string { if (eventName && eventName.length > 0) { this.dlInitEvent = eventName; return 'Dl Init Event Override Successful'; } return 'Dl Init Event Override Failed'; } /** * Exposed method for registering custom processors for events * @param eventType * @param fn */ registerProcessor(eventType: string, fn: EventProcessorFn): void { if (!eventType || !fn || typeof fn !== 'function') { Logger.logError('EventType and Fn are mandatory'); return; } if (!this.eventProcessors[eventType]) { this.eventProcessors[eventType] = []; } this.eventProcessors[eventType].push(fn); } /** * Exposed getter for getting values from data layer * @param key * @returns {*|undefined} */ get(key: string): MapLike | undefined { return this._masterDataLayer[key] || undefined; } getState(): TypedAny[] { return this.stateTracker; } hasEventHappened(key: string): boolean { return !!this.stateTracker.find((i) => i.eventName === key); } reset(): boolean { this._masterDataLayer = {}; return true; } /** * Full shutdown - clears all state and allows reinitialization */ shutdown(): void { this.isReady = false; this._masterDataLayer = {}; this._persistedVars = {}; this.eventProcessors = {}; this.stateTracker = []; this.receivedInitialEvent = false; this.eventQueue = []; this.isProcessing = false; this.isEntryPoint = 0; this.eventDeduplicationCache.clear(); this.purchaseTransactionCache.clear(); Logger.logDbg('EEDL shutdown complete'); } ready(): void { this.isReady = true; if (this.receivedInitialEvent) { Logger.logDbg('Initial event received: ', this.dlInitEvent); // Start processing queue if we have events and initial event received if (this.eventQueue.length > 0) { this.processNext(); } } else { Logger.logDbg( `Initial event (${this.dlInitEvent}) NOT received. Events will be queued` ); } } /** * Process next event in queue with atomic check-and-set * Ensures only one event processes at a time, eliminating race conditions */ processNext(): void { // Atomic check-and-set: if already processing or queue empty, return if (this.isProcessing || this.eventQueue.length === 0) { return; } this.isProcessing = true; // Dequeue from front for FIFO ordering const item = this.eventQueue.shift(); if (item) { const [eventName, payload] = item; // Process the event this.processQItems(eventName, payload) .catch((err) => { Logger.logError('Error processing event in queue', err); }) .finally(() => { // Reset processing flag this.isProcessing = false; // Process next item if queue not empty // Use setImmediate for non-blocking event loop if (this.eventQueue.length > 0) { setImmediate(() => this.processNext()); } }); } else { this.isProcessing = false; } } getIsEntryPointValue(): number { // In React Native, we'll use a simple approach for entry point tracking // This could be enhanced with proper session storage implementation const currentValue = this.isEntryPoint; this.isEntryPoint = 0; // Reset after first use return currentValue; } resetEntryPointInfo(): void { // In React Native, we'll default to entry point = 1 for the first session // This could be enhanced with proper referrer tracking if needed this.isEntryPoint = 1; } }