From 3b647dfd3796502177e0848b4ec5233835bd8caa Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 25 Sep 2024 17:44:53 -0400 Subject: [PATCH 1/9] IO-2924-Refactor-Production-board-to-use-Socket-Provider: rough in split logic / production-board-kanban.container.jsx logic Signed-off-by: Dave Richer --- .../production-board-kanban.container.jsx | 89 ++++++++++++++++--- 1 file changed, 75 insertions(+), 14 deletions(-) diff --git a/client/src/components/production-board-kanban/production-board-kanban.container.jsx b/client/src/components/production-board-kanban/production-board-kanban.container.jsx index 45bfb353c..d6a04fedd 100644 --- a/client/src/components/production-board-kanban/production-board-kanban.container.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban.container.jsx @@ -1,15 +1,18 @@ -import React, { useEffect, useMemo, useRef } from "react"; -import { useQuery, useSubscription } from "@apollo/client"; +import React, { useEffect, useMemo, useRef, useContext } from "react"; +import { useQuery, useSubscription, useApolloClient, gql } from "@apollo/client"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { QUERY_JOBS_IN_PRODUCTION, SUBSCRIPTION_JOBS_IN_PRODUCTION, - SUBSCRIPTION_JOBS_IN_PRODUCTION_VIEW + SUBSCRIPTION_JOBS_IN_PRODUCTION_VIEW, + GET_JOB_BY_PK } from "../../graphql/jobs.queries"; import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import ProductionBoardKanbanComponent from "./production-board-kanban.component"; +import { useSplitTreatments } from "@splitsoftware/splitio-react"; +import SocketContext from "../../contexts/SocketIO/socketContext.jsx"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -17,7 +20,17 @@ const mapStateToProps = createStructuredSelector({ }); function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionType = "direct" }) { - const fired = useRef(false); // useRef to keep track of whether the subscription fired + const fired = useRef(false); + const client = useApolloClient(); + const { socket } = useContext(SocketContext); // Get the socket from context + + const { + treatments: { Websocket_Production } + } = useSplitTreatments({ + attributes: {}, + names: ["Websocket_Production"], + splitKey: bodyshop && bodyshop.imexshopid + }); const combinedStatuses = useMemo( () => [ @@ -34,9 +47,12 @@ function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionTyp onError: (error) => console.error(`Error fetching jobs in production: ${error.message}`) }); + const subscriptionEnabled = Websocket_Production?.treatment === "on"; + const { data: updatedJobs } = useSubscription( subscriptionType === "view" ? SUBSCRIPTION_JOBS_IN_PRODUCTION_VIEW : SUBSCRIPTION_JOBS_IN_PRODUCTION, { + skip: !subscriptionEnabled, onError: (error) => console.error(`Error subscribing to jobs in production: ${error.message}`) } ); @@ -46,22 +62,67 @@ function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionTyp onError: (error) => console.error(`Error fetching Kanban settings: ${error.message}`) }); - // const currentReducerData = useSelector((state) => (state.trello.lanes ? state.trello : {})); - useEffect(() => { - if (!updatedJobs) { + if (subscriptionEnabled) { + if (!updatedJobs) { + return; + } + if (!fired.current) { + fired.current = true; + return; + } + refetch().catch((err) => console.error(`Error re-fetching jobs in production: ${err.message}`)); + } + }, [updatedJobs, refetch, subscriptionEnabled]); + + // Socket.IO implementation for users with Split treatment "off" + useEffect(() => { + if (subscriptionEnabled || !socket || !bodyshop || !bodyshop.id) { return; } - if (!fired.current) { - fired.current = true; - return; - } - refetch().catch((err) => console.error(`Error re-fetching jobs in production: ${err.message}`)); - }, [updatedJobs, refetch]); + + const handleJobChanged = async (jobChangedData) => { + const jobId = jobChangedData.jobId; + const existingJob = data?.jobs.find((job) => job.id === jobId); + + if (existingJob) { + try { + const { data: jobData } = await client.query({ + query: GET_JOB_BY_PK, + variables: { id: jobId }, + fetchPolicy: "network-only" + }); + + client.writeFragment({ + id: client.cache.identify({ __typename: "Job", id: jobId }), + fragment: gql` + fragment UpdatedJob on Job { + id + status + updatedAt + # ... include other fields you need to update + } + `, + data: jobData.job + }); + } catch (error) { + console.error(`Error fetching job ${jobId}: ${error.message}`); + } + } + }; + + // Listen for 'job-changed' events + socket.on("job-changed", handleJobChanged); + + // Clean up on unmount or when dependencies change + return () => { + socket.off("job-changed", handleJobChanged); + }; + }, [subscriptionEnabled, socket, bodyshop, data, client]); const filteredAssociationSettings = useMemo(() => { return associationSettings?.associations[0] || null; - }, [associationSettings]); + }, [associationSettings?.associations]); return ( Date: Wed, 25 Sep 2024 21:05:27 -0400 Subject: [PATCH 2/9] IO-2924-Refactor-Production-board-to-use-Socket-Provider: rough in backend logic Signed-off-by: Dave Richer --- server/job/job-updated.js | 32 ++++++++++++++++++++++++++++++++ server/job/job.js | 1 + server/routes/jobRoutes.js | 3 ++- 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 server/job/job-updated.js diff --git a/server/job/job-updated.js b/server/job/job-updated.js new file mode 100644 index 000000000..87ed23640 --- /dev/null +++ b/server/job/job-updated.js @@ -0,0 +1,32 @@ +const { isObject } = require("lodash"); + +const jobUpdated = async (req, res) => { + const { ioRedis, logger } = req; + + if (!req?.body?.event?.data?.new || !isObject(req?.body?.event?.data?.new)) { + logger.log("job-update-error", "ERROR", req.user?.email, null, { + message: `Malformed Job Update request sent from Hasura`, + body: req?.body + }); + + return res.json({ + status: "error", + message: `Malformed Job Update request sent from Hasura` + }); + } + + logger.log("job-update", "INFO", req.user?.email, null, { + message: `Job updated event received from Hasura`, + jobid: req?.body?.event?.data?.new?.id + }); + + const updatedJob = req.body.event.data.new; + const bodyshopID = updatedJob.shopid; + + // Emit the job-updated event only to the room corresponding to the bodyshop + ioRedis.to(bodyshopID).emit("job-updated", updatedJob); + + return res.json({ message: "Job updated and event emitted" }); +}; + +module.exports = jobUpdated; diff --git a/server/job/job.js b/server/job/job.js index 0dc54c041..dc8640a98 100644 --- a/server/job/job.js +++ b/server/job/job.js @@ -14,3 +14,4 @@ exports.costing = require("./job-costing").JobCosting; exports.costingmulti = require("./job-costing").JobCostingMulti; exports.statustransition = require("./job-status-transition").statustransition; exports.lifecycle = require("./job-lifecycle"); +exports.jobUpdated = require("./job-updated"); diff --git a/server/routes/jobRoutes.js b/server/routes/jobRoutes.js index 1b655aa9f..6a83271dd 100644 --- a/server/routes/jobRoutes.js +++ b/server/routes/jobRoutes.js @@ -5,7 +5,7 @@ const ppc = require("../ccc/partspricechange"); const { partsScan } = require("../parts-scan/parts-scan"); const eventAuthorizationMiddleware = require("../middleware/eventAuthorizationMIddleware"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); -const { totals, statustransition, totalsSsu, costing, lifecycle, costingmulti } = require("../job/job"); +const { totals, statustransition, totalsSsu, costing, lifecycle, costingmulti, jobUpdated } = require("../job/job"); const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware"); router.post("/totals", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, totals); @@ -16,5 +16,6 @@ router.post("/lifecycle", validateFirebaseIdTokenMiddleware, withUserGraphQLClie router.post("/costingmulti", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, costingmulti); router.post("/partsscan", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, partsScan); router.post("/ppc", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, ppc.generatePpc); +router.post("/job-updated", eventAuthorizationMiddleware, jobUpdated); module.exports = router; From c661fce8f114af1a94a1a63a52c9218b130b0846 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 25 Sep 2024 21:34:43 -0400 Subject: [PATCH 3/9] IO-2924-Refactor-Production-board-to-use-Socket-Provider: Finalize Signed-off-by: Dave Richer --- .../production-board-kanban.container.jsx | 47 ++++++++++++------- server/job/job-updated.js | 7 ++- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/client/src/components/production-board-kanban/production-board-kanban.container.jsx b/client/src/components/production-board-kanban/production-board-kanban.container.jsx index d6a04fedd..31220a9bd 100644 --- a/client/src/components/production-board-kanban/production-board-kanban.container.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban.container.jsx @@ -81,11 +81,31 @@ function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionTyp return; } - const handleJobChanged = async (jobChangedData) => { - const jobId = jobChangedData.jobId; - const existingJob = data?.jobs.find((job) => job.id === jobId); + const handleJobUpdates = async (jobChangedData) => { + const jobId = jobChangedData.id; + + // Access the existing cache for QUERY_JOBS_IN_PRODUCTION + const existingJobsCache = client.readQuery({ + query: QUERY_JOBS_IN_PRODUCTION + }); + + const existingJobs = existingJobsCache?.jobs || []; + + // Check if the job already exists in the cached jobs + const existingJob = existingJobs.find((job) => job.id === jobId); if (existingJob) { + // If the job exists, we update the cache without making any additional queries + client.writeQuery({ + query: QUERY_JOBS_IN_PRODUCTION, + data: { + jobs: existingJobs.map((job) => + job.id === jobId ? { ...existingJob, ...jobChangedData, __typename: "Job" } : job + ) + } + }); + } else { + // If the job doesn't exist, fetch it from the server and then add it to the cache try { const { data: jobData } = await client.query({ query: GET_JOB_BY_PK, @@ -93,17 +113,12 @@ function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionTyp fetchPolicy: "network-only" }); - client.writeFragment({ - id: client.cache.identify({ __typename: "Job", id: jobId }), - fragment: gql` - fragment UpdatedJob on Job { - id - status - updatedAt - # ... include other fields you need to update - } - `, - data: jobData.job + // Add the job to the existing cached jobs + client.writeQuery({ + query: QUERY_JOBS_IN_PRODUCTION, + data: { + jobs: [...existingJobs, { ...jobData.job, __typename: "Job" }] + } }); } catch (error) { console.error(`Error fetching job ${jobId}: ${error.message}`); @@ -112,11 +127,11 @@ function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionTyp }; // Listen for 'job-changed' events - socket.on("job-changed", handleJobChanged); + socket.on("job-updated", handleJobUpdates); // Clean up on unmount or when dependencies change return () => { - socket.off("job-changed", handleJobChanged); + socket.off("job-updated", handleJobUpdates); }; }, [subscriptionEnabled, socket, bodyshop, data, client]); diff --git a/server/job/job-updated.js b/server/job/job-updated.js index 87ed23640..61667e9ea 100644 --- a/server/job/job-updated.js +++ b/server/job/job-updated.js @@ -24,7 +24,12 @@ const jobUpdated = async (req, res) => { const bodyshopID = updatedJob.shopid; // Emit the job-updated event only to the room corresponding to the bodyshop - ioRedis.to(bodyshopID).emit("job-updated", updatedJob); + + // Note: We are only sending the ID, because that is all that is needed + // What we should do is prevent hasura from sending anything more than it + // but the rewrite templates are currently borked. + + ioRedis.to(bodyshopID).emit("job-updated", { id: updatedJob.id }); return res.json({ message: "Job updated and event emitted" }); }; From 328a64eb90dcccf01562c04cb1c45add063040d9 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 25 Sep 2024 21:46:39 -0400 Subject: [PATCH 4/9] IO-2924-Refactor-Production-board-to-use-Socket-Provider: Finalize Signed-off-by: Dave Richer --- server/job/job-updated.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/server/job/job-updated.js b/server/job/job-updated.js index 61667e9ea..a6b527608 100644 --- a/server/job/job-updated.js +++ b/server/job/job-updated.js @@ -25,11 +25,7 @@ const jobUpdated = async (req, res) => { // Emit the job-updated event only to the room corresponding to the bodyshop - // Note: We are only sending the ID, because that is all that is needed - // What we should do is prevent hasura from sending anything more than it - // but the rewrite templates are currently borked. - - ioRedis.to(bodyshopID).emit("job-updated", { id: updatedJob.id }); + ioRedis.to(bodyshopID).emit("job-updated", updatedJob); return res.json({ message: "Job updated and event emitted" }); }; From 932f572fb50ba94d0a5bcd86a62657c21f7bb594 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 26 Sep 2024 10:56:48 -0400 Subject: [PATCH 5/9] IO-2924-Refactor-Production-board-to-use-Socket-Provider: Finalize Signed-off-by: Dave Richer --- .../production-board-kanban.container.jsx | 8 +- client/src/contexts/SocketIO/useSocket.js | 128 +++++++++--------- server.js | 4 +- server/ioevent/ioevent.js | 8 +- server/job/job-updated.js | 5 +- server/utils/ioHelpers.js | 17 +++ server/web-sockets/redisSocketEvents.js | 46 +++++-- 7 files changed, 127 insertions(+), 89 deletions(-) create mode 100644 server/utils/ioHelpers.js diff --git a/client/src/components/production-board-kanban/production-board-kanban.container.jsx b/client/src/components/production-board-kanban/production-board-kanban.container.jsx index 31220a9bd..6182b3ffb 100644 --- a/client/src/components/production-board-kanban/production-board-kanban.container.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban.container.jsx @@ -1,12 +1,12 @@ -import React, { useEffect, useMemo, useRef, useContext } from "react"; -import { useQuery, useSubscription, useApolloClient, gql } from "@apollo/client"; +import React, { useContext, useEffect, useMemo, useRef } from "react"; +import { useApolloClient, useQuery, useSubscription } from "@apollo/client"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { + GET_JOB_BY_PK, QUERY_JOBS_IN_PRODUCTION, SUBSCRIPTION_JOBS_IN_PRODUCTION, - SUBSCRIPTION_JOBS_IN_PRODUCTION_VIEW, - GET_JOB_BY_PK + SUBSCRIPTION_JOBS_IN_PRODUCTION_VIEW } from "../../graphql/jobs.queries"; import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; diff --git a/client/src/contexts/SocketIO/useSocket.js b/client/src/contexts/SocketIO/useSocket.js index c5688cf3c..42385bfb7 100644 --- a/client/src/contexts/SocketIO/useSocket.js +++ b/client/src/contexts/SocketIO/useSocket.js @@ -1,83 +1,83 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import SocketIO from "socket.io-client"; import { auth } from "../../firebase/firebase.utils"; const useSocket = (bodyshop) => { - const [socket, setSocket] = useState(null); + const socketRef = useRef(null); const [clientId, setClientId] = useState(null); - const [token, setToken] = useState(null); useEffect(() => { - // Listener for token changes const unsubscribe = auth.onIdTokenChanged(async (user) => { if (user) { const newToken = await user.getIdToken(); - setToken(newToken); + + if (socketRef.current) { + // Send new token to server + socketRef.current.emit("update-token", newToken); + } else if (bodyshop && bodyshop.id) { + // Initialize the socket + const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : ""; + + const socketInstance = SocketIO(endpoint, { + path: "/wss", + withCredentials: true, + auth: { token: newToken }, + reconnectionAttempts: Infinity, + reconnectionDelay: 2000, + reconnectionDelayMax: 10000 + }); + + socketRef.current = socketInstance; + + const handleBodyshopMessage = (message) => { + if (!import.meta.env.DEV) return; + console.log(`Received message for bodyshop ${bodyshop.id}:`, message); + }; + + const handleConnect = () => { + console.log("Socket connected:", socketInstance.id); + socketInstance.emit("join-bodyshop-room", bodyshop.id); + setClientId(socketInstance.id); + }; + + const handleReconnect = (attempt) => { + console.log(`Socket reconnected after ${attempt} attempts`); + }; + + const handleConnectionError = (err) => { + console.error("Socket connection error:", err); + }; + + const handleDisconnect = () => { + console.log("Socket disconnected"); + }; + + socketInstance.on("connect", handleConnect); + socketInstance.on("reconnect", handleReconnect); + socketInstance.on("connect_error", handleConnectionError); + socketInstance.on("disconnect", handleDisconnect); + socketInstance.on("bodyshop-message", handleBodyshopMessage); + } } else { - setToken(null); + // User is not authenticated + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + } } }); // Clean up the listener on unmount - return () => unsubscribe(); - }, []); + return () => { + unsubscribe(); + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + } + }; + }, [bodyshop.id]); - useEffect(() => { - if (bodyshop && bodyshop.id && token) { - const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : ""; - - const socketInstance = SocketIO(endpoint, { - path: "/wss", - withCredentials: true, - auth: { token }, // Use the current token - reconnectionAttempts: Infinity, - reconnectionDelay: 2000, - reconnectionDelayMax: 10000 - }); - - setSocket(socketInstance); - - const handleBodyshopMessage = (message) => { - console.log(`Received message for bodyshop ${bodyshop.id}:`, message); - }; - - const handleConnect = () => { - console.log("Socket connected:", socketInstance.id); - socketInstance.emit("join-bodyshop-room", bodyshop.id); - setClientId(socketInstance.id); - }; - - const handleReconnect = (attempt) => { - console.log(`Socket reconnected after ${attempt} attempts`); - }; - - const handleConnectionError = (err) => { - console.error("Socket connection error:", err); - }; - - const handleDisconnect = () => { - console.log("Socket disconnected"); - }; - - socketInstance.on("connect", handleConnect); - socketInstance.on("reconnect", handleReconnect); - socketInstance.on("connect_error", handleConnectionError); - socketInstance.on("disconnect", handleDisconnect); - socketInstance.on("bodyshop-message", handleBodyshopMessage); - - return () => { - socketInstance.emit("leave-bodyshop-room", bodyshop.id); - socketInstance.off("connect", handleConnect); - socketInstance.off("reconnect", handleReconnect); - socketInstance.off("connect_error", handleConnectionError); - socketInstance.off("disconnect", handleDisconnect); - socketInstance.off("bodyshop-message", handleBodyshopMessage); - socketInstance.disconnect(); - }; - } - }, [bodyshop, token]); // Include 'token' in dependencies to re-run effect when it changes - - return { socket, clientId }; + return { socket: socketRef.current, clientId }; }; export default useSocket; diff --git a/server.js b/server.js index e11fa31a7..bbf843ab2 100644 --- a/server.js +++ b/server.js @@ -14,6 +14,7 @@ const { instrument, RedisStore } = require("@socket.io/admin-ui"); const { isString, isEmpty } = require("lodash"); const applyRedisHelpers = require("./server/utils/redisHelpers"); +const applyIOHelpers = require("./server/utils/ioHelpers"); // Load environment variables require("dotenv").config({ @@ -192,13 +193,14 @@ const main = async () => { const { pubClient, ioRedis } = await applySocketIO(server, app); const api = applyRedisHelpers(pubClient, app); + const ioHelpers = applyIOHelpers(app, api, ioRedis, logger); // Legacy Socket Events require("./server/web-sockets/web-socket"); applyMiddleware(app); applyRoutes(app); - redisSocketEvents(ioRedis, api); + redisSocketEvents(ioRedis, api, ioHelpers); try { await server.listen(port); diff --git a/server/ioevent/ioevent.js b/server/ioevent/ioevent.js index b53159918..36950b84e 100644 --- a/server/ioevent/ioevent.js +++ b/server/ioevent/ioevent.js @@ -11,7 +11,10 @@ require("dotenv").config({ exports.default = async (req, res) => { const { useremail, bodyshopid, operationName, variables, env, time, dbevent, user } = req.body; - const { ioRedis } = req; + const { + ioRedis, + ioHelpers: { getBodyshopRoom } + } = req; try { await client.request(queries.INSERT_IOEVENT, { event: { @@ -24,8 +27,7 @@ exports.default = async (req, res) => { useremail } }); - - ioRedis.to(bodyshopid).emit("bodyshop-message", { + ioRedis.to(getBodyshopRoom(bodyshopid)).emit("bodyshop-message", { operationName, useremail }); diff --git a/server/job/job-updated.js b/server/job/job-updated.js index a6b527608..bec619837 100644 --- a/server/job/job-updated.js +++ b/server/job/job-updated.js @@ -1,7 +1,7 @@ const { isObject } = require("lodash"); const jobUpdated = async (req, res) => { - const { ioRedis, logger } = req; + const { ioRedis, logger, ioHelpers } = req; if (!req?.body?.event?.data?.new || !isObject(req?.body?.event?.data?.new)) { logger.log("job-update-error", "ERROR", req.user?.email, null, { @@ -24,8 +24,7 @@ const jobUpdated = async (req, res) => { const bodyshopID = updatedJob.shopid; // Emit the job-updated event only to the room corresponding to the bodyshop - - ioRedis.to(bodyshopID).emit("job-updated", updatedJob); + ioRedis.to(ioHelpers.getBodyshopRoom(bodyshopID)).emit("job-updated", updatedJob); return res.json({ message: "Job updated and event emitted" }); }; diff --git a/server/utils/ioHelpers.js b/server/utils/ioHelpers.js new file mode 100644 index 000000000..7ef9098d9 --- /dev/null +++ b/server/utils/ioHelpers.js @@ -0,0 +1,17 @@ +const applyIOHelpers = (app, api, io, logger) => { + const getBodyshopRoom = (bodyshopID) => `broadcast-room-${bodyshopID}`; + + const ioHelpersAPI = { + getBodyshopRoom + }; + + // Helper middleware + app.use((req, res, next) => { + req.ioHelpers = ioHelpersAPI; + next(); + }); + + return ioHelpersAPI; +}; + +module.exports = applyIOHelpers; diff --git a/server/web-sockets/redisSocketEvents.js b/server/web-sockets/redisSocketEvents.js index 516be1fe4..798debd35 100644 --- a/server/web-sockets/redisSocketEvents.js +++ b/server/web-sockets/redisSocketEvents.js @@ -11,32 +11,50 @@ function createLogEvent(socket, level, message) { logger.log("ioredis-log-event", level, socket.user.email, null, { wsmessage: message }); } -const redisSocketEvents = (io, { addUserToRoom, getUsersInRoom, removeUserFromRoom }) => { +const registerUpdateEvents = (socket) => { + socket.on("update-token", async (newToken) => { + try { + socket.user = await admin.auth().verifyIdToken(newToken); + createLogEvent(socket, "INFO", "Token updated successfully"); + socket.emit("token-updated", { success: true }); + } catch (error) { + createLogEvent(socket, "ERROR", `Token update failed: ${error.message}`); + socket.emit("token-updated", { success: false, error: error.message }); + // Optionally disconnect the socket if token verification fails + socket.disconnect(); + } + }); +}; + +const redisSocketEvents = (io, { addUserToRoom, getUsersInRoom, removeUserFromRoom }, { getBodyshopRoom }) => { // Room management and broadcasting events function registerRoomAndBroadcastEvents(socket) { socket.on("join-bodyshop-room", async (bodyshopUUID) => { - socket.join(bodyshopUUID); - await addUserToRoom(bodyshopUUID, { uid: socket.user.uid, email: socket.user.email }); + const room = getBodyshopRoom(bodyshopUUID); + socket.join(room); + await addUserToRoom(room, { uid: socket.user.uid, email: socket.user.email }); createLogEvent(socket, "DEBUG", `Client joined bodyshop room: ${bodyshopUUID}`); // Notify all users in the room about the updated user list const usersInRoom = await getUsersInRoom(bodyshopUUID); - io.to(bodyshopUUID).emit("room-users-updated", usersInRoom); + io.to(room).emit("room-users-updated", usersInRoom); }); socket.on("leave-bodyshop-room", async (bodyshopUUID) => { - socket.leave(bodyshopUUID); - createLogEvent(socket, "DEBUG", `Client left bodyshop room: ${bodyshopUUID}`); + const room = getBodyshopRoom(bodyshopUUID); + socket.leave(room); + createLogEvent(socket, "DEBUG", `Client left bodyshop room: ${room}`); }); socket.on("get-room-users", async (bodyshopUUID, callback) => { - const usersInRoom = await getUsersInRoom(bodyshopUUID); + const usersInRoom = await getUsersInRoom(getBodyshopRoom(bodyshopUUID)); callback(usersInRoom); }); socket.on("broadcast-to-bodyshop", async (bodyshopUUID, message) => { - io.to(bodyshopUUID).emit("bodyshop-message", message); - createLogEvent(socket, "INFO", `Broadcast message to bodyshop ${bodyshopUUID}`); + const room = getBodyshopRoom(bodyshopUUID); + io.to(room).emit("bodyshop-message", message); + createLogEvent(socket, "INFO", `Broadcast message to bodyshop ${room}`); }); socket.on("disconnect", async () => { @@ -45,12 +63,12 @@ const redisSocketEvents = (io, { addUserToRoom, getUsersInRoom, removeUserFromRo // Get all rooms the socket is part of const rooms = Array.from(socket.rooms).filter((room) => room !== socket.id); - for (const bodyshopUUID of rooms) { - await removeUserFromRoom(bodyshopUUID, { uid: socket.user.uid, email: socket.user.email }); + for (const bodyshopRoom of rooms) { + await removeUserFromRoom(bodyshopRoom, { uid: socket.user.uid, email: socket.user.email }); // Notify all users in the room about the updated user list - const usersInRoom = await getUsersInRoom(bodyshopUUID); - io.to(bodyshopUUID).emit("room-users-updated", usersInRoom); + const usersInRoom = await getUsersInRoom(bodyshopRoom); + io.to(bodyshopRoom).emit("room-users-updated", usersInRoom); } }); } @@ -61,7 +79,7 @@ const redisSocketEvents = (io, { addUserToRoom, getUsersInRoom, removeUserFromRo // Register room and broadcasting events registerRoomAndBroadcastEvents(socket); - + registerUpdateEvents(socket); // Handle socket disconnection socket.on("disconnect", async () => { createLogEvent(socket, "DEBUG", `User disconnected.`); From 80527670024ef19daa4100bfc1f5a8d97c0e4ec4 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 26 Sep 2024 11:03:46 -0400 Subject: [PATCH 6/9] IO-2924-Refactor-Production-board-to-use-Socket-Provider: Finalize Signed-off-by: Dave Richer --- client/src/contexts/SocketIO/useSocket.js | 2 +- server/ioevent/ioevent.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/contexts/SocketIO/useSocket.js b/client/src/contexts/SocketIO/useSocket.js index 42385bfb7..9af0dcf99 100644 --- a/client/src/contexts/SocketIO/useSocket.js +++ b/client/src/contexts/SocketIO/useSocket.js @@ -75,7 +75,7 @@ const useSocket = (bodyshop) => { socketRef.current = null; } }; - }, [bodyshop.id]); + }, [bodyshop?.id]); return { socket: socketRef.current, clientId }; }; diff --git a/server/ioevent/ioevent.js b/server/ioevent/ioevent.js index 36950b84e..252a77b42 100644 --- a/server/ioevent/ioevent.js +++ b/server/ioevent/ioevent.js @@ -27,6 +27,7 @@ exports.default = async (req, res) => { useremail } }); + ioRedis.to(getBodyshopRoom(bodyshopid)).emit("bodyshop-message", { operationName, useremail From fa57828ebdef4c9635322c2bc23a324de47727e0 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 26 Sep 2024 11:13:08 -0400 Subject: [PATCH 7/9] IO-2924-Refactor-Production-board-to-use-Socket-Provider: Finalize Signed-off-by: Dave Richer --- client/src/contexts/SocketIO/useSocket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/contexts/SocketIO/useSocket.js b/client/src/contexts/SocketIO/useSocket.js index 9af0dcf99..e6b78748c 100644 --- a/client/src/contexts/SocketIO/useSocket.js +++ b/client/src/contexts/SocketIO/useSocket.js @@ -75,7 +75,7 @@ const useSocket = (bodyshop) => { socketRef.current = null; } }; - }, [bodyshop?.id]); + }, [bodyshop]); return { socket: socketRef.current, clientId }; }; From b286ab2439f871b4e944f51a25f0e4f7d9b542bc Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 26 Sep 2024 11:21:26 -0400 Subject: [PATCH 8/9] IO-2924-Refactor-Production-board-to-use-Socket-Provider: Finalize Signed-off-by: Dave Richer --- server/web-sockets/redisSocketEvents.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/server/web-sockets/redisSocketEvents.js b/server/web-sockets/redisSocketEvents.js index 798debd35..b090ddc3d 100644 --- a/server/web-sockets/redisSocketEvents.js +++ b/server/web-sockets/redisSocketEvents.js @@ -80,10 +80,6 @@ const redisSocketEvents = (io, { addUserToRoom, getUsersInRoom, removeUserFromRo // Register room and broadcasting events registerRoomAndBroadcastEvents(socket); registerUpdateEvents(socket); - // Handle socket disconnection - socket.on("disconnect", async () => { - createLogEvent(socket, "DEBUG", `User disconnected.`); - }); } const authMiddleware = (socket, next) => { @@ -97,7 +93,7 @@ const redisSocketEvents = (io, { addUserToRoom, getUsersInRoom, removeUserFromRo next(); }) .catch((error) => { - next(new Error("Authentication error", JSON.stringify(error))); + next(new Error(`Authentication error: ${error.message}`)); }); } else { next(new Error("Authentication error - no authorization token.")); @@ -105,7 +101,6 @@ const redisSocketEvents = (io, { addUserToRoom, getUsersInRoom, removeUserFromRo } catch (error) { console.log("Uncaught connection error:::", error); logger.log("websocket-connection-error", "error", null, null, { - token: socket.handshake.auth.token, ...error }); next(new Error(`Authentication error ${error}`)); From db0c16f31d9a4967f3c80affce795975e719cbd2 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Thu, 26 Sep 2024 14:03:27 -0400 Subject: [PATCH 9/9] IO-2924-Refactor-Production-board-to-use-Socket-Provider: Finalize Signed-off-by: Dave Richer --- .../production-board-kanban.container.jsx | 12 +- .../production-list-table.container.jsx | 134 +++++++++++++++--- .../production-list.component.jsx | 2 +- server/job/job-updated.js | 2 +- 4 files changed, 119 insertions(+), 31 deletions(-) diff --git a/client/src/components/production-board-kanban/production-board-kanban.container.jsx b/client/src/components/production-board-kanban/production-board-kanban.container.jsx index 6182b3ffb..dd5d73d1e 100644 --- a/client/src/components/production-board-kanban/production-board-kanban.container.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban.container.jsx @@ -3,7 +3,7 @@ import { useApolloClient, useQuery, useSubscription } from "@apollo/client"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { - GET_JOB_BY_PK, + QUERY_EXACT_JOB_IN_PRODUCTION, QUERY_JOBS_IN_PRODUCTION, SUBSCRIPTION_JOBS_IN_PRODUCTION, SUBSCRIPTION_JOBS_IN_PRODUCTION_VIEW @@ -100,7 +100,7 @@ function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionTyp query: QUERY_JOBS_IN_PRODUCTION, data: { jobs: existingJobs.map((job) => - job.id === jobId ? { ...existingJob, ...jobChangedData, __typename: "Job" } : job + job.id === jobId ? { ...existingJob, ...jobChangedData, __typename: "jobs" } : job ) } }); @@ -108,7 +108,7 @@ function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionTyp // If the job doesn't exist, fetch it from the server and then add it to the cache try { const { data: jobData } = await client.query({ - query: GET_JOB_BY_PK, + query: QUERY_EXACT_JOB_IN_PRODUCTION, variables: { id: jobId }, fetchPolicy: "network-only" }); @@ -117,7 +117,7 @@ function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionTyp client.writeQuery({ query: QUERY_JOBS_IN_PRODUCTION, data: { - jobs: [...existingJobs, { ...jobData.job, __typename: "Job" }] + jobs: [...existingJobs, { ...jobData.job, __typename: "jobs" }] } }); } catch (error) { @@ -127,11 +127,11 @@ function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionTyp }; // Listen for 'job-changed' events - socket.on("job-updated", handleJobUpdates); + socket.on("production-job-updated", handleJobUpdates); // Clean up on unmount or when dependencies change return () => { - socket.off("job-updated", handleJobUpdates); + socket.off("production-job-updated", handleJobUpdates); }; }, [subscriptionEnabled, socket, bodyshop, data, client]); diff --git a/client/src/components/production-list-table/production-list-table.container.jsx b/client/src/components/production-list-table/production-list-table.container.jsx index 9f564bde7..3cb9d8b5e 100644 --- a/client/src/components/production-list-table/production-list-table.container.jsx +++ b/client/src/components/production-list-table/production-list-table.container.jsx @@ -1,5 +1,5 @@ import { useApolloClient, useQuery, useSubscription } from "@apollo/client"; -import React, { useEffect, useState } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { QUERY_EXACT_JOB_IN_PRODUCTION, QUERY_EXACT_JOBS_IN_PRODUCTION, @@ -9,19 +9,42 @@ import { } from "../../graphql/jobs.queries"; import ProductionListTable from "./production-list-table.component"; import _ from "lodash"; +import { useSplitTreatments } from "@splitsoftware/splitio-react"; +import SocketContext from "../../contexts/SocketIO/socketContext.jsx"; -export default function ProductionListTableContainer({ subscriptionType = "direct" }) { +export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) { + const client = useApolloClient(); + const { socket } = useContext(SocketContext); + const [joblist, setJoblist] = useState([]); + + // Get Split treatment + const { + treatments: { Websocket_Production } + } = useSplitTreatments({ + attributes: {}, + names: ["Websocket_Production"], + splitKey: bodyshop && bodyshop.imexshopid + }); + + // Determine if subscription is enabled + const subscriptionEnabled = Websocket_Production?.treatment === "on"; + + // Use GraphQL query const { refetch, loading, data } = useQuery(QUERY_JOBS_IN_PRODUCTION, { pollInterval: 3600000, fetchPolicy: "network-only", nextFetchPolicy: "network-only" }); - const client = useApolloClient(); - const [joblist, setJoblist] = useState([]); + + // Use GraphQL subscription when subscription is enabled const { data: updatedJobs } = useSubscription( - subscriptionType === "view" ? SUBSCRIPTION_JOBS_IN_PRODUCTION_VIEW : SUBSCRIPTION_JOBS_IN_PRODUCTION + subscriptionType === "view" ? SUBSCRIPTION_JOBS_IN_PRODUCTION_VIEW : SUBSCRIPTION_JOBS_IN_PRODUCTION, + { + skip: !subscriptionEnabled + } ); + // Update joblist when data changes useEffect(() => { if (!(data && data.jobs)) return; setJoblist( @@ -31,34 +54,99 @@ export default function ProductionListTableContainer({ subscriptionType = "direc ); }, [data]); + // Handle updates from GraphQL subscription useEffect(() => { - if (!updatedJobs || joblist.length === 0) return; + if (subscriptionEnabled) { + if (!updatedJobs || joblist.length === 0) return; - const jobDiff = _.differenceWith( - joblist, - updatedJobs.jobs, - (a, b) => a.id === b.id && a.updated_at === b.updated_at - ); + const jobDiff = _.differenceWith( + joblist, + updatedJobs.jobs, + (a, b) => a.id === b.id && a.updated_at === b.updated_at + ); - if (jobDiff.length > 1) { - getUpdatedJobsData(jobDiff.map((j) => j.id)); - } else if (jobDiff.length === 1) { - jobDiff.forEach((job) => { - getUpdatedJobData(job.id); - }); + if (jobDiff.length > 1) { + getUpdatedJobsData(jobDiff.map((j) => j.id)); + } else if (jobDiff.length === 1) { + jobDiff.forEach((job) => { + getUpdatedJobData(job.id); + }); + } + + setJoblist(updatedJobs.jobs); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [updatedJobs, subscriptionEnabled]); + + // Handle updates from Socket.IO when subscription is disabled + useEffect(() => { + if (subscriptionEnabled || !socket || !bodyshop || !bodyshop.id) { + return; } - setJoblist(updatedJobs.jobs); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [updatedJobs]); + const handleJobUpdates = async (jobChangedData) => { + const jobId = jobChangedData.id; + // Access the existing cache for QUERY_JOBS_IN_PRODUCTION + const existingJobsCache = client.readQuery({ + query: QUERY_JOBS_IN_PRODUCTION + }); + + const existingJobs = existingJobsCache?.jobs || []; + + // Check if the job already exists in the cached jobs + const existingJob = existingJobs.find((job) => job.id === jobId); + + if (existingJob) { + // If the job exists, we update the cache without making any additional queries + client.writeQuery({ + query: QUERY_JOBS_IN_PRODUCTION, + data: { + jobs: existingJobs.map((job) => + job.id === jobId ? { ...existingJob, ...jobChangedData, __typename: "jobs" } : job + ) + } + }); + } else { + // If the job doesn't exist, fetch it from the server and then add it to the cache + try { + const { data: jobData } = await client.query({ + query: QUERY_EXACT_JOB_IN_PRODUCTION, + variables: { id: jobId }, + fetchPolicy: "network-only" + }); + + // Add the job to the existing cached jobs + client.writeQuery({ + query: QUERY_JOBS_IN_PRODUCTION, + data: { + jobs: [...existingJobs, { ...jobData.job, __typename: "jobs" }] + } + }); + } catch (error) { + console.error(`Error fetching job ${jobId}: ${error.message}`); + } + } + }; + + // Listen for 'production-job-updated' events + socket.on("production-job-updated", handleJobUpdates); + + // Clean up on unmount or when dependencies change + return () => { + socket.off("production-job-updated", handleJobUpdates); + }; + }, [subscriptionEnabled, socket, bodyshop, client]); + + // Functions to fetch updated job data const getUpdatedJobData = async (jobId) => { - client.query({ + await client.query({ query: QUERY_EXACT_JOB_IN_PRODUCTION, - variables: { id: jobId } + variables: { id: jobId }, + fetchPolicy: "network-only" }); }; - const getUpdatedJobsData = async (jobIds) => { + const getUpdatedJobsData = (jobIds) => { client.query({ query: QUERY_EXACT_JOBS_IN_PRODUCTION, variables: { ids: jobIds } diff --git a/client/src/pages/production-list/production-list.component.jsx b/client/src/pages/production-list/production-list.component.jsx index 177108f6d..aa6e4e1b1 100644 --- a/client/src/pages/production-list/production-list.component.jsx +++ b/client/src/pages/production-list/production-list.component.jsx @@ -26,7 +26,7 @@ export function ProductionListComponent({ bodyshop }) { return ( <> - + ); } diff --git a/server/job/job-updated.js b/server/job/job-updated.js index bec619837..f218f83bc 100644 --- a/server/job/job-updated.js +++ b/server/job/job-updated.js @@ -24,7 +24,7 @@ const jobUpdated = async (req, res) => { const bodyshopID = updatedJob.shopid; // Emit the job-updated event only to the room corresponding to the bodyshop - ioRedis.to(ioHelpers.getBodyshopRoom(bodyshopID)).emit("job-updated", updatedJob); + ioRedis.to(ioHelpers.getBodyshopRoom(bodyshopID)).emit("production-job-updated", updatedJob); return res.json({ message: "Job updated and event emitted" }); };