IO-3166-Global-Notifications-Part-2 - Checkpoint

This commit is contained in:
Dave Richer
2025-03-04 17:07:31 -05:00
parent fd7850b551
commit 07faa5eec2
10 changed files with 230 additions and 31 deletions

View File

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

View File

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

View File

@@ -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
}
}
`;

View File

@@ -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();

View File

@@ -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<string>}: Fields to check for changes.
* - matchToUserFields {Array<string>}: Fields used to match scenarios to user data.
* - onNew {boolean|Array<boolean>}: Indicates whether the scenario should be triggered on new data.
* - onlyTrue {Array<string>}: Specifies fields that must be true for the scenario to match.
* - builder {Function}: A function to handle the scenario.
*/
* - onlyTruthyValues {boolean|Array<string>}: 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;
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
/**
* Update or invalidate bodyshop cache
* @param req
* @param res
* @returns {Promise<void>}
*/
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;