diff --git a/.gitignore b/.gitignore index 94a32f464..4b0d51183 100644 --- a/.gitignore +++ b/.gitignore @@ -136,6 +136,13 @@ server/job/test/fixtures !.github/copilot-instructions.md _reference/ragmate/.ragmate.env docker_data +/.cursorrules +/AGENTS.md +/AI_CONTEXT.md +/CLAUDE.md +/COPILOT.md +/GEMINI.md +/_reference/select-component-test-plan.md /.cursorrules /AGENTS.md diff --git a/client/src/contexts/SocketIO/socketProvider.jsx b/client/src/contexts/SocketIO/socketProvider.jsx index 75b80d62f..3539908b8 100644 --- a/client/src/contexts/SocketIO/socketProvider.jsx +++ b/client/src/contexts/SocketIO/socketProvider.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import SocketIO from "socket.io-client"; import { auth } from "../../firebase/firebase.utils"; import { store } from "../../redux/store"; @@ -18,6 +18,7 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js"; const LIMIT = INITIAL_NOTIFICATIONS; +const TOKEN_SYNC_INTERVAL_MS = 10 * 60 * 1000; /** * Socket Provider - Scenario Notifications / Web Socket related items @@ -30,6 +31,7 @@ const LIMIT = INITIAL_NOTIFICATIONS; */ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { const socketRef = useRef(null); + const tokenSyncIntervalRef = useRef(null); const [clientId, setClientId] = useState(null); const [isConnected, setIsConnected] = useState(false); const notification = useNotification(); @@ -147,6 +149,30 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { onError: (err) => console.error("MARK_ALL_NOTIFICATIONS_READ error:", err) }); + const reconnectSocket = useCallback( + async ({ forceRefreshToken = true } = {}) => { + const socketInstance = socketRef.current; + if (!socketInstance || !auth.currentUser || !bodyshop?.id) return false; + + try { + const token = await auth.currentUser.getIdToken(forceRefreshToken); + socketInstance.auth = { token, bodyshopId: bodyshop.id }; + + if (socketInstance.connected) { + socketInstance.emit("update-token", { token, bodyshopId: bodyshop.id }); + } + + socketInstance.disconnect(); + socketInstance.connect(); + return true; + } catch (error) { + console.error("Socket reconnect failed:", error?.message || error); + return false; + } + }, + [bodyshop?.id] + ); + useEffect(() => { const initializeSocket = async (token) => { if (!bodyshop?.id || socketRef.current) return; @@ -254,25 +280,60 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { } }; + const syncCurrentTokenToSocket = async () => { + try { + if (!auth.currentUser || !bodyshop?.id) return; + const token = await auth.currentUser.getIdToken(); + socketInstance.auth = { token, bodyshopId: bodyshop.id }; + socketInstance.emit("update-token", { token, bodyshopId: bodyshop.id }); + } catch (error) { + console.error("Failed to sync token to socket:", error?.message || error); + } + }; + + const forceRefreshAndSyncToken = async () => { + try { + if (!auth.currentUser || !bodyshop?.id) return; + const token = await auth.currentUser.getIdToken(true); + socketInstance.auth = { token, bodyshopId: bodyshop.id }; + socketInstance.emit("update-token", { token, bodyshopId: bodyshop.id }); + } catch (error) { + console.error("Failed to force-refresh token for socket:", error?.message || error); + } + }; + const handleConnect = () => { socketInstance.emit("join-bodyshop-room", bodyshop.id); + syncCurrentTokenToSocket(); setClientId(socketInstance.id); setIsConnected(true); store.dispatch(setWssStatus("connected")); }; const handleReconnect = () => { + forceRefreshAndSyncToken(); setIsConnected(true); store.dispatch(setWssStatus("connected")); }; + const handleTokenUpdated = ({ success, error }) => { + if (success) return; + const err = String(error || ""); + if (/stale token|id-token-expired/i.test(err)) { + forceRefreshAndSyncToken(); + } + }; + const handleConnectionError = (err) => { console.error("Socket connection error:", err); setIsConnected(false); - if (err.message.includes("auth/id-token-expired")) { + if (err?.message?.includes("auth/id-token-expired")) { console.warn("Token expired, refreshing..."); auth.currentUser?.getIdToken(true).then((newToken) => { - socketInstance.auth = { token: newToken }; + socketInstance.auth = { token: newToken, bodyshopId: bodyshop.id }; + if (socketInstance.connected) { + socketInstance.emit("update-token", { token: newToken, bodyshopId: bodyshop.id }); + } socketInstance.connect(); }); } else { @@ -513,10 +574,23 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { socketInstance.on("notification", handleNotification); socketInstance.on("sync-notification-read", handleSyncNotificationRead); socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead); + socketInstance.on("token-updated", handleTokenUpdated); + + if (tokenSyncIntervalRef.current) { + clearInterval(tokenSyncIntervalRef.current); + } + tokenSyncIntervalRef.current = setInterval(() => { + if (!socketInstance.connected) return; + syncCurrentTokenToSocket(); + }, TOKEN_SYNC_INTERVAL_MS); }; const unsubscribe = auth.onIdTokenChanged(async (user) => { if (!user) { + if (tokenSyncIntervalRef.current) { + clearInterval(tokenSyncIntervalRef.current); + tokenSyncIntervalRef.current = null; + } socketRef.current?.disconnect(); socketRef.current = null; setIsConnected(false); @@ -525,7 +599,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { const token = await user.getIdToken(); if (socketRef.current) { - socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id }); + socketRef.current.auth = { token, bodyshopId: bodyshop.id }; + if (socketRef.current.connected) { + socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id }); + } } else { initializeSocket(token).catch((err) => console.error("Something went wrong Initializing Sockets:", err?.message || "") @@ -535,6 +612,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { return () => { unsubscribe(); + if (tokenSyncIntervalRef.current) { + clearInterval(tokenSyncIntervalRef.current); + tokenSyncIntervalRef.current = null; + } if (socketRef.current) { socketRef.current.disconnect(); socketRef.current = null; @@ -549,6 +630,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { socket: socketRef.current, clientId, isConnected, + reconnectSocket, markNotificationRead, markAllNotificationsRead, scenarioNotificationsOn: Realtime_Notifications_UI?.treatment === "on" diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index 6f49cbb15..80edc5eeb 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -77,6 +77,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse const { t } = useTranslation(); const [resetAfterReconnect, setResetAfterReconnect] = useState(false); const [allocationsSummary, setAllocationsSummary] = useState(null); + const [reconnectNonce, setReconnectNonce] = useState(0); // Compute a single normalized mode and pick the proper socket const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none" @@ -114,7 +115,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse const notification = useNotification(); - const { socket: wsssocket } = useSocket(); + const { socket: wsssocket, reconnectSocket } = useSocket(); const activeSocket = useMemo(() => (isWssMode(mode) ? wsssocket : legacySocket), [mode, wsssocket]); const [isConnected, setIsConnected] = useState(!!activeSocket?.connected); @@ -178,6 +179,27 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse }`; const resetKey = useMemo(() => `${mode || "none"}-${jobId || "none"}`, [mode, jobId]); + const customerSelectorKey = useMemo(() => `${resetKey}-${reconnectNonce}`, [resetKey, reconnectNonce]); + + const handleReconnectClick = async () => { + setResetAfterReconnect(true); + setReconnectNonce((n) => n + 1); + + if (!activeSocket) return; + + if (isWssMode(mode)) { + setActiveLogLevel(logLevel); + const didReconnect = await reconnectSocket?.({ forceRefreshToken: true }); + if (!didReconnect) { + activeSocket.disconnect(); + setTimeout(() => activeSocket.connect(), 100); + } + return; + } + + activeSocket.disconnect(); + setTimeout(() => activeSocket.connect(), 100); + }; // 🔄 Hard reset of local + server-side DMS context when the page/job loads useEffect(() => { @@ -428,7 +450,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse // Check if Reynolds mode requires early RO const hasEarlyRO = !!(data.jobs_by_pk?.dms_id && data.jobs_by_pk?.dms_customer_id && data.jobs_by_pk?.dms_advisor_id); - + if (isRrMode && !hasEarlyRO) { return ( - + } > diff --git a/server/fortellis/fortellis-helpers.js b/server/fortellis/fortellis-helpers.js index 087dc3d16..affd2431e 100644 --- a/server/fortellis/fortellis-helpers.js +++ b/server/fortellis/fortellis-helpers.js @@ -86,6 +86,9 @@ async function FetchSubscriptions({ redisHelpers, socket, jobid, SubscriptionObj logRequest: false }); const SubscriptionMeta = subscriptions.data.subscriptions.find((s) => s.subscriptionId === SubscriptionID); + if (!SubscriptionMeta) { + throw new Error(`Subscription metadata not found for SubscriptionID: ${SubscriptionID}`); + } if (setSessionTransactionData) { await setSessionTransactionData( socket.id, @@ -102,11 +105,15 @@ async function FetchSubscriptions({ redisHelpers, socket, jobid, SubscriptionObj error: error.message, stack: error.stack }); + throw error; } } async function GetDepartmentId({ apiName, debug = false, SubscriptionMeta, overrideDepartmentId }) { if (!apiName) throw new Error("apiName not provided. Unable to get department without apiName."); + if (!SubscriptionMeta || !Array.isArray(SubscriptionMeta.apiDmsInfo)) { + throw new Error("Subscription metadata missing apiDmsInfo."); + } if (debug) { console.log("API Names & Departments "); console.log("==========="); @@ -118,9 +125,8 @@ async function GetDepartmentId({ apiName, debug = false, SubscriptionMeta, overr .find((info) => info.name === apiName)?.departments; //Departments are categorized by API name and have an array of departments. if (overrideDepartmentId) { - return departmentIds && departmentIds.find(d => d.id === overrideDepartmentId)?.id + return departmentIds && departmentIds.find((d) => d.id === overrideDepartmentId)?.id; } else { - return departmentIds && departmentIds[0] && departmentIds[0].id; //TODO: This makes the assumption that there is only 1 department. } } diff --git a/server/fortellis/fortellis.js b/server/fortellis/fortellis.js index 968dac09d..5a81d25f2 100644 --- a/server/fortellis/fortellis.js +++ b/server/fortellis/fortellis.js @@ -180,22 +180,52 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome getTransactionType(jobid), FortellisCacheEnums.txEnvelope ); - const DMSVid = await redisHelpers.getSessionTransactionData( - socket.id, - getTransactionType(JobData.id), - FortellisCacheEnums.DMSVid - ); + if (!JobData || !txEnvelope) { + const friendlyMessage = + "Fortellis export context was lost after reconnect. Click Post again to restart the Fortellis flow."; + CreateFortellisLogEvent(socket, "WARN", friendlyMessage, { + jobid, + hasJobData: !!JobData, + hasTxEnvelope: !!txEnvelope + }); + socket.emit("export-failed", { + title: "Fortellis", + severity: "warning", + errorCode: "FORTELLIS_CONTEXT_MISSING", + friendlyMessage + }); + return; + } + try { + const DMSVid = await redisHelpers.getSessionTransactionData( + socket.id, + getTransactionType(JobData.id), + FortellisCacheEnums.DMSVid + ); + if (!DMSVid) { + const friendlyMessage = + "Fortellis vehicle context is missing after reconnect. Click Post again to restart the Fortellis flow."; + CreateFortellisLogEvent(socket, "WARN", friendlyMessage, { + jobid, + hasDMSVid: !!DMSVid + }); + socket.emit("export-failed", { + title: "Fortellis", + severity: "warning", + errorCode: "FORTELLIS_CONTEXT_MISSING", + friendlyMessage + }); + return; + } + let DMSCust; if (selectedCustomerId) { CreateFortellisLogEvent(socket, "DEBUG", `{3.1} Querying the Customer using Customer ID: ${selectedCustomerId}`); //Get cust list from Redis. Return the item - const DMSCustList = await getSessionTransactionData( - socket.id, - getTransactionType(jobid), - FortellisCacheEnums.DMSCustList - ); + const DMSCustList = + (await getSessionTransactionData(socket.id, getTransactionType(jobid), FortellisCacheEnums.DMSCustList)) || []; const existingCustomerInDMSCustList = DMSCustList.find((c) => c.customerId === selectedCustomerId); DMSCust = existingCustomerInDMSCustList || { customerId: selectedCustomerId //This is the fall back in case it is the generic customer. @@ -306,7 +336,7 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome //There was something wrong. Throw an error to trigger clean up. //throw new Error("Error posting DMS Batch Transaction"); } - } catch (error) { + } catch { //Clean up the transaction and insert a faild error code // //Get the error code CreateFortellisLogEvent(socket, "DEBUG", `{6.1} Getting errors for Transaction ID ${DMSTransHeader.transID}`); @@ -336,6 +366,12 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome stack: error.stack, data: error.errorData }); + socket.emit("export-failed", { + title: "Fortellis", + severity: "error", + error: error.message, + friendlyMessage: "Fortellis export failed. Please click Post again to retry." + }); await InsertFailedExportLog({ socket, JobData, diff --git a/server/utils/redisHelpers.js b/server/utils/redisHelpers.js index a317e7e7a..0ca8b599d 100644 --- a/server/utils/redisHelpers.js +++ b/server/utils/redisHelpers.js @@ -68,12 +68,33 @@ const fetchBodyshopFromDB = async (bodyshopId, logger) => { * @param logger */ const applyRedisHelpers = ({ pubClient, app, logger }) => { + const toRedisJson = (value) => JSON.stringify(value === undefined ? null : value); + // Store session data in Redis const setSessionData = async (socketId, key, value, ttl) => { try { - await pubClient.hset(`socket:${socketId}`, key, JSON.stringify(value)); // Use Redis pubClient + const sessionKey = `socket:${socketId}`; + + // Supports both forms: + // 1) setSessionData(socketId, "field", value, ttl) + // 2) setSessionData(socketId, { fieldA: valueA, fieldB: valueB }, ttl) + if (key && typeof key === "object" && !Array.isArray(key)) { + const entries = Object.entries(key).flatMap(([field, fieldValue]) => [field, toRedisJson(fieldValue)]); + + if (entries.length > 0) { + await pubClient.hset(sessionKey, ...entries); + } + + const objectTtl = typeof value === "number" ? value : typeof ttl === "number" ? ttl : null; + if (objectTtl) { + await pubClient.expire(sessionKey, objectTtl); + } + return; + } + + await pubClient.hset(sessionKey, key, toRedisJson(value)); // Use Redis pubClient if (ttl && typeof ttl === "number") { - await pubClient.expire(`socket:${socketId}`, ttl); + await pubClient.expire(sessionKey, ttl); } } catch (error) { logger.log(`Error Setting Session Data for socket ${socketId}: ${error}`, "ERROR", "redis"); @@ -88,7 +109,26 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => { */ const getSessionData = async (socketId, key) => { try { - const data = await pubClient.hget(`socket:${socketId}`, key); + const sessionKey = `socket:${socketId}`; + + // Supports: + // 1) getSessionData(socketId, "field") -> parsed field value + // 2) getSessionData(socketId) -> parsed object of all fields + if (typeof key === "undefined") { + const raw = await pubClient.hgetall(sessionKey); + if (!raw || Object.keys(raw).length === 0) return null; + + return Object.entries(raw).reduce((acc, [field, rawValue]) => { + try { + acc[field] = JSON.parse(rawValue); + } catch { + acc[field] = rawValue; + } + return acc; + }, {}); + } + + const data = await pubClient.hget(sessionKey, key); return data ? JSON.parse(data) : null; } catch (error) { logger.log(`Error Getting Session Data for socket ${socketId}: ${error}`, "ERROR", "redis"); @@ -106,7 +146,7 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => { */ const setSessionTransactionData = async (socketId, transactionType, key, value, ttl) => { try { - await pubClient.hset(getSocketTransactionkey({ socketId, transactionType }), key, JSON.stringify(value)); // Use Redis pubClient + await pubClient.hset(getSocketTransactionkey({ socketId, transactionType }), key, toRedisJson(value)); // Use Redis pubClient if (ttl && typeof ttl === "number") { await pubClient.expire(getSocketTransactionkey({ socketId, transactionType }), ttl); } @@ -160,7 +200,17 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => { */ const clearSessionTransactionData = async (socketId, transactionType) => { try { - await pubClient.del(getSocketTransactionkey({ socketId, transactionType })); + if (transactionType) { + await pubClient.del(getSocketTransactionkey({ socketId, transactionType })); + return; + } + + // If no transactionType is provided, clear all transaction namespaces for this socket. + const pattern = getSocketTransactionkey({ socketId, transactionType: "*" }); + const keys = await pubClient.keys(pattern); + if (Array.isArray(keys) && keys.length > 0) { + await pubClient.del(...keys); + } } catch (error) { logger.log( `Error Clearing Session Transaction Data for socket ${socketId}:${transactionType}: ${error}`, diff --git a/server/web-sockets/redisSocketEvents.js b/server/web-sockets/redisSocketEvents.js index c4af2fb55..1d8effeff 100644 --- a/server/web-sockets/redisSocketEvents.js +++ b/server/web-sockets/redisSocketEvents.js @@ -4,11 +4,14 @@ const { FortellisJobExport, FortellisSelectedCustomer } = require("../fortellis/ const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default; const registerRREvents = require("../rr/rr-register-socket-events"); +const SOCKET_SESSION_TTL_SECONDS = 60 * 60 * 24; + const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => { // Destructure helpers locally, but keep full objects available for downstream modules const { setSessionData, getSessionData, + clearSessionData, addUserSocketMapping, removeUserSocketMapping, refreshUserSocketTTL, @@ -51,12 +54,16 @@ const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => { } // NEW: seed a base session for this socket so downstream handlers can read it - await setSessionData(socket.id, { - bodyshopId, - email: user.email, - uid: user.user_id || user.uid, - seededAt: Date.now() - }); + await setSessionData( + socket.id, + { + bodyshopId, + email: user.email, + uid: user.user_id || user.uid, + seededAt: Date.now() + }, + SOCKET_SESSION_TTL_SECONDS + ); await addUserSocketMapping(user.email, socket.id, bodyshopId); next(); @@ -126,14 +133,18 @@ const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => { } // NEW: refresh (or create) the base session with the latest info - await setSessionData(socket.id, { - bodyshopId, - email: user.email, - uid: user.user_id || user.uid, - refreshedAt: Date.now() - }); + await setSessionData( + socket.id, + { + bodyshopId, + email: user.email, + uid: user.user_id || user.uid, + refreshedAt: Date.now() + }, + SOCKET_SESSION_TTL_SECONDS + ); - await refreshUserSocketTTL(user.email, bodyshopId); + await refreshUserSocketTTL(user.email); socket.emit("token-updated", { success: true }); } catch (error) { if (error.code === "auth/id-token-expired") { @@ -189,6 +200,11 @@ const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => { if (socket.user?.email) { await removeUserSocketMapping(socket.user.email, socket.id); } + try { + await clearSessionData(socket.id); + } catch { + // + } // Optional: clear transactional session try { await clearSessionTransactionData(socket.id);