import { CheckoutEvent, CheckoutProps, Client, DataRequestTypeEnum, DataResidencyEnum, Event, EventTypeEnum, InitSignalPayload, PurchaseEvent, PurchaseProps, RequestData, SendDataOptions, Session, SignalInstance, TrackEvent, } from '../types/signal.types'; import { isBrowser, getOnlineStatusSubscription } from '../helpers/browser.helper'; import { DEFAULT_MAX_SESSION_LAST_ACTIVITY_MINS } from '../constants/constants'; import { getClientInfo } from '../helpers/signals-client.helper'; import { getDateString, getDifInMins } from '../helpers/date.helper'; import { cacheSession, generateSession, setLastActivityCache } from '../helpers/session.helper'; import { getEdgeAppInitProps, getSessionFromInstance } from '../helpers/signals.helper'; import { cache, cacheData, flushCacheOnce } from '../helpers/cache.helper'; import shortenKeys from '../helpers/shorten-look-ups.helper'; import { PhyHubClient } from '../index'; import { z } from 'zod'; const tenantIdSchema = z .string() .regex(/^[a-zA-Z0-9]{24}$/, 'Invalid tenant ID format (must be 24 alphanumeric characters)'); const spaceIdSchema = z .string() .regex(/^[a-zA-Z0-9]{24}$/, 'Invalid space ID format (must be 24 alphanumeric characters)'); const clientIdSchema = z.string().regex(/^[a-zA-Z0-9=\-_]{24,36}$/, 'Invalid client ID format'); const ipSchema = z.string().refine( value => // Very simplified IPv4 check: /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) || // Very simplified IPv6 check: /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/i.test(value), { message: 'Invalid IP address (must be a valid IPv4 or IPv6)', } ); const eventSchema = z.object({ captureId: z.string().optional(), categoryId: z.string().optional(), clientId: clientIdSchema.optional(), dataResidency: z.nativeEnum(DataResidencyEnum), eventTime: z.string().optional(), eventType: z.string().regex(/^[A-Z0-9_]{5,60}$/), interaction: z.boolean(), int1: z.number().int().optional(), int2: z.number().int().optional(), int3: z.number().int().optional(), int4: z.number().int().optional(), int5: z.number().int().optional(), ip: ipSchema.optional(), productId: z.string().optional(), public: z.number().optional(), sessionId: z.string().max(36), spaceId: spaceIdSchema, stateful: z.boolean().optional(), str1: z.string().max(200).optional(), str2: z.string().max(200).optional(), str3: z.string().max(200).optional(), str4: z.string().max(200).optional(), str5: z.string().max(200).optional(), tenantId: tenantIdSchema, }); const defaultSignalInstance: SignalInstance = { // Session tenantId: '', sessionId: '', sessionCreated: '', environment: '', dataResidency: DataResidencyEnum.EU, // Default to EU country: '', locationAccuracy: undefined, latitude: undefined, longitude: undefined, spaceId: '', appId: '', appVersion: undefined, installationId: '', installationVersion: undefined, deviceId: null, clientId: undefined, accessId: '', accessToken: '', ip: undefined, // Client Specific clientUserAgent: isBrowser ? navigator?.userAgent : undefined, clientCreated: getDateString(), // Internal lastActivity: '', }; export class SignalsService { private instance = defaultSignalInstance; private maxSessionLastActivityMins = DEFAULT_MAX_SESSION_LAST_ACTIVITY_MINS; private isOnline = false; private requiredInitParams = [ 'tenantId', 'environment', 'dataResidency', 'country', 'appId', 'installationId', ]; private readonly hubClient: PhyHubClient; constructor(hubClient: PhyHubClient, initParams: InitSignalPayload) { this.hubClient = hubClient; this.initialize(initParams); } // todo: refactor to isInitialized or something? private validateObjectKeys>(obj: T, requiredKeys: string[]): void { const missingKeys = requiredKeys.filter(key => !(key in obj)); if (missingKeys.length > 0) { throw new Error(`Missing required keys: ${missingKeys.join(', ')} from payload`); } } private updateLastActivity = () => { const lastActivityCache = setLastActivityCache(getDateString()); this.instance.lastActivity = lastActivityCache || getDateString(); }; private sendOrCacheData = async ( type: DataRequestTypeEnum, data: RequestData, options?: SendDataOptions ): Promise => { try { if (!this.isOnline && this.instance.clientUserAgent) { throw new Error('Currently offline'); } // TODO: handle response const expectResponse = options && options.expectResponse ? options.expectResponse : false; this.send(type, data, expectResponse); const response = undefined; if (this.instance.clientUserAgent && cache && cache.length) { flushCacheOnce(this.sendOrCacheData); } return response; } catch (err) { if (this.instance.clientUserAgent && cache) { const { expectResponse } = options ?? {}; cacheData(type, data); if (expectResponse) { throw new Error(err as string); } } else { throw err; } } }; private trackEvent = async (event: TrackEvent, options?: SendDataOptions) => { this.validateObjectKeys(this.instance, this.requiredInitParams); this.updateLastActivity(); const eventData: Event = { tenantId: this.instance.tenantId, spaceId: this.instance.spaceId, eventTime: getDateString(), dataResidency: this.instance.dataResidency, sessionId: this.instance.sessionId, clientId: this.instance.clientId, ...event, }; return this.sendOrCacheData(DataRequestTypeEnum.EVENT, eventData, options); }; private startSessionTracker = () => { setInterval(async () => { const isExpiredSession = getDifInMins(getDateString(), this.instance.lastActivity) >= this.maxSessionLastActivityMins; if (isExpiredSession) { await this.createSession(); } }, 60000); }; private subscribeToOnlineState() { getOnlineStatusSubscription().subscribe(({ isOnline, message }) => { this.isOnline = isOnline; if (isBrowser) { this.validateObjectKeys(this.instance, this.requiredInitParams); if (isOnline) { this.sendAppOnline(message); } else { this.sendAppOffline(message); } } }); } /** * Everytime gridapp loads */ private sendAppStart = () => this.trackEvent({ eventType: EventTypeEnum.APP_START, interaction: false, }); private sendData = async (data: RequestData, _options: SendDataOptions) => { const jsonData = shortenKeys(data); const { type } = _options; this.hubClient.sendSignal(type, jsonData); // Fire and forget, no need to implement expect response // const { expectResponse } = options ?? {}; // if (expectResponse) {} }; // used internally to send signals by sendOrCacheData private send = ( type: DataRequestTypeEnum, data: RequestData, expectResponse: boolean = false ) => { switch (type) { case DataRequestTypeEnum.EVENT: this.sendEvent(data as Event, expectResponse); return; case DataRequestTypeEnum.CHECKOUT: this.sendCheckout(data as CheckoutEvent); return; case DataRequestTypeEnum.PURCHASE: this.sendPurchase(data as PurchaseEvent); return; case DataRequestTypeEnum.SESSION: this.sendSession(data as Session, expectResponse); return; case DataRequestTypeEnum.CLIENT: this.sendClient(data as Client, expectResponse); return; default: throw new Error(`Unsupported type ${type}`); } }; public getInstanceProps = () => { return this.instance; }; public createSession = async (instanceParams?: Partial) => { // override this.instance with params before creating a session if (instanceParams) { this.instance = { ...this.instance, ...instanceParams, }; } this.validateObjectKeys(this.instance, this.requiredInitParams); const { sessionId, sessionCreated } = generateSession({ forceCreateNewSession: true, maxSessionLastActivityMins: this.maxSessionLastActivityMins, }); this.instance.sessionId = sessionId; this.instance.sessionCreated = sessionCreated; this.updateLastActivity(); return this.sendOrCacheData(DataRequestTypeEnum.SESSION, getSessionFromInstance(this.instance)); }; public initialize(signalParams: InitSignalPayload) { if (this.instance.sessionId) { throw new Error('Signals already initialized'); } else { this.validateObjectKeys(signalParams, this.requiredInitParams); this.instance = { ...this.instance, ...signalParams, }; this.maxSessionLastActivityMins = typeof signalParams.createNewSessionAfterLastActivityMins === 'number' ? signalParams.createNewSessionAfterLastActivityMins : this.maxSessionLastActivityMins; const clientInfo = getClientInfo(this.instance); this.instance.clientId = clientInfo.clientId; this.instance.clientCreated = clientInfo.clientCreated ?? getDateString(); let sessionId = signalParams.sessionId ? signalParams.sessionId : ''; let sessionCreated = ''; let isSessionFromCache = false; if (sessionId) { sessionCreated = getDateString(); if (isBrowser) { cacheSession(sessionId, sessionCreated); } } else { const generatedSession = generateSession({ maxSessionLastActivityMins: this.maxSessionLastActivityMins, forceCreateNewSession: typeof signalParams.useValidCachedSessionOnInit === 'boolean' ? !signalParams.useValidCachedSessionOnInit : false, }); sessionId = generatedSession.sessionId; sessionCreated = generatedSession.sessionCreated; isSessionFromCache = generatedSession.isFromCache; } this.instance.sessionId = sessionId; this.instance.sessionCreated = sessionCreated; this.updateLastActivity(); if (isBrowser) { this.subscribeToOnlineState(); this.startSessionTracker(); } return Promise.all([ ...(isSessionFromCache ? [] : [ this.sendOrCacheData( DataRequestTypeEnum.SESSION, getSessionFromInstance(this.instance) ), ]), ...(isSessionFromCache ? [] : [this.sendAppStart()]), ...(!this.instance.clientId ? [] : [ this.sendOrCacheData(DataRequestTypeEnum.CLIENT, { tenantId: this.instance.tenantId, clientId: this.instance.clientId, clientCreated: this.instance.clientCreated, clientUserAgent: this.instance.clientUserAgent, dataResidency: this.instance.dataResidency, country: this.instance.country, latitude: this.instance.latitude, longitude: this.instance.longitude, locationAccuracy: this.instance.locationAccuracy, clientIp: this.instance.ip ?? this.instance.clientIp ?? undefined, }), ]), ]); } } public initializeEdgeApp = (signalParams: InitSignalPayload | {} = {}) => { const edgeParams = getEdgeAppInitProps(); const params: InitSignalPayload = { ...edgeParams, ...signalParams, }; this.validateObjectKeys(params, this.requiredInitParams); this.instance = { ...this.instance, ...params, }; }; public sendAppOnline = (message?: string) => { return this.trackEvent({ eventType: EventTypeEnum.APP_ONLINE, interaction: false, str1: message, }); }; public sendAppOffline = (message?: string) => { return this.trackEvent({ eventType: EventTypeEnum.APP_OFFLINE, interaction: false, str1: message, }); }; public sendEvent = (event: Event, expectResponse?: boolean) => { const parsedEvent = eventSchema.parse(event) as RequestData; return this.sendData(parsedEvent, { expectResponse, type: DataRequestTypeEnum.EVENT, }); }; private sendTransactionEvent = ( transactionData: CheckoutProps | PurchaseProps, eventType: 'PURCHASE' | 'CHECKOUT' ) => { const { transactionId, currency, revenue, products } = transactionData; const productEventType = `${eventType}_PRODUCT`; products.forEach(product => { this.trackEvent({ eventType: productEventType, productId: product.id, categoryId: product.categoryId, interaction: true, int1: product.quantity, int2: product.price, str1: transactionId, str2: product.name, str3: currency, }); }); return this.trackEvent({ eventType: eventType, interaction: true, int1: revenue, str1: transactionId, str2: currency, }); }; public sendPurchase = (purchaseData: PurchaseProps) => { return this.sendTransactionEvent(purchaseData, EventTypeEnum.PURCHASE); }; public sendCheckout = (checkoutData: CheckoutProps) => { return this.sendTransactionEvent(checkoutData, EventTypeEnum.CHECKOUT); }; public sendSession = (session: Session, expectResponse?: boolean) => { return this.sendData(session, { expectResponse, type: DataRequestTypeEnum.SESSION, }); }; public sendClient = (client: Client, expectResponse?: boolean) => { return this.sendData(client, { expectResponse, type: DataRequestTypeEnum.CLIENT, }); }; /** * When the order process starts. * Used for Click and Collect, restaurants, or any other order process */ public sendProcessOrderStart = () => { return this.trackEvent({ eventType: EventTypeEnum.PROCESS_ORDER_START, interaction: true, }); }; /** * When the order process ends. * Used for Click and Collect, restaurants, or any other order process */ public sendProcessOrderEnd = () => { return this.trackEvent({ eventType: EventTypeEnum.PROCESS_ORDER_END, interaction: true, }); }; /** * When the order process was successful. * Sent before sendProcessOrderEnd (PROCESS_ORDER_END) */ public sendProcessOrderSuccess = () => { return this.trackEvent({ eventType: EventTypeEnum.PROCESS_ORDER_SUCCESS, interaction: true, }); }; /** * When the order process failed. * Sent before sendProcessOrderEnd (PROCESS_ORDER_END) */ public sendProcessOrderFailed = () => { return this.trackEvent({ eventType: EventTypeEnum.PROCESS_ORDER_FAILED, interaction: true, }); }; /** * Represents each item in the order. * Add item amout/price later, for orders with known amount/price */ public sendOrderItem = ({ orderId, itemId, itemType, }: { orderId: string; itemId: string; itemType: string; }) => { return this.trackEvent({ eventType: EventTypeEnum.ORDER_ITEM, interaction: true, str1: orderId, str2: itemId, str3: itemType, }); }; /** * Represents each item in the order that was successfully delivered */ public sendProcessOrderItemDelivered = ({ orderId, itemId, }: { orderId: string; itemId: string; }) => { return this.trackEvent({ eventType: EventTypeEnum.PROCESS_ORDER_ITEM_DELIVERED, interaction: true, str1: orderId, str2: itemId, }); }; /** * Represents each item in the order that was failed to be delivered */ public sendProcessOrderItemFailed = ({ orderId, itemId, }: { orderId: string; itemId: string; }) => { return this.trackEvent({ eventType: EventTypeEnum.PROCESS_ORDER_ITEM_FAILED, interaction: true, str1: orderId, str2: itemId, }); }; /** * Authentication success event */ public sendAuthenticationSuccess = () => { return this.trackEvent({ eventType: EventTypeEnum.AUTHENTICATION_SUCCESS, interaction: true, }); }; /** * Authentication failed event */ public sendAuthenticationFailed = () => { return this.trackEvent({ eventType: EventTypeEnum.AUTHENTICATION_FAILED, interaction: true, }); }; /** * Generic barcode scan event */ public sendScanBarcode = ({ barcode }: { barcode: string }) => { return this.trackEvent({ eventType: EventTypeEnum.SCAN_BARCODE, interaction: true, str1: barcode, }); }; /** * Media finished event. Send this event after a piece of content has been played * @param id - The id of the media content * @param type - The type of the media content * @param name - The name of the media content * @param duration - The duration of the media content in milliseconds * @param tags - The list of tags of the media content */ public sendMediaFinished = ({ id, type, name, duration, tags = [], }: { id: string; type: string; name: string; duration: number; tags?: string[]; }) => { return this.trackEvent({ eventType: EventTypeEnum.MEDIA_FINISHED, interaction: false, str1: id, str2: type, str3: name, str4: tags.length > 0 ? tags.join(',') : undefined, int1: duration, }); }; /** * Custom event */ public sendCustomEvent = async (event: TrackEvent) => { return this.trackEvent(event); }; /** * Browsing products under a category */ public sendCategoryView = ({ categoryId }: { categoryId: string }) => { return this.trackEvent({ eventType: EventTypeEnum.CATEGORY_VIEW, categoryId, interaction: true, }); }; /** * Viewing a specific product page */ public sendProductView = ({ productId }: { productId: string }) => { return this.trackEvent({ eventType: EventTypeEnum.PRODUCT_VIEW, productId, interaction: true, }); }; /** * Adding a product to the cart */ public sendCartAdd = ({ productId, quantity }: { productId: string; quantity: number }) => { return this.trackEvent({ eventType: EventTypeEnum.CART_ADD, productId, interaction: true, int1: quantity, // quantity }); }; /** * Viewing the cart page */ public sendCartView = () => { return this.trackEvent({ eventType: EventTypeEnum.CART_VIEW, interaction: true, }); }; /** * Removing a product from the cart */ public sendCartRemove = ({ productId, quantity }: { productId: string; quantity: number }) => { return this.trackEvent({ eventType: EventTypeEnum.CART_REMOVE, interaction: true, productId, int1: quantity, //quantity }); }; /** * Clear the cart */ public sendCartClear = () => { return this.trackEvent({ eventType: EventTypeEnum.CART_CLEAR, interaction: true, }); }; /** * Searching a product, category, or anything in the app */ public sendSearch = ({ searchQueryString }: { searchQueryString: string }) => { return this.trackEvent({ eventType: EventTypeEnum.SEARCH, interaction: true, str1: searchQueryString, }); }; /** * Searching a product, category, or anything in the app */ public sendSearchClear = () => { return this.trackEvent({ eventType: EventTypeEnum.SEARCH_CLEAR, interaction: true, }); }; /** * User's rate of the experience */ public sendRating = ({ rating, interactionDelay, comment, }: { rating: 1 | 2 | 3 | 4 | 5; interactionDelay?: number; comment?: string; }) => { return this.trackEvent({ eventType: EventTypeEnum.RATING, interaction: true, int1: rating, int2: interactionDelay, str1: comment, }); }; /** * User's feedback of the experience */ public sendFeedback = ({ feedback, rating, }: { feedback: string; rating?: 1 | 2 | 3 | 4 | 5; }) => { return this.trackEvent({ eventType: EventTypeEnum.FEEDBACK, interaction: true, str1: feedback, str2: rating?.toString() ?? '', }); }; public sendQrScan = () => { return this.trackEvent({ eventType: EventTypeEnum.QR_RUN, interaction: true, }); }; /** * Setting state key, value and expiry duration (in ms) */ public setState = ({ key, value, expiryDuration, }: { key: string; value: string | number; expiryDuration: number; }) => { return this.trackEvent({ eventType: (key || '').toUpperCase(), interaction: false, str1: typeof value === 'string' ? value : undefined, int1: typeof value === 'number' ? value : undefined, stateful: true, public: expiryDuration, }); }; }