From 2ee582bfa2d769fa1f14d0421f9d15c02e247348 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Tue, 11 Feb 2025 10:40:57 -0500 Subject: [PATCH] feature/IO-3096-GlobalNotifications - Check-point --- hasura/metadata/tables.yaml | 6 -- server/graphql-client/queries.js | 7 ++ .../eventHandlers/handeJobsChange.js | 12 ++- .../eventHandlers/handleTasksChange.js | 6 +- .../jobAssignedToMeBuilder.js | 6 ++ .../tasksUpdatedCreatedBuilder.js | 95 ++++++++----------- .../utils/notificationEmailQueue.js | 23 +++++ server/notifications/utils/scenarioMapperr.js | 5 +- server/notifications/utils/scenarioParser.js | 87 +++++++++++++---- 9 files changed, 156 insertions(+), 91 deletions(-) create mode 100644 server/notifications/scenarioBuilders/jobAssignedToMeBuilder.js create mode 100644 server/notifications/utils/notificationEmailQueue.js diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 62c2b8fbe..51bc81b76 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -4474,12 +4474,6 @@ - name: event-secret value_from_env: EVENT_SECRET request_transform: - body: - action: transform - template: |- - { - "success": true - } method: POST query_params: {} template_engine: Kriti diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index b24b6e6de..0dc6fd03a 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2695,6 +2695,13 @@ query GET_JOB_WATCHERS($jobid: uuid!) { job_watchers_aggregate(where: { jobid: { _eq: $jobid } }) { nodes { user_email + user { + employee { + id + first_name + last_name + } + } } } job: jobs_by_pk(id: $jobid) { diff --git a/server/notifications/eventHandlers/handeJobsChange.js b/server/notifications/eventHandlers/handeJobsChange.js index 9449f23c4..84b4ab8ab 100644 --- a/server/notifications/eventHandlers/handeJobsChange.js +++ b/server/notifications/eventHandlers/handeJobsChange.js @@ -1,5 +1,11 @@ -const handleJobsChange = (req, res) => { - return res.status(200).json({ message: "Jobs change handled." }); -}; +const scenarioParser = require("../utils/scenarioParser"); +const handleJobsChange = async (req, res) => { + const { logger } = req; + scenarioParser(req, `req.body.event.new.id`).catch((e) => + logger.log("notifications-error", "error", "notifications", null, { error: e?.message }) + ); + return res.status(200).json({ message: "Job Notifications Event Handled." }); +}; +// module.exports = handleJobsChange; diff --git a/server/notifications/eventHandlers/handleTasksChange.js b/server/notifications/eventHandlers/handleTasksChange.js index 71382cdd2..795f21944 100644 --- a/server/notifications/eventHandlers/handleTasksChange.js +++ b/server/notifications/eventHandlers/handleTasksChange.js @@ -2,8 +2,10 @@ const scenarioParser = require("../utils/scenarioParser"); const handleTasksChange = async (req, res) => { const { logger } = req; - scenarioParser(req).catch((e) => logger.log("notifications-error", "error", "jsr", null, { error: e?.message })); - return res.status(200).json({ message: "Notification Scenario Event Handled." }); + scenarioParser(req, "req.body.event.new.jobid").catch((e) => + logger.log("notifications-error", "error", "notifications", null, { error: e?.message }) + ); + return res.status(200).json({ message: "Tasks Notifications Event Handled." }); }; // module.exports = handleTasksChange; diff --git a/server/notifications/scenarioBuilders/jobAssignedToMeBuilder.js b/server/notifications/scenarioBuilders/jobAssignedToMeBuilder.js new file mode 100644 index 000000000..31bdefff6 --- /dev/null +++ b/server/notifications/scenarioBuilders/jobAssignedToMeBuilder.js @@ -0,0 +1,6 @@ +const consoleDir = require("../../utils/consoleDir"); +const jobAssignedToMeBuilder = (data) => { + consoleDir(data); +}; + +module.exports = jobAssignedToMeBuilder; diff --git a/server/notifications/scenarioBuilders/tasksUpdatedCreatedBuilder.js b/server/notifications/scenarioBuilders/tasksUpdatedCreatedBuilder.js index 036165972..365c31e20 100644 --- a/server/notifications/scenarioBuilders/tasksUpdatedCreatedBuilder.js +++ b/server/notifications/scenarioBuilders/tasksUpdatedCreatedBuilder.js @@ -1,64 +1,47 @@ const consoleDir = require("../../utils/consoleDir"); -const { sendTaskEmail } = require("../../email/sendemail"); -// { -// "changedFieldNames": [ -// "description", -// "updated_at" -// ], -// "changedFields": { -// "description": "sadasdasdasdsadssdsaddddsdsd", -// "updated_at": "2025-02-10T19:20:34.195086+00:00" -// }, -// "isNew": false, -// "data": { -// "assigned_to": "5e4f78a2-0f23-4e7e-920c-02a4e016b398", -// "billid": null, -// "bodyshopid": "71f8494c-89f0-43e0-8eb2-820b52d723bc", -// "completed": false, -// "completed_at": null, -// "created_at": "2025-02-09T20:02:46.839271+00:00", -// "created_by": "dave@imex.dev", -// "deleted": false, -// "deleted_at": null, -// "description": "sadasdasdasdsadssdsaddddsdsd", -// "due_date": null, -// "id": "ca1c49a9-3c26-46cb-bebd-4b93f02cad2a", -// "jobid": "ec1c26c7-b0ea-493f-9bba-30efc291e0fa", -// "joblineid": "84b5bbf9-ab57-4c77-abb0-8fdd8709c9ff", -// "partsorderid": null, -// "priority": 2, -// "remind_at": null, -// "remind_at_sent": null, -// "title": "sd", -// "updated_at": "2025-02-10T19:20:34.195086+00:00" -// }, -// "trigger": { -// "name": "notifications_tasks" -// }, -// "table": { -// "name": "tasks", -// "schema": "public" -// }, -// "jobId": "ec1c26c7-b0ea-493f-9bba-30efc291e0fa", -// "watchers": [ -// "dave@imex.dev" -// ], -// "bodyShopId": "71f8494c-89f0-43e0-8eb2-820b52d723bc", -// "bodyShopName": "Rome Online Collision DEMO" -// } +// node-app | { +// node-app | trigger: 'notifications_tasks', +// node-app | bodyShopId: '71f8494c-89f0-43e0-8eb2-820b52d723bc', +// node-app | bodyShopName: 'Rome Online Collision DEMO', +// node-app | scenarioKey: 'tasks-updated-created', +// node-app | scenarioTable: 'tasks', +// node-app | scenarioFields: [ 'updated_at' ], +// node-app | scenarioBuilder: [AsyncFunction: tasksUpdatedCreatedBuilder], +// node-app | scenarioWatchers: [ { user: 'dave@imex.dev', email: true, app: true, fcm: undefined } ], +// node-app | jobId: 'ec1c26c7-b0ea-493f-9bba-30efc291e0fa', +// node-app | isNew: false, +// node-app | changedFieldNames: [ 'description', 'updated_at' ], +// node-app | changedFields: { +// node-app | description: 'sadasdasdasdsadssdsaddddsdsddddddddddddsdsdddsddddddddddd', +// node-app | updated_at: '2025-02-10T23:02:21.244722+00:00' +// node-app | }, +// node-app | data: { +// node-app | assigned_to: '5e4f78a2-0f23-4e7e-920c-02a4e016b398', +// node-app | billid: null, +// node-app | bodyshopid: '71f8494c-89f0-43e0-8eb2-820b52d723bc', +// node-app | completed: false, +// node-app | completed_at: null, +// node-app | created_at: '2025-02-09T20:02:46.839271+00:00', +// node-app | created_by: 'dave@imex.dev', +// node-app | deleted: false, +// node-app | deleted_at: null, +// node-app | description: 'sadasdasdasdsadssdsaddddsdsddddddddddddsdsdddsddddddddddd', +// node-app | due_date: null, +// node-app | id: 'ca1c49a9-3c26-46cb-bebd-4b93f02cad2a', +// node-app | jobid: 'ec1c26c7-b0ea-493f-9bba-30efc291e0fa', +// node-app | joblineid: '84b5bbf9-ab57-4c77-abb0-8fdd8709c9ff', +// node-app | partsorderid: null, +// node-app | priority: 2, +// node-app | remind_at: null, +// node-app | remind_at_sent: null, +// node-app | title: 'sd', +// node-app | updated_at: '2025-02-10T23:02:21.244722+00:00' +// node-app | } +// node-app | } const tasksUpdatedCreatedBuilder = async (data) => { consoleDir(data); - // Step 0: Check to see if the users are watching the current scenario - - // Step 1: Dispatch Email to all watchers - // sendTaskEmail({ - // bcc: data.watchers, - // subject: `Task Updated: ${data.data.title}`, - // text: `Task Updated: ${data.data.title}` - // }); - // Step 2: Send notification and basic paramaters to a real time job queue to debounce potential multiple notifications }; module.exports = tasksUpdatedCreatedBuilder; diff --git a/server/notifications/utils/notificationEmailQueue.js b/server/notifications/utils/notificationEmailQueue.js new file mode 100644 index 000000000..8da63d500 --- /dev/null +++ b/server/notifications/utils/notificationEmailQueue.js @@ -0,0 +1,23 @@ +const path = require("path"); +require("dotenv").config({ + path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) +}); +const Queue = require("better-queue"); + +const logger = require("../../utils/logger"); + +const notificationsEmailQueue = () => + new Queue( + (taskIds, cb) => { + logger.log("Processing Notification Emails: ", "silly", null, null); + cb(null); + }, + { + batchSize: 50, + batchDelay: 5000, + // The lower this is, the more likely we are to hit the rate limit. + batchDelayTimeout: 1000 + } + ); + +module.exports = { notificationsEmailQueue }; diff --git a/server/notifications/utils/scenarioMapperr.js b/server/notifications/utils/scenarioMapperr.js index dbdc802c0..0323bbaba 100644 --- a/server/notifications/utils/scenarioMapperr.js +++ b/server/notifications/utils/scenarioMapperr.js @@ -1,13 +1,12 @@ -const tasksUpdatedCreatedBuilder = require("../scenarioBuilders/tasksUpdatedCreatedBuilder"); - // Key: scenario name // Table: table name to check for changes // Fields: fields to check for changes // OnNew: whether the scenario should be triggered on new data // Builder: function to handle the scenario +const tasksUpdatedCreatedBuilder = require("../scenarioBuilders/tasksUpdatedCreatedBuilder"); const notificationScenarios = [ - { key: "job-assigned-to-me", table: "jobs" }, + { key: "job-assigned-to-me", table: "jobs", fields: ["scheduled_in", "scheduled_completion", "scheduled_delivery"] }, { key: "bill-posted", table: "bills" }, { key: "new-note-added", table: "notes", onNew: true }, { diff --git a/server/notifications/utils/scenarioParser.js b/server/notifications/utils/scenarioParser.js index 9ad0c47f3..0d9115a38 100644 --- a/server/notifications/utils/scenarioParser.js +++ b/server/notifications/utils/scenarioParser.js @@ -4,9 +4,10 @@ const queries = require("../../graphql-client/queries"); const { isEmpty, isFunction } = require("lodash"); const { getMatchingScenarios } = require("./scenarioMapperr"); -const scenarioParser = async (req) => { +const scenarioParser = async (req, jobIdField) => { // Destructure required fields from the request body const { event, trigger, table } = req.body; + if (!event?.data || !trigger || !table) { throw new Error("Missing required request fields: event data, trigger, or table."); } @@ -17,19 +18,29 @@ const scenarioParser = async (req) => { oldData: event.data.old, trigger, table, - jobIdField: `req.body.event.new.jobid` + jobIdField }); // Step 2: Query jobWatchers for this job const watcherData = await gqlClient.request(queries.GET_JOB_WATCHERS, { jobid: eventData.jobId }); - const jobWatchers = watcherData?.job_watchers_aggregate?.nodes?.map((watcher) => watcher.user_email); - if (isEmpty(jobWatchers)) return; + + const jobWatchers = watcherData?.job_watchers_aggregate?.nodes?.map((watcher) => ({ + email: watcher.user_email, + firstName: watcher?.user?.employee?.first_name, + lastName: watcher?.user?.employee?.last_name, + employeeId: watcher?.user?.employee?.id + })); + + if (isEmpty(jobWatchers)) { + return; + } // Step 3: Infer bodyshop information from the job and validate const bodyShopId = watcherData?.job?.bodyshop?.id; const bodyShopName = watcherData?.job?.bodyshop?.shopname; + if (!bodyShopId || !bodyShopName) { throw new Error("No bodyshop data found for this job."); } @@ -41,7 +52,10 @@ const scenarioParser = async (req) => { bodyShopId, bodyShopName }); - if (isEmpty(matchingScenarios)) return; + + if (isEmpty(matchingScenarios)) { + return; + } // Prepare the final scenario data const finalScenarioData = { @@ -55,17 +69,20 @@ const scenarioParser = async (req) => { // Step 5: Query associations (notification_settings) for each watcher // Filter by both useremail and shopid const associationsData = await gqlClient.request(queries.GET_NOTIFICATION_ASSOCIATIONS, { - emails: jobWatchers, + emails: jobWatchers.map((x) => x.email), shopid: bodyShopId }); - if (isEmpty(associationsData?.associations)) return; + if (isEmpty(associationsData?.associations)) { + return; + } // Step 6: For each matching scenario, add a scenarioWatchers property // that includes only the jobWatchers with at least one notification method enabled. // Each watcher object is formatted as: { user, email, app, fcm } - finalScenarioData.matchingScenarios.forEach((scenario) => { - scenario.scenarioWatchers = associationsData.associations + finalScenarioData.matchingScenarios = finalScenarioData.matchingScenarios.map((scenario) => ({ + ...scenario, + scenarioWatchers: associationsData.associations .filter((assoc) => { // Retrieve the settings object for this scenario (it now contains app, email, and fcm) const settings = assoc.notification_settings && assoc.notification_settings[scenario.key]; @@ -74,26 +91,54 @@ const scenarioParser = async (req) => { }) .map((assoc) => { const settings = assoc.notification_settings[scenario.key]; + // Determine the email from the association—either from assoc.user or assoc.useremail + const watcherEmail = assoc.user || assoc.useremail; + // Find the matching watcher object from jobWatchers using the email address + const matchingWatcher = jobWatchers.find((watcher) => watcher.email === watcherEmail); + return { - // Use assoc.user if available, otherwise fallback to assoc.useremail as the identifier - user: assoc.user || assoc.useremail, - // The email field here is the user's email notification setting (boolean) + // This is the common identifier (email in this case) + user: watcherEmail, + // Notification settings for this scenario email: settings.email, app: settings.app, - fcm: settings.fcm + fcm: settings.fcm, + // Additional fields from the watcher lookup + firstName: matchingWatcher ? matchingWatcher.firstName : undefined, + lastName: matchingWatcher ? matchingWatcher.lastName : undefined, + employeeId: matchingWatcher ? matchingWatcher.employeeId : undefined }; - }); - }); + }) + })); + + if (isEmpty(finalScenarioData?.matchingScenarios)) { + return; + } // Step 7: Call builder functions for each matching scenario (fire-and-forget) // Only invoke a builder if its scenario has at least one watcher - finalScenarioData.matchingScenarios.forEach((scenario) => { - if (!isEmpty(scenario.scenarioWatchers) && isFunction(scenario.builder)) { - scenario - .builder(finalScenarioData) - .catch((error) => console.error(`Error in builder for scenario '${scenario.key}':`, error)); + for (const scenario of finalScenarioData.matchingScenarios) { + if (isEmpty(scenario.scenarioWatchers) || !isFunction(scenario.builder)) { + continue; } - }); + scenario + .builder({ + trigger: finalScenarioData.trigger.name, + bodyShopId: finalScenarioData.bodyShopId, + bodyShopName: finalScenarioData.bodyShopName, + scenarioKey: scenario.key, + scenarioTable: scenario.table, + scenarioFields: scenario.fields, + scenarioBuilder: scenario.builder, + scenarioWatchers: scenario.scenarioWatchers, + jobId: finalScenarioData.jobId, + isNew: finalScenarioData.isNew, + changedFieldNames: finalScenarioData.changedFieldNames, + changedFields: finalScenarioData.changedFields, + data: finalScenarioData.data + }) + .catch((error) => console.error(`Error in builder for scenario '${scenario.key}':`, error)); + } }; module.exports = scenarioParser;