Merge branch 'feature/IO-3586-Socket-Reconnect-Issues' into release/2026-02-27

This commit is contained in:
Dave
2026-03-02 10:43:42 -05:00
7 changed files with 258 additions and 52 deletions

7
.gitignore vendored
View File

@@ -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

View File

@@ -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.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"

View File

@@ -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(() => {
@@ -503,6 +525,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
</Col>
<DmsCustomerSelector
key={customerSelectorKey}
jobid={jobId}
job={data?.jobs_by_pk}
bodyshop={bodyshop}
@@ -550,21 +573,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
]}
/>
<Button onClick={() => setLogs([])}>Clear Logs</Button>
<Button
onClick={() => {
setLogs([]);
setResetAfterReconnect(true);
if (isWssMode(mode)) {
setActiveLogLevel(logLevel);
}
if (activeSocket) {
activeSocket.disconnect();
setTimeout(() => activeSocket.connect(), 100);
}
}}
>
Reconnect
</Button>
<Button onClick={handleReconnectClick}>Reconnect</Button>
</Space>
}
>

View File

@@ -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.
}
}

View File

@@ -180,22 +180,52 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
getTransactionType(jobid),
FortellisCacheEnums.txEnvelope
);
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
);
try {
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,

View File

@@ -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 {
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}`,

View File

@@ -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, {
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, {
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);