From 1440a6022847a80877a5f84859e54664aebf4a23 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 12 Nov 2024 12:31:46 -0800 Subject: [PATCH 1/2] feature/IO-3026-Enhanced-Notifications - Initial commit Signed-off-by: Dave Richer --- client/src/contexts/SocketIO/useSocket.js | 21 ++- .../pages/manage/manage.page.component.jsx | 120 +++++++++++++++++- .../redux/application/application.actions.js | 11 ++ .../redux/application/application.reducer.js | 23 +++- .../application/application.selectors.js | 1 + .../redux/application/application.types.js | 4 +- 6 files changed, 167 insertions(+), 13 deletions(-) diff --git a/client/src/contexts/SocketIO/useSocket.js b/client/src/contexts/SocketIO/useSocket.js index 11577c906..1c5a058fc 100644 --- a/client/src/contexts/SocketIO/useSocket.js +++ b/client/src/contexts/SocketIO/useSocket.js @@ -1,8 +1,9 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import SocketIO from "socket.io-client"; import { auth } from "../../firebase/firebase.utils"; import { store } from "../../redux/store"; -import { setWssStatus } from "../../redux/application/application.actions"; +import { addAlerts, setWssStatus } from "../../redux/application/application.actions"; + const useSocket = (bodyshop) => { const socketRef = useRef(null); const [clientId, setClientId] = useState(null); @@ -31,6 +32,14 @@ const useSocket = (bodyshop) => { socketRef.current = socketInstance; const handleBodyshopMessage = (message) => { + if (!message || !message?.type) return; + + switch (message.type) { + case "alert-update": + store.dispatch(addAlerts(message.payload)); + break; + } + if (!import.meta.env.DEV) return; console.log(`Received message for bodyshop ${bodyshop.id}:`, message); }; @@ -39,22 +48,22 @@ const useSocket = (bodyshop) => { console.log("Socket connected:", socketInstance.id); socketInstance.emit("join-bodyshop-room", bodyshop.id); setClientId(socketInstance.id); - store.dispatch(setWssStatus("connected")) + store.dispatch(setWssStatus("connected")); }; const handleReconnect = (attempt) => { console.log(`Socket reconnected after ${attempt} attempts`); - store.dispatch(setWssStatus("connected")) + store.dispatch(setWssStatus("connected")); }; const handleConnectionError = (err) => { console.error("Socket connection error:", err); - store.dispatch(setWssStatus("error")) + store.dispatch(setWssStatus("error")); }; const handleDisconnect = () => { console.log("Socket disconnected"); - store.dispatch(setWssStatus("disconnected")) + store.dispatch(setWssStatus("disconnected")); }; socketInstance.on("connect", handleConnect); diff --git a/client/src/pages/manage/manage.page.component.jsx b/client/src/pages/manage/manage.page.component.jsx index 46d66016f..a6ef08753 100644 --- a/client/src/pages/manage/manage.page.component.jsx +++ b/client/src/pages/manage/manage.page.component.jsx @@ -1,4 +1,4 @@ -import { FloatButton, Layout, Spin } from "antd"; +import { FloatButton, Layout, notification, Spin } from "antd"; // import preval from "preval.macro"; import React, { lazy, Suspense, useContext, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -21,11 +21,12 @@ import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-st import { requestForToken } from "../../firebase/firebase.utils"; import SocketContext from "../../contexts/SocketIO/socketContext.jsx"; import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors"; - import UpdateAlert from "../../components/update-alert/update-alert.component"; import InstanceRenderManager from "../../utils/instanceRenderMgr.js"; import "./manage.page.styles.scss"; import WssStatusDisplayComponent from "../../components/wss-status-display/wss-status-display.component.jsx"; +import { selectAlerts } from "../../redux/application/application.selectors.js"; +import { addAlerts } from "../../redux/application/application.actions.js"; const JobsPage = lazy(() => import("../jobs/jobs.page")); @@ -104,16 +105,125 @@ const { Content, Footer } = Layout; const mapStateToProps = createStructuredSelector({ conflict: selectInstanceConflict, - bodyshop: selectBodyshop + bodyshop: selectBodyshop, + alerts: selectAlerts }); -const mapDispatchToProps = (dispatch) => ({}); +// images.imex.online/alerts/alerts.json +const ALERT_FILE_URL = "http://localhost:5000/alerts.json"; -export function Manage({ conflict, bodyshop }) { +const mapDispatchToProps = (dispatch) => ({ + setAlerts: (alerts) => dispatch(addAlerts(alerts)) +}); + +export function Manage({ conflict, bodyshop, alerts, setAlerts }) { const { t } = useTranslation(); const [chatVisible] = useState(false); const { socket, clientId } = useContext(SocketContext); + // State to track displayed alerts + const [displayedAlertIds, setDisplayedAlertIds] = useState([]); + + // Fetch displayed alerts from localStorage on mount + useEffect(() => { + const displayedAlerts = JSON.parse(localStorage.getItem("displayedAlerts") || "[]"); + setDisplayedAlertIds(displayedAlerts); + }, []); + + // Fetch alerts from the JSON file and dispatch to Redux store + useEffect(() => { + const fetchAlerts = async () => { + try { + const response = await fetch(ALERT_FILE_URL); + const fetchedAlerts = await response.json(); + setAlerts(fetchedAlerts); + } catch (error) { + console.error("Error fetching alerts:", error); + } + }; + + fetchAlerts(); + }, []); + + useEffect(() => { + console.log("Alerts in Manage component:", alerts); + if (alerts && Object.keys(alerts).length > 0) { + // Convert the alerts object into an array + const alertArray = Object.values(alerts); + + // Filter out alerts that have already been displayed + const newAlerts = alertArray.filter((alert) => !displayedAlertIds.includes(alert.id)); + + console.log("New alerts to display:", newAlerts); + + newAlerts.forEach((alert) => { + // Display the notification + notification.open({ + key: "notification-alerts-" + alert.id, + message: alert.message, + description: alert.description, + type: alert.type || "info", + duration: 0, + placement: "bottomRight", + closable: true + }); + + // Update displayed alerts state and localStorage + setDisplayedAlertIds((prevIds) => { + const updatedIds = [...prevIds, alert.id]; + localStorage.setItem("displayedAlerts", JSON.stringify(updatedIds)); + return updatedIds; + }); + }); + } + }, [alerts]); + + // useEffect(() => { + // const fetchAlerts = async () => { + // try { + // const response = await fetch(ALERT_FILE_URL); + // + // // Check if the response is OK (status in the range 200-299) + // if (!response.ok) { + // console.error(`Network response was not ok: ${response.status} ${response.statusText}`); + // return; // Exit the function early since we can't proceed + // } + // + // const alerts = await response.json(); + // + // // Check if alerts is an array + // if (!Array.isArray(alerts)) { + // console.error("Alerts data is not an array"); + // return; + // } + // + // const displayedAlerts = JSON.parse(localStorage.getItem("displayedAlerts") || "[]"); + // const alertsNotDisplayed = alerts.filter((alert) => !displayedAlerts.includes(alert.id)); + // + // // Display notifications for alerts not yet displayed + // alertsNotDisplayed.forEach((alert) => { + // // Update localStorage immediately to prevent duplicate notifications + // displayedAlerts.push(alert.id); + // localStorage.setItem("displayedAlerts", JSON.stringify(displayedAlerts)); + // + // notification.open({ + // key: "notification-alerts-" + alert.id, + // message: alert.message, + // description: alert.description, + // type: alert.type || "info", + // duration: 0, + // placement: "bottomRight", + // closable: true + // }); + // }); + // } catch (error) { + // console.error("Error fetching alerts:", error); + // } + // }; + // + // fetchAlerts(); + // }, []); + useEffect(() => { const widgetId = InstanceRenderManager({ imex: "IABVNO4scRKY11XBQkNr", diff --git a/client/src/redux/application/application.actions.js b/client/src/redux/application/application.actions.js index c8246022b..04f880b9a 100644 --- a/client/src/redux/application/application.actions.js +++ b/client/src/redux/application/application.actions.js @@ -67,6 +67,17 @@ export const setUpdateAvailable = (isUpdateAvailable) => ({ type: ApplicationActionTypes.SET_UPDATE_AVAILABLE, payload: isUpdateAvailable }); + +export const setAlerts = (alerts) => ({ + type: ApplicationActionTypes.SET_ALERTS, + payload: alerts +}); + +export const addAlerts = (alerts) => ({ + type: ApplicationActionTypes.ADD_ALERTS, + payload: alerts +}); + export const setWssStatus = (status) => ({ type: ApplicationActionTypes.SET_WSS_STATUS, payload: status diff --git a/client/src/redux/application/application.reducer.js b/client/src/redux/application/application.reducer.js index 21878e52a..56327a0c0 100644 --- a/client/src/redux/application/application.reducer.js +++ b/client/src/redux/application/application.reducer.js @@ -15,7 +15,8 @@ const INITIAL_STATE = { error: null }, jobReadOnly: false, - partnerVersion: null + partnerVersion: null, + alerts: {} }; const applicationReducer = (state = INITIAL_STATE, action) => { @@ -91,6 +92,26 @@ const applicationReducer = (state = INITIAL_STATE, action) => { case ApplicationActionTypes.SET_WSS_STATUS: { return { ...state, wssStatus: action.payload }; } + case ApplicationActionTypes.SET_ALERTS: { + const alertsMap = {}; + action.payload.forEach((alert) => { + alertsMap[alert.id] = alert; + }); + return { + ...state, + alerts: alertsMap + }; + } + case ApplicationActionTypes.ADD_ALERTS: { + const newAlertsMap = { ...state.alerts }; + action.payload.forEach((alert) => { + newAlertsMap[alert.id] = alert; + }); + return { + ...state, + alerts: newAlertsMap + }; + } default: return state; } diff --git a/client/src/redux/application/application.selectors.js b/client/src/redux/application/application.selectors.js index a4f434cfe..6b0d1c2c4 100644 --- a/client/src/redux/application/application.selectors.js +++ b/client/src/redux/application/application.selectors.js @@ -23,3 +23,4 @@ export const selectOnline = createSelector([selectApplication], (application) => export const selectProblemJobs = createSelector([selectApplication], (application) => application.problemJobs); export const selectUpdateAvailable = createSelector([selectApplication], (application) => application.updateAvailable); export const selectWssStatus = createSelector([selectApplication], (application) => application.wssStatus); +export const selectAlerts = createSelector([selectApplication], (application) => application.alerts); diff --git a/client/src/redux/application/application.types.js b/client/src/redux/application/application.types.js index 1672cda0b..3665d6193 100644 --- a/client/src/redux/application/application.types.js +++ b/client/src/redux/application/application.types.js @@ -13,6 +13,8 @@ const ApplicationActionTypes = { INSERT_AUDIT_TRAIL: "INSERT_AUDIT_TRAIL", SET_PROBLEM_JOBS: "SET_PROBLEM_JOBS", SET_UPDATE_AVAILABLE: "SET_UPDATE_AVAILABLE", - SET_WSS_STATUS: "SET_WSS_STATUS" + SET_WSS_STATUS: "SET_WSS_STATUS", + SET_ALERTS: "SET_ALERTS", + ADD_ALERTS: "ADD_ALERTS" }; export default ApplicationActionTypes; From 6f454dd4cb7597f2ced8a69d930f69e3f28e233a Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 12 Nov 2024 14:20:49 -0800 Subject: [PATCH 2/2] feature/IO-3026-Enhanced-Notifications - final revisions Signed-off-by: Dave Richer --- .../pages/manage/manage.page.component.jsx | 31 ++++---- .../redux/application/application.actions.js | 5 -- .../redux/application/application.reducer.js | 14 +--- .../redux/application/application.types.js | 1 - server/alerts/alertcheck.js | 76 +++++++++++++++++++ server/routes/miscellaneousRoutes.js | 4 + 6 files changed, 99 insertions(+), 32 deletions(-) create mode 100644 server/alerts/alertcheck.js diff --git a/client/src/pages/manage/manage.page.component.jsx b/client/src/pages/manage/manage.page.component.jsx index a6ef08753..065fe7b46 100644 --- a/client/src/pages/manage/manage.page.component.jsx +++ b/client/src/pages/manage/manage.page.component.jsx @@ -109,8 +109,10 @@ const mapStateToProps = createStructuredSelector({ alerts: selectAlerts }); -// images.imex.online/alerts/alerts.json -const ALERT_FILE_URL = "http://localhost:5000/alerts.json"; +const ALERT_FILE_URL = InstanceRenderManager({ + imex: "https://images.imex.online/alerts/alerts-imex.json", + rome: "https://images.imex.online/alerts/alerts-rome.json" +}); const mapDispatchToProps = (dispatch) => ({ setAlerts: (alerts) => dispatch(addAlerts(alerts)) @@ -145,17 +147,15 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) { fetchAlerts(); }, []); + // Use useEffect to watch for new alerts useEffect(() => { - console.log("Alerts in Manage component:", alerts); if (alerts && Object.keys(alerts).length > 0) { // Convert the alerts object into an array const alertArray = Object.values(alerts); - // Filter out alerts that have already been displayed + // Filter out alerts that have already been dismissed const newAlerts = alertArray.filter((alert) => !displayedAlertIds.includes(alert.id)); - console.log("New alerts to display:", newAlerts); - newAlerts.forEach((alert) => { // Display the notification notification.open({ @@ -165,18 +165,19 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) { type: alert.type || "info", duration: 0, placement: "bottomRight", - closable: true - }); - - // Update displayed alerts state and localStorage - setDisplayedAlertIds((prevIds) => { - const updatedIds = [...prevIds, alert.id]; - localStorage.setItem("displayedAlerts", JSON.stringify(updatedIds)); - return updatedIds; + closable: true, + onClose: () => { + // When the notification is closed, update displayed alerts state and localStorage + setDisplayedAlertIds((prevIds) => { + const updatedIds = [...prevIds, alert.id]; + localStorage.setItem("displayedAlerts", JSON.stringify(updatedIds)); + return updatedIds; + }); + } }); }); } - }, [alerts]); + }, [alerts, displayedAlertIds]); // useEffect(() => { // const fetchAlerts = async () => { diff --git a/client/src/redux/application/application.actions.js b/client/src/redux/application/application.actions.js index 04f880b9a..4d362c6d8 100644 --- a/client/src/redux/application/application.actions.js +++ b/client/src/redux/application/application.actions.js @@ -68,11 +68,6 @@ export const setUpdateAvailable = (isUpdateAvailable) => ({ payload: isUpdateAvailable }); -export const setAlerts = (alerts) => ({ - type: ApplicationActionTypes.SET_ALERTS, - payload: alerts -}); - export const addAlerts = (alerts) => ({ type: ApplicationActionTypes.ADD_ALERTS, payload: alerts diff --git a/client/src/redux/application/application.reducer.js b/client/src/redux/application/application.reducer.js index 56327a0c0..6d5421e27 100644 --- a/client/src/redux/application/application.reducer.js +++ b/client/src/redux/application/application.reducer.js @@ -92,19 +92,11 @@ const applicationReducer = (state = INITIAL_STATE, action) => { case ApplicationActionTypes.SET_WSS_STATUS: { return { ...state, wssStatus: action.payload }; } - case ApplicationActionTypes.SET_ALERTS: { - const alertsMap = {}; - action.payload.forEach((alert) => { - alertsMap[alert.id] = alert; - }); - return { - ...state, - alerts: alertsMap - }; - } + case ApplicationActionTypes.ADD_ALERTS: { const newAlertsMap = { ...state.alerts }; - action.payload.forEach((alert) => { + + action.payload.alerts.forEach((alert) => { newAlertsMap[alert.id] = alert; }); return { diff --git a/client/src/redux/application/application.types.js b/client/src/redux/application/application.types.js index 3665d6193..26c1b4c7d 100644 --- a/client/src/redux/application/application.types.js +++ b/client/src/redux/application/application.types.js @@ -14,7 +14,6 @@ const ApplicationActionTypes = { SET_PROBLEM_JOBS: "SET_PROBLEM_JOBS", SET_UPDATE_AVAILABLE: "SET_UPDATE_AVAILABLE", SET_WSS_STATUS: "SET_WSS_STATUS", - SET_ALERTS: "SET_ALERTS", ADD_ALERTS: "ADD_ALERTS" }; export default ApplicationActionTypes; diff --git a/server/alerts/alertcheck.js b/server/alerts/alertcheck.js new file mode 100644 index 000000000..e29ff1e6a --- /dev/null +++ b/server/alerts/alertcheck.js @@ -0,0 +1,76 @@ +const axios = require("axios"); +const _ = require("lodash"); +const { default: InstanceMgr } = require("../utils/instanceMgr"); // For deep object comparison + +// Constants +const ALERTS_REDIS_KEY = "alerts_data"; // The key under which we'll store alerts in Redis +const GLOBAL_SOCKET_ID = "global"; // Use 'global' as a socketId to store global data + +const ALERT_FILE_URL = InstanceMgr({ + imex: "https://images.imex.online/alerts/alerts-imex.json", + rome: "https://images.imex.online/alerts/alerts-rome.json" +}); + +const alertCheck = async (req, res) => { + // Access Redis helper functions + const { ioRedis, logger } = req; + const { getSessionData, setSessionData } = req.sessionUtils; + + try { + // Get the JSON Alert file from the server + const response = await axios.get(ALERT_FILE_URL); + const currentAlerts = response.data; + // Retrieve stored alerts from Redis using a global socketId + const storedAlerts = await getSessionData(GLOBAL_SOCKET_ID, ALERTS_REDIS_KEY); + if (!storedAlerts) { + // Alerts not in Redis, store them + await setSessionData(GLOBAL_SOCKET_ID, ALERTS_REDIS_KEY, currentAlerts); + logger.logger.debug("Alerts added to Redis for the first time."); + + // Emit to clients + if (ioRedis) { + ioRedis.emit("bodyshop-message", { + type: "alert-update", + payload: currentAlerts + }); + logger.logger.debug("Alerts emitted to clients for the first time."); + } else { + logger.log("Socket.IO instance not found. (1)", "error"); + } + + return res.status(200).send("Alerts added to Redis and emitted to clients."); + } else { + // Alerts are in Redis, compare them + if (!_.isEqual(currentAlerts, storedAlerts)) { + // Alerts are different, update Redis and emit to clients + await setSessionData(GLOBAL_SOCKET_ID, ALERTS_REDIS_KEY, currentAlerts); + logger.logger.debug("Alerts updated in Redis."); + + // Emit the new alerts to all connected clients + if (ioRedis) { + ioRedis.emit("bodyshop-message", { + type: "alert-update", + payload: currentAlerts + }); + logger.logger.debug("Alerts emitted to clients after update."); + } else { + logger.log("Socket.IO instance not found. (2)", "error"); + } + + return res.status(200).send("Alerts updated in Redis and emitted to clients."); + } else { + return res.status(200).send("No changes in alerts."); + } + } + } catch (error) { + logger.log("Error in alertCheck:", "error", null, null, { + error: { + message: error.message, + stack: error.stack + } + }); + return res.status(500).send("Internal server error."); + } +}; + +module.exports = { alertCheck }; diff --git a/server/routes/miscellaneousRoutes.js b/server/routes/miscellaneousRoutes.js index fded5f44b..d772ffe61 100644 --- a/server/routes/miscellaneousRoutes.js +++ b/server/routes/miscellaneousRoutes.js @@ -12,6 +12,7 @@ const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebas const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware"); const { taskAssignedEmail, tasksRemindEmail } = require("../email/tasksEmails"); const { canvastest } = require("../render/canvas-handler"); +const { alertCheck } = require("../alerts/alertcheck"); //Test route to ensure Express is responding. router.get("/test", async function (req, res) { @@ -53,4 +54,7 @@ router.post("/taskHandler", validateFirebaseIdTokenMiddleware, taskHandler.taskH // Canvas Test router.post("/canvastest", validateFirebaseIdTokenMiddleware, canvastest); +// Alert Check +router.post("/alertcheck", eventAuthorizationMiddleware, alertCheck); + module.exports = router;