From 07faa5eec23f4c8b571874fa12652aea4b13fe09 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 4 Mar 2025 17:07:31 -0500 Subject: [PATCH] IO-3166-Global-Notifications-Part-2 - Checkpoint --- client/src/contexts/SocketIO/useSocket.jsx | 6 +- hasura/metadata/tables.yaml | 46 ++++++++- server/graphql-client/queries.js | 10 ++ server/notifications/scenarioBuilders.js | 1 - server/notifications/scenarioMapper.js | 43 ++++----- server/routes/miscellaneousRoutes.js | 5 + server/utils/ioHelpers.js | 6 +- server/utils/redisHelpers.js | 105 ++++++++++++++++++++- server/web-sockets/redisSocketEvents.js | 3 + server/web-sockets/updateBodyshopCache.js | 36 +++++++ 10 files changed, 230 insertions(+), 31 deletions(-) create mode 100644 server/web-sockets/updateBodyshopCache.js diff --git a/client/src/contexts/SocketIO/useSocket.jsx b/client/src/contexts/SocketIO/useSocket.jsx index a8b28a145..b2f20e459 100644 --- a/client/src/contexts/SocketIO/useSocket.jsx +++ b/client/src/contexts/SocketIO/useSocket.jsx @@ -85,7 +85,11 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser, scenarioNot }); } }, - onError: (err) => console.error("MARK_NOTIFICATION_READ error:", err) + onError: (err) => + console.error("MARK_NOTIFICATION_READ error:", { + message: err?.message, + stack: err?.stack + }) }); const [markAllNotificationsRead] = useMutation(MARK_ALL_NOTIFICATIONS_READ, { diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 22ad129a2..04ee6d648 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -1127,6 +1127,46 @@ - active: _eq: true check: null + event_triggers: + - name: cache_bodyshop + definition: + enable_manual: false + update: + columns: + - shopname + - md_order_statuses + retry_conf: + interval_sec: 10 + num_retries: 0 + timeout_sec: 60 + webhook_from_env: HASURA_API_URL + headers: + - name: event-secret + value_from_env: EVENT_SECRET + request_transform: + body: + action: transform + template: |- + { + "created_at": {{$body.created_at}}, + "delivery_info": {{$body.delivery_info}}, + "event": { + "data": { + "new": { + "id": {{$body.event.data.new.id}}, + "shopname": {{$body.event.data.new.shopname}}, + "md_order_statuses": {{$body.event.data.new.md_order_statuses}} + } + }, + "op": {{$body.event.op}}, + "session_variables": {{$body.event.session_variables}} + } + } + method: POST + query_params: {} + template_engine: Kriti + url: '{{$base_url}}/bodyshop-cache' + version: 2 - table: name: cccontracts schema: public @@ -3245,6 +3285,7 @@ update: columns: - critical + - status retry_conf: interval_sec: 10 num_retries: 0 @@ -3254,11 +3295,14 @@ - name: event-secret value_from_env: EVENT_SECRET request_transform: + body: + action: transform + template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body.event.session_variables.x-hasura-user-id}}\r\n }, \r\n \"op\": \"UPDATE\",\r\n \"data\": {\r\n \"old\": {\r\n \"id\": {{$body.event.data.old.id}},\r\n \"jobid\": {{$body.event.data.old.jobid}},\r\n \"critical\": {{$body.event.data.old.critical}},\r\n \"status\": {{$body.event.data.old.status}}\r\n },\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"jobid\": {{$body.event.data.new.jobid}},\r\n \"critical\": {{$body.event.data.old.critical}},\r\n \"status\": {{$body.event.data.new.status}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_joblines\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"joblines\"\r\n }\r\n}\r\n" method: POST query_params: {} template_engine: Kriti url: '{{$base_url}}/notifications/events/handleJobLinesChange' - version: 1 + version: 2 - table: name: joblines_status schema: public diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 94c76bb90..542f3b8bd 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2757,3 +2757,13 @@ exports.INSERT_NOTIFICATIONS_MUTATION = ` mutation INSERT_NOTIFICATIONS($object } } }`; + +exports.GET_BODYSHOP_BY_ID = ` + query GET_BODYSHOP_BY_ID($id: uuid!) { + bodyshops_by_pk(id: $id) { + id + md_order_statuses + shopname + } + } +`; diff --git a/server/notifications/scenarioBuilders.js b/server/notifications/scenarioBuilders.js index 0860f8ad0..7bce7d39a 100644 --- a/server/notifications/scenarioBuilders.js +++ b/server/notifications/scenarioBuilders.js @@ -326,7 +326,6 @@ const newNoteAddedBuilder = (data) => { * Builds notification data for new time tickets posted. */ const newTimeTicketPostedBuilder = (data) => { - consoleDir(data); const type = data?.data?.cost_center; const body = `An ${type} time ticket has been posted${data?.data?.flat_rate ? " (Flat Rate)" : ""}.`.trim(); diff --git a/server/notifications/scenarioMapper.js b/server/notifications/scenarioMapper.js index 6a8b59ba3..4e41c0280 100644 --- a/server/notifications/scenarioMapper.js +++ b/server/notifications/scenarioMapper.js @@ -15,6 +15,7 @@ const { supplementImportedBuilder, partMarkedBackOrderedBuilder } = require("./scenarioBuilders"); +const { isFunction } = require("lodash"); /** * An array of notification scenario definitions. @@ -25,9 +26,9 @@ const { * - fields {Array}: Fields to check for changes. * - matchToUserFields {Array}: Fields used to match scenarios to user data. * - onNew {boolean|Array}: Indicates whether the scenario should be triggered on new data. - * - onlyTrue {Array}: Specifies fields that must be true for the scenario to match. * - builder {Function}: A function to handle the scenario. - */ + * - onlyTruthyValues {boolean|Array}: Specifies fields that must have truthy values for the scenario to match. + * */ const notificationScenarios = [ { key: "job-assigned-to-me", @@ -86,7 +87,6 @@ const notificationScenarios = [ builder: newTimeTicketPostedBuilder }, { - // Good test for batching as this will hit multiple scenarios key: "intake-delivery-checklist-completed", table: "jobs", fields: ["intakechecklist", "deliverchecklist"], @@ -109,24 +109,21 @@ const notificationScenarios = [ key: "critical-parts-status-changed", table: "joblines", fields: ["critical"], - onlyTrue: ["critical"], + onlyTruthyValues: ["critical"], builder: criticalPartsStatusChangedBuilder }, + { + key: "part-marked-back-ordered", + table: "joblines", + fields: ["status"], + builder: partMarkedBackOrderedBuilder + }, // -------------- Difficult --------------- // Holding off on this one for now { key: "supplement-imported", builder: supplementImportedBuilder // spans multiple tables, - }, - // This one may be tricky as the jobid is not directly in the event data (this is probably wrong) - // (should otherwise) - // Status needs to mark meta data 'md_backorderd' for example - // Double check Jobid - { - key: "part-marked-back-ordered", - table: "joblines", - builder: partMarkedBackOrderedBuilder } ]; @@ -183,18 +180,6 @@ const getMatchingScenarios = (eventData) => } } - // OnlyTrue logic: - // If a scenario defines an onlyTrue array, then at least one of those fields must have changed - // and its new value (from eventData.data) must be non-falsey. - if (scenario.onlyTrue && Array.isArray(scenario.onlyTrue) && scenario.onlyTrue.length > 0) { - const hasTruthyChange = scenario.onlyTrue.some( - (field) => eventData.changedFieldNames.includes(field) && Boolean(eventData.data[field]) - ); - if (!hasTruthyChange) { - return false; - } - } - // OnlyTruthyValues logic: // If onlyTruthyValues is defined, check that the new values of specified fields (or all changed fields if true) // are truthy. If an array, only check the listed fields, which must be in scenario.fields. @@ -225,6 +210,14 @@ const getMatchingScenarios = (eventData) => } } + // Execute the callback if defined, passing eventData, and filter based on its return value + if (isFunction(scenario?.callback)) { + const shouldInclude = scenario.callback(eventData); + if (!shouldInclude) { + return false; + } + } + return true; }); diff --git a/server/routes/miscellaneousRoutes.js b/server/routes/miscellaneousRoutes.js index 7463e1757..2fa04a552 100644 --- a/server/routes/miscellaneousRoutes.js +++ b/server/routes/miscellaneousRoutes.js @@ -13,6 +13,7 @@ const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLCl const { taskAssignedEmail, tasksRemindEmail } = require("../email/tasksEmails"); const { canvastest } = require("../render/canvas-handler"); const { alertCheck } = require("../alerts/alertcheck"); +const updateBodyshopCache = require("../web-sockets/updateBodyshopCache"); const uuid = require("uuid").v4; //Test route to ensure Express is responding. @@ -58,6 +59,7 @@ router.get("/test-logs", eventAuthorizationMiddleware, (req, res) => { return res.status(500).send("Logs tested."); }); + router.get("/wstest", eventAuthorizationMiddleware, (req, res) => { const { ioRedis } = req; ioRedis.to(`bodyshop-broadcast-room:bfec8c8c-b7f1-49e0-be4c-524455f4e582`).emit("new-message-summary", { @@ -137,4 +139,7 @@ router.post("/canvastest", validateFirebaseIdTokenMiddleware, canvastest); // Alert Check router.post("/alertcheck", eventAuthorizationMiddleware, alertCheck); +// Redis Cache Routes +router.post("/bodyshop-cache", eventAuthorizationMiddleware, updateBodyshopCache); + module.exports = router; diff --git a/server/utils/ioHelpers.js b/server/utils/ioHelpers.js index a95bd90b0..584d45ce7 100644 --- a/server/utils/ioHelpers.js +++ b/server/utils/ioHelpers.js @@ -1,7 +1,9 @@ const applyIOHelpers = ({ app, api, io, logger }) => { - const getBodyshopRoom = (bodyshopID) => `bodyshop-broadcast-room:${bodyshopID}`; + // Global Bodyshop Room + const getBodyshopRoom = (bodyshopId) => `bodyshop-broadcast-room:${bodyshopId}`; + // Messaging - conversation specific room to handle detailed messages when the user has a conversation open. - const getBodyshopConversationRoom = ({bodyshopId, conversationId}) => + const getBodyshopConversationRoom = ({ bodyshopId, conversationId }) => `bodyshop-conversation-room:${bodyshopId}:${conversationId}`; const ioHelpersAPI = { diff --git a/server/utils/redisHelpers.js b/server/utils/redisHelpers.js index 11a42dc0c..763981962 100644 --- a/server/utils/redisHelpers.js +++ b/server/utils/redisHelpers.js @@ -1,3 +1,39 @@ +const { GET_BODYSHOP_BY_ID } = require("../graphql-client/queries"); +const client = require("../graphql-client/graphql-client").client; + +const BODYSHOP_CACHE_TTL = 3600; // 1 hour + +/** + * Generate a cache key for a bodyshop + * @param bodyshopId + * @returns {`bodyshop-cache:${string}`} + */ +const getBodyshopCacheKey = (bodyshopId) => `bodyshop-cache:${bodyshopId}`; + +/** + * Fetch bodyshop data from the database + * @param bodyshopId + * @param logger + * @returns {Promise<*>} + */ +const fetchBodyshopFromDB = async (bodyshopId, logger) => { + try { + const response = await client.request(GET_BODYSHOP_BY_ID, { id: bodyshopId }); + const bodyshop = response.bodyshops_by_pk; + if (!bodyshop) { + throw new Error(`Bodyshop with ID ${bodyshopId} not found`); + } + return bodyshop; // Return the full object as-is + } catch (error) { + logger.log("fetch-bodyshop-from-db", "ERROR", "redis", null, { + bodyshopId, + error: error?.message, + stack: error?.stack + }); + throw error; + } +}; + /** * Apply Redis helper functions * @param pubClient @@ -234,6 +270,71 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => { } }; + // Get bodyshop data from Redis or fetch from DB if missing + const getBodyshopFromRedis = async (bodyshopId) => { + const key = getBodyshopCacheKey(bodyshopId); + try { + // Check if data exists in Redis + const cachedData = await pubClient.get(key); + if (cachedData) { + return JSON.parse(cachedData); // Parse and return the full object + } + + // Cache miss: fetch from DB + const bodyshopData = await fetchBodyshopFromDB(bodyshopId, logger); + + // Store in Redis as a single JSON string + const jsonData = JSON.stringify(bodyshopData); + await pubClient.set(key, jsonData); + await pubClient.expire(key, BODYSHOP_CACHE_TTL); + + logger.log("bodyshop-cache-miss", "DEBUG", "redis", null, { + bodyshopId, + action: "Fetched from DB and cached" + }); + + return bodyshopData; // Return the full object + } catch (error) { + logger.log("get-bodyshop-from-redis", "ERROR", "redis", null, { + bodyshopId, + error: error.message + }); + throw error; + } + }; + + // Update or invalidate bodyshop data in Redis + const updateOrInvalidateBodyshopFromRedis = async (bodyshopId, values = null) => { + const key = getBodyshopCacheKey(bodyshopId); + try { + if (!values) { + // Invalidate cache by deleting the key + await pubClient.del(key); + logger.log("bodyshop-cache-invalidate", "DEBUG", "api", "redis", { + bodyshopId, + action: "Cache invalidated" + }); + } else { + // Update cache with the full provided values + const jsonData = JSON.stringify(values); + await pubClient.set(key, jsonData); + await pubClient.expire(key, BODYSHOP_CACHE_TTL); + logger.log("bodyshop-cache-update", "DEBUG", "api", "redis", { + bodyshopId, + action: "Cache updated", + values + }); + } + } catch (error) { + logger.log("update-or-invalidate-bodyshop-from-redis", "ERROR", "api", "redis", { + bodyshopId, + values, + error: error.message + }); + throw error; + } + }; + const api = { setSessionData, getSessionData, @@ -251,7 +352,9 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => { removeUserSocketMapping, getUserSocketMappingByBodyshop, getUserSocketMapping, - refreshUserSocketTTL + refreshUserSocketTTL, + getBodyshopFromRedis, + updateOrInvalidateBodyshopFromRedis }; Object.assign(module.exports, api); diff --git a/server/web-sockets/redisSocketEvents.js b/server/web-sockets/redisSocketEvents.js index 50c7db961..f59723f11 100644 --- a/server/web-sockets/redisSocketEvents.js +++ b/server/web-sockets/redisSocketEvents.js @@ -14,12 +14,15 @@ const redisSocketEvents = ({ // Socket Auth Middleware const authMiddleware = async (socket, next) => { const { token, bodyshopId } = socket.handshake.auth; + if (!token) { return next(new Error("Authentication error - no authorization token.")); } + if (!bodyshopId) { return next(new Error("Authentication error - no bodyshopId provided.")); } + try { const user = await admin.auth().verifyIdToken(token); socket.user = user; diff --git a/server/web-sockets/updateBodyshopCache.js b/server/web-sockets/updateBodyshopCache.js new file mode 100644 index 000000000..fc330e6b3 --- /dev/null +++ b/server/web-sockets/updateBodyshopCache.js @@ -0,0 +1,36 @@ +/** + * Update or invalidate bodyshop cache + * @param req + * @param res + * @returns {Promise} + */ +const updateBodyshopCache = async (req, res) => { + const { + sessionUtils: { updateOrInvalidateBodyshopFromRedis }, + logger + } = req; + + const { event } = req.body; + const { new: newData } = event.data; + + try { + if (newData && newData.id) { + // Update cache with the full new data object + await updateOrInvalidateBodyshopFromRedis(newData.id, newData); + logger.logger.debug("Bodyshop cache updated successfully."); + } else { + // Invalidate cache if no valid data provided + await updateOrInvalidateBodyshopFromRedis(newData.id); + logger.logger.debug("Bodyshop cache invalidated successfully."); + } + res.status(200).json({ success: true }); + } catch (error) { + logger.log("bodyshop-cache-update-error", "ERROR", "api", "redis", { + message: error?.message, + stack: error?.stack + }); + res.status(500).json({ success: false, error: error.message }); + } +}; + +module.exports = updateBodyshopCache;