diff --git a/src/constants/application-config.js b/src/constants/application-config.js index b5233d170..1e2324ba7 100644 --- a/src/constants/application-config.js +++ b/src/constants/application-config.js @@ -6,6 +6,7 @@ const applicationConfig = { COOKIE_PATH: "/", CURRENT_ENVIRONMENT: process.env.VUE_APP_CURRENT_ENVIRONMENT, // "Localhost", "Dev", "QA", and "Prod" APPLICATION_NAME: "FixMyGlass", + ANALYTICS_APPLICATION_NAME: "FixMyGlassNextGen", APPLICATION_ABBREVIATION: "fmg", PAGE_QUERYSTRING: "fmgPage", SITE_ENTRY_TRIGGER_VALUE: "FixMyGlass", diff --git a/src/constants/cookie-names.js b/src/constants/cookie-names.js index 5fab52899..d62934cf5 100644 --- a/src/constants/cookie-names.js +++ b/src/constants/cookie-names.js @@ -3,10 +3,32 @@ import { applicationConfig } from "@/constants/application-config.js"; const cookieNames = { FUNNEL_SESSION_INFO: `FunnelSessionInfo-${applicationConfig.CURRENT_ENVIRONMENT}`, + FUNNEL_SESSION_KEY: `FunnelSessionKey-${applicationConfig.CURRENT_ENVIRONMENT}`, + FUNNEL_USER_ID: `FunnelUserId-${applicationConfig.CURRENT_ENVIRONMENT}`, + // Existing Safelite.com cookies DXDEV: "dxdev", SESSION_ID: "sid", SESSION_KEY: "skey", }; -export { cookieNames }; +const cookieExpirations = { + SESSION_ID: convertToSeconds({ minutes: 30 }), + DXDEV: convertToSeconds({ years: 1 }), + FUNNEL_USER_ID: convertToSeconds({ weeks: 1 }), + FUNNEL_SESSION_KEY: convertToSeconds({ minutes: 30 }), +}; + +export { cookieNames, cookieExpirations }; + +function convertToSeconds({ years, months, weeks, days, hours, minutes, seconds }) { + let total = seconds ?? 0; + total += (minutes ?? 0) * 60; + total += (hours ?? 0) * 60 * 60; + total += (days ?? 0) * 24 * 60 * 60; + total += (weeks ?? 0) * 7 * 24 * 60 * 60; + total += (months ?? 0) * 30 * 24 * 60 * 60; + total += (years ?? 0) * 365 * 24 * 60 * 60; + + return total; +} diff --git a/src/helpers/heritage-integration/cookie-helper.js b/src/helpers/heritage-integration/cookie-helper.js index 1ef43bff0..abb1c0a67 100644 --- a/src/helpers/heritage-integration/cookie-helper.js +++ b/src/helpers/heritage-integration/cookie-helper.js @@ -1,4 +1,4 @@ -import { cookieNames } from "@/constants/cookie-names"; +import { cookieNames, cookieExpirations } from "@/constants/cookie-names"; import store from "@/store"; import { applicationConfig } from "@/constants/application-config"; @@ -78,11 +78,43 @@ export function getDeviceIdValue() { return "00000000-0000-0000-0000-000000000000"; } +export function regenerateDeviceId() { + if (!isCookieSet(cookieNames.DXDEV)) { + setCookieProperties( + { + [cookieNames.DXDEV]: `did=${crypto.randomUUID()}`, + }, + { maxAge: cookieExpirations.DXDEV } + ); + } +} + +export function getUserIdValue() { + const cookieValue = getCookieValueByName(cookieNames.FUNNEL_USER_ID); + + if (cookieValue) { + return cookieValue; + } + + return "00000000-0000-0000-0000-000000000000"; +} + +export function regenerateUserId() { + if (!isCookieSet(cookieNames.FUNNEL_USER_ID)) { + setCookieProperties( + { + [cookieNames.FUNNEL_USER_ID]: crypto.randomUUID(), + }, + { maxAge: cookieExpirations.FUNNEL_USER_ID } + ); + } +} + /* Gets value of skey cookie, returns 0 if not found. */ export function getSessionKeyValue() { - const cookieValue = getCookieValueByName(cookieNames.SESSION_KEY); + const cookieValue = getCookieValueByName(cookieNames.FUNNEL_SESSION_KEY); if (cookieValue) { return cookieValue; @@ -91,6 +123,17 @@ export function getSessionKeyValue() { return 0; } +export function setSessionKeyIfUnset(value) { + if (!isCookieSet(cookieNames.FUNNEL_SESSION_KEY)) { + setCookieProperties( + { + [cookieNames.FUNNEL_SESSION_KEY]: value, + }, + { maxAge: cookieExpirations.FUNNEL_SESSION_KEY } + ); + } +} + /* Gets value of skey cookie, returns 0 if not found. */ @@ -104,11 +147,24 @@ export function getSessionIdValue() { return "00000000-0000-0000-0000-000000000000"; } +export function setSessionIdIfUnset(value) { + if (!isCookieSet(cookieNames.SESSION_ID)) { + setCookieProperties( + { + [cookieNames.SESSION_ID]: value, + }, + { maxAge: cookieExpirations.SESSION_ID } + ); + } +} + /* Updates session ID cookie with new expiration date */ export function updateSessionIdCookie() { - createOrUpdateCookie(cookieNames.SESSION_ID, getSessionIdValue(), { maxAge: 60 * 30 }); + createOrUpdateCookie(cookieNames.SESSION_ID, getSessionIdValue(), { + maxAge: cookieExpirations.SESSION_ID, + }); } export function setCookieProperties( @@ -126,6 +182,34 @@ export function setCookieProperties( } } +export function isCookieSet(name) { + const val = getCookieValueByName(name); + + return !!val; +} + +export function areAllSessionCookiesSet() { + return ( + isCookieSet(cookieNames.SESSION_ID) && + isCookieSet(cookieNames.DXDEV) && + isCookieSet(cookieNames.FUNNEL_SESSION_KEY) && + isCookieSet(cookieNames.FUNNEL_USER_ID) + ); +} + +export function refreshSessionExpiration() { + refreshCookieExpiration(cookieNames.SESSION_ID, cookieExpirations.SESSION_ID); + refreshCookieExpiration(cookieNames.DXDEV, cookieExpirations.DXDEV); + refreshCookieExpiration(cookieNames.FUNNEL_USER_ID, cookieExpirations.FUNNEL_USER_ID); + refreshCookieExpiration(cookieNames.FUNNEL_SESSION_KEY, cookieExpirations.FUNNEL_SESSION_KEY); +} + +export function refreshCookieExpiration(name, expirationTime) { + if (isCookieSet(name)) { + createOrUpdateCookie(name, getCookieValueByName(name), { maxAge: expirationTime }); + } +} + /* =========================== = PRIVATE FUNCTIONS = diff --git a/src/helpers/unit-test-helper.js b/src/helpers/unit-test-helper.js index 467b38c2b..123dc9d40 100644 --- a/src/helpers/unit-test-helper.js +++ b/src/helpers/unit-test-helper.js @@ -100,7 +100,8 @@ export const cookies = { someOtherCookie: "{}", dxdev: "did=21b9b94a-ec23-42c1-aaac-e2ae4e4dbffe", sid: "cba0c3d1-3c1b-4305-bb56-31aa50f58e27", - skey: "12345", + [cookieNames.FUNNEL_SESSION_KEY]: "12345", + [cookieNames.FUNNEL_USER_ID]: "11aec5e8-92ba-4dc9-a8b6-179a916d8d7a", }; // Removes test cookies for testing cookie-helper and order-helper diff --git a/src/mixins/analytics-mixin.js b/src/mixins/analytics-mixin.js index 37615f68b..6ea36a903 100644 --- a/src/mixins/analytics-mixin.js +++ b/src/mixins/analytics-mixin.js @@ -1,9 +1,15 @@ import { storeActions } from "@/constants/store-actions"; import { - setCookieProperties, getDeviceIdValue, getSessionIdValue, getSessionKeyValue, + getUserIdValue, + regenerateDeviceId, + regenerateUserId, + refreshSessionExpiration, + areAllSessionCookiesSet, + setSessionIdIfUnset, + setSessionKeyIfUnset, } from "@/helpers/heritage-integration/cookie-helper"; import { queryStrings } from "@/constants/query-strings"; import { experimentSettings } from "@/constants/experiments"; @@ -15,7 +21,6 @@ import { GaEvents, ValueToLogTypes, } from "@/constants/analytics"; -import { cookieNames } from "@/constants/cookie-names"; import store from "@/store"; import baseMixin from "@/mixins/base-mixin"; @@ -27,10 +32,12 @@ export default { return getPageNameByQueryString(); }, - logPageView(pageEvent) { + async logPageView(pageEvent) { const currentPageName = getPageNameByQueryString(); + await this.validateSession(); + var payload = { - userId: getDeviceIdValue(), + userId: getUserIdValue(), sessionKey: getSessionKeyValue(), pageName: currentPageName, sessionId: getSessionIdValue(), @@ -42,14 +49,15 @@ export default { parentAccountNumber: store.getters.order.payment.parentAccountNumber, }; - baseMixin.methods.dispatchStoreAction(storeActions.LOG_PAGE_VIEW, payload, false); + await baseMixin.methods.dispatchStoreAction(storeActions.LOG_PAGE_VIEW, payload, false); }, - logCustomEvent(category, action, label, value) { + async logCustomEvent(category, action, label, value) { const currentPageName = getPageNameByQueryString(); + await this.validateSession(); var payload = { - userId: getDeviceIdValue(), + userId: getUserIdValue(), sessionKey: getSessionKeyValue(), pageName: currentPageName, sessionId: getSessionIdValue(), @@ -63,10 +71,14 @@ export default { parentAccountNumber: store.getters.order.payment.parentAccountNumber, }; - baseMixin.methods.dispatchStoreAction(storeActions.LOG_CUSTOM_EVENT, payload, false); + await baseMixin.methods.dispatchStoreAction( + storeActions.LOG_CUSTOM_EVENT, + payload, + false + ); }, - pushEventToGA(category, action, label, pushToLogApp = false, valueToLogType = null) { + async pushEventToGA(category, action, label, pushToLogApp = false, valueToLogType = null) { const currentPageName = getPageNameByQueryString(); const labelToLog = getValueToLog(label, valueToLogType); @@ -82,11 +94,11 @@ export default { pushToDataLayerIfDefined(eventToBePushed); if (pushToLogApp) { - this.logCustomEvent(category, action, labelToLog, undefined); + await this.logCustomEvent(category, action, labelToLog, undefined); } }, - pushPageViewToGA() { + async pushPageViewToGA() { const currentPageName = getPageNameByQueryString(); const pageViewEvent = { event: GaEvents.PAGE_VIEW_EVENT, @@ -96,7 +108,7 @@ export default { pushToDataLayerIfDefined(pageViewEvent); - this.logPageView(analyticsPageEvents.ENTRY); + await this.logPageView(analyticsPageEvents.ENTRY); }, pushExperimentsToDataLayer() { @@ -136,16 +148,22 @@ export default { }, async initSession() { - const sid = getSessionIdValue(); - const skey = getSessionKeyValue(); - const referrer = - applicationConfig.CURRENT_ENVIRONMENT != "Localhost" ? document.referrer : null; + regenerateDeviceId(); + regenerateUserId(); - var payload = { - userId: getDeviceIdValue(), - sessionId: sid, - userAgent: navigator.userAgent, - referrer: referrer, + const userId = getUserIdValue(); // cookieNames.FUNNEL_USER_ID + const deviceId = getDeviceIdValue(); // cookieNames.DXDEV + const sessionId = getSessionIdValue(); // cookieNames.SESSION_ID + const userAgent = navigator.userAgent; // navigator.userAgent + const refferer = + applicationConfig.CURRENT_ENVIRONMENT != "Localhost" ? document.referrer : null; // see above + + const payload = { + userId: userId, + deviceId: deviceId, + sessionId: sessionId, + userAgent: userAgent, + refferer: refferer, }; const response = await baseMixin.methods.dispatchStoreAction( @@ -155,30 +173,26 @@ export default { ); if (response?.data) { - if (response?.data.sessionKey && skey === 0) { - setCookieProperties( - { [cookieNames.SESSION_KEY]: response?.data.sessionKey }, - { - useDefaultFunnelCookieAttributes: false, - } - ); + if (response.data.sessionKey) { + setSessionKeyIfUnset(response.data.sessionKey); } - if (response?.data.sessionId && sid === "00000000-0000-0000-0000-000000000000") { - setCookieProperties( - { [cookieNames.SESSION_ID]: response?.data.sessionId }, - { - maxAge: 60 * 30, // 30 minutes - } - ); + + if (response.data.sessionId) { + setSessionIdIfUnset(response.data.sessionId); } } }, noSession() { - return ( - getSessionKeyValue() === 0 || - getSessionIdValue() === "00000000-0000-0000-0000-000000000000" - ); + return !areAllSessionCookiesSet(); + }, + + async validateSession() { + if (this.noSession()) { + await this.initSession(); + } + + refreshSessionExpiration(); }, removeParamsFromEndpoint(endpoint) { diff --git a/src/mixins/analytics-mixin.spec.js b/src/mixins/analytics-mixin.spec.js index 0426ca297..8f7cfceaa 100644 --- a/src/mixins/analytics-mixin.spec.js +++ b/src/mixins/analytics-mixin.spec.js @@ -11,8 +11,20 @@ import { } from "@/constants/analytics"; import store from "@/store"; +// Mock only the regenerate functions; window.crypto is not available in testing. +jest.mock("@/helpers/heritage-integration/cookie-helper", () => { + const originalModule = jest.requireActual("@/helpers/heritage-integration/cookie-helper"); + + return { + __esModule: true, + ...originalModule, + regenerateDeviceId: jest.fn(), + regenerateUserId: jest.fn(), + }; +}); + describe("analyticsMixin.js", () => { - test("logPageView: calls dispatch with type and payload", () => { + test("logPageView: calls dispatch with type and payload", async () => { const type = ""; const payload = {}; @@ -21,6 +33,9 @@ describe("analyticsMixin.js", () => { { actionName: storeActions.LOG_PAGE_VIEW, }, + { + actionName: storeActions.INITIALIZE_SESSION, + }, ], }; const mocks = setupMocksForJsFiles(mockData); @@ -31,27 +46,35 @@ describe("analyticsMixin.js", () => { setupCookies({ funnelCookieValue: JSON.stringify(testCookieValue) }); - analyticsMixin.methods.logPageView(type, payload); + await analyticsMixin.methods.logPageView(type, payload); expect(mocks.baseMixin.methods.dispatchStoreAction).toBeCalled(); }); - test("logCustomEvent: calls dispatch with type and payload", () => { + test("logCustomEvent: calls dispatch with type and payload", async () => { const mockData = { actionList: [ { actionName: storeActions.LOG_CUSTOM_EVENT, }, + { + actionName: storeActions.INITIALIZE_SESSION, + }, ], }; const mocks = setupMocksForJsFiles(mockData); - analyticsMixin.methods.logCustomEvent("someCat", "someAction", "someLabel", "someVal"); + await analyticsMixin.methods.logCustomEvent( + "someCat", + "someAction", + "someLabel", + "someVal" + ); expect(mocks.baseMixin.methods.dispatchStoreAction).toBeCalled(); }); - test("pushEventToGA, should call dataLayer push and logCustomEvent too", () => { + test("pushEventToGA, should call dataLayer push and logCustomEvent too", async () => { // Arrange window.dataLayer = []; const mockData = { @@ -59,6 +82,9 @@ describe("analyticsMixin.js", () => { { actionName: storeActions.LOG_CUSTOM_EVENT, }, + { + actionName: storeActions.INITIALIZE_SESSION, + }, ], }; const mocks = setupMocksForJsFiles(mockData); @@ -73,14 +99,14 @@ describe("analyticsMixin.js", () => { }); // Act - analyticsMixin.methods.pushEventToGA("category", "action", "label", true); + await analyticsMixin.methods.pushEventToGA("category", "action", "label", true); // Assert expect(mockDataLayer).toEqual(expect.arrayContaining(window.dataLayer)); expect(mocks.baseMixin.methods.dispatchStoreAction).toBeCalled(); }); - test("pushEventToGA, should call dataLayer push and ValueToLogTypes.LAST_5 only logs last 5 of label", () => { + test("pushEventToGA, should call dataLayer push and ValueToLogTypes.LAST_5 only logs last 5 of label", async () => { // Arrange window.dataLayer = []; var expectedDataLayer = []; @@ -93,8 +119,21 @@ describe("analyticsMixin.js", () => { path: "/fmg/?fmgPage=", }); + const mockData = { + actionList: [ + { + actionName: storeActions.LOG_CUSTOM_EVENT, + }, + { + actionName: storeActions.INITIALIZE_SESSION, + }, + ], + }; + + const mocks = setupMocksForJsFiles(mockData); + // Act - analyticsMixin.methods.pushEventToGA( + await analyticsMixin.methods.pushEventToGA( "category", "action", "1111122222333333", @@ -106,7 +145,7 @@ describe("analyticsMixin.js", () => { expect(expectedDataLayer).toEqual(expect.arrayContaining(window.dataLayer)); }); - test("pushEventToGA, should call dataLayer push and ValueToLogTypes.LAST_5 logs only the last 3 characters for a 3 character string", () => { + test("pushEventToGA, should call dataLayer push and ValueToLogTypes.LAST_5 logs only the last 3 characters for a 3 character string", async () => { // Arrange window.dataLayer = []; var expectedDataLayer = []; @@ -119,8 +158,21 @@ describe("analyticsMixin.js", () => { path: "/fmg/?fmgPage=", }); + const mockData = { + actionList: [ + { + actionName: storeActions.LOG_CUSTOM_EVENT, + }, + { + actionName: storeActions.INITIALIZE_SESSION, + }, + ], + }; + + const mocks = setupMocksForJsFiles(mockData); + // Act - analyticsMixin.methods.pushEventToGA( + await analyticsMixin.methods.pushEventToGA( "category", "action", "111", diff --git a/src/router/index.js b/src/router/index.js index e2188997a..7769d8a2d 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -38,11 +38,7 @@ const routes = [ async beforeEnter(to, from, next) { // If we have no query string, or we don't have the FmgPage query string. try { - if (analyticsMixin.methods.noSession()) { - await analyticsMixin.methods.initSession(); - } else { - updateSessionIdCookie(); - } + await analyticsMixin.methods.validateSession(); if (getFunnelCookie()?.SuppressConceptFunnel) { await navigateToHeritageFunnel({ shouldSaveSession: false }); diff --git a/src/store/index.js b/src/store/index.js index efaf5b428..55a2ba120 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1076,7 +1076,7 @@ export const actions = { sessionKey: sessionKey, sessionId: sessionId, pageName: pageName, - applicationName: applicationConfig.APPLICATION_NAME, + applicationName: applicationConfig.ANALYTICS_APPLICATION_NAME, action: action, event: event, shouldUseSessionId: shouldUseSessionId, @@ -1124,7 +1124,7 @@ export const actions = { sessionKey: sessionKey, sessionId: sessionId, pageName: pageName, - applicationName: applicationConfig.APPLICATION_NAME, + applicationName: applicationConfig.ANALYTICS_APPLICATION_NAME, category: category, action: action, label: label, @@ -1151,11 +1151,11 @@ export const actions = { } ); }, - initializeSession(context, { userId, sessionId, userAgent, referrer }) { + initializeSession(context, { userId, deviceId, sessionId, userAgent, referrer }) { var payload = { - applicationName: applicationConfig.APPLICATION_NAME, + applicationName: applicationConfig.ANALYTICS_APPLICATION_NAME, userId: userId, - deviceId: userId, + deviceId: deviceId, sessionId: sessionId, userAgent: userAgent, operatorId: "WEB",