From 8ae3b28cb6d8dde7d8de5940aee6be98c64e1147 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 12 Mar 2025 11:34:50 -0400 Subject: [PATCH 1/3] IO-3166-Global-Notifications-Part-2: checkpoint, Modify additional strings as per Allan, Refactor builder down to prevent duplicate logic, comment out supplement imported. --- client/src/utils/jobNotificationScenarios.js | 6 +- server/notifications/scenarioBuilders.js | 996 +++++++------------ server/notifications/scenarioParser.js | 12 - 3 files changed, 364 insertions(+), 650 deletions(-) diff --git a/client/src/utils/jobNotificationScenarios.js b/client/src/utils/jobNotificationScenarios.js index 52519977b..aeab82861 100644 --- a/client/src/utils/jobNotificationScenarios.js +++ b/client/src/utils/jobNotificationScenarios.js @@ -1,10 +1,13 @@ +/** Notification Scenarios + * @description This file contains the scenarios for job notifications. + * @type {string[]} + */ const notificationScenarios = [ "job-assigned-to-me", "bill-posted", "critical-parts-status-changed", "part-marked-back-ordered", "new-note-added", - "supplement-imported", "schedule-dates-changed", "tasks-updated-created", "new-media-added-reassigned", @@ -14,6 +17,7 @@ const notificationScenarios = [ "job-status-change", "payment-collected-completed", "alternate-transport-changed" + // "supplement-imported", // Disabled for now ]; export { notificationScenarios }; diff --git a/server/notifications/scenarioBuilders.js b/server/notifications/scenarioBuilders.js index a9dd54d2d..817f467bb 100644 --- a/server/notifications/scenarioBuilders.js +++ b/server/notifications/scenarioBuilders.js @@ -6,624 +6,20 @@ const Dinero = require("dinero.js"); Dinero.globalRoundingMode = "HALF_EVEN"; /** - * Populates the recipients for app, email, and FCM notifications based on scenario watchers. - * - * @param {Object} data - The data object containing scenarioWatchers and bodyShopId. - * @param {Object} result - The result object to populate with recipients for app, email, and FCM notifications. + * Creates a standard notification object with app, email, and FCM properties and populates recipients. + * @param {Object} data - Input data containing jobId, jobRoNumber, bodyShopId, bodyShopName, and scenarioWatchers + * @param {string} key - Notification key for the app + * @param {string} body - Notification body text + * @param {Object} [variables={}] - Variables for the app notification + * @returns {Object} Notification object with populated recipients */ -const populateWatchers = (data, result) => { - data.scenarioWatchers.forEach((recipients) => { - const { user, app, fcm, email, firstName, lastName, employeeId, associationId } = recipients; - if (app === true) - result.app.recipients.push({ - user, - bodyShopId: data.bodyShopId, - employeeId, - associationId - }); - if (fcm === true) result.fcm.recipients.push(user); - if (email === true) result.email.recipients.push({ user, firstName, lastName }); - }); -}; - -/** - * Builds notification data for changes to alternate transport. - */ -const alternateTransportChangedBuilder = (data) => { - const body = `The alternate transportation has been changed from ${data.changedFields.alt_transport?.old || "unset"} to ${data?.changedFields?.alt_transport?.new || "unset"}.`; - const result = { - app: { - jobId: data.jobId, - bodyShopId: data.bodyShopId, - jobRoNumber: data.jobRoNumber, - key: "notifications.job.alternateTransportChanged", - body, - variables: { - alternateTransport: data?.changedFields?.alt_transport?.new, - oldAlternateTransport: data?.changedFields?.alt_transport?.old - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for bill posted events. - */ -const billPostedHandler = (data) => { - const facing = data?.data?.isinhouse ? "in-house" : "vendor"; - const body = `An ${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim(); - +const buildNotification = (data, key, body, variables = {}) => { const result = { app: { jobId: data.jobId, jobRoNumber: data.jobRoNumber, bodyShopId: data.bodyShopId, - key: "notifications.job.billPosted", - body, - variables: { - isInHouse: data?.data?.isinhouse, - isCreditMemo: data?.data?.is_credit_memo - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for changes to critical parts status. - */ -// -const criticalPartsStatusChangedBuilder = (data) => { - const body = `The status on a critical part line (${data?.data?.line_desc}) has changed to ${data?.data?.status || "unset"}.`; - - const result = { - app: { - jobId: data.jobId, - bodyShopId: data.bodyShopId, - jobRoNumber: data.jobRoNumber, - key: "notifications.job.criticalPartsStatusChanged", - body, - variables: { - joblineId: data?.data?.id, // If we want to deeplink to the jobline - status: data?.data?.status, - line_desc: data?.data?.line_desc - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for completed intake or delivery checklists. - */ -const intakeDeliveryChecklistCompletedBuilder = (data) => { - const checklistType = data?.changedFields?.intakechecklist ? "intake" : "delivery"; - const body = `The ${checklistType.charAt(0).toUpperCase() + checklistType.slice(1)} checklist has been completed.`; - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: "notifications.job.checklistCompleted", - body, - variables: { - checklistType, - completed: true - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for job assignment events. - */ -const jobAssignedToMeBuilder = (data) => { - const body = `You have been assigned to ${getJobAssignmentType(data.scenarioFields?.[0])}.`; - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: "notifications.job.assigned", - body, - variables: { - type: data.scenarioFields?.[0] - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for jobs added to production. - */ -const jobsAddedToProductionBuilder = (data) => { - const body = `Job is now in production.`; - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: "notifications.job.addedToProduction", - body, - variables: {}, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for job status changes. - */ -const jobStatusChangeBuilder = (data) => { - const body = `The status has changed from ${data?.changedFields?.status?.old || "unset"} to ${data?.changedFields?.status?.new || "unset"}`; - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: "notifications.job.statusChanged", - body, - variables: { - status: data.changedFields.status.new, - oldStatus: data.changedFields.status.old - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for new media added or reassigned events. - */ -const newMediaAddedReassignedBuilder = (data) => { - // Determine if it's an image or document - const mediaType = data?.data?.type?.startsWith("image") ? "Image" : "Document"; - - // Determine the action - let action; - - if (data?.data?._documentMoved) { - action = "moved to another job"; // Special case for document moved from this job - } else if (data.isNew) { - action = "added"; // New media - } else if (data.changedFields?.jobid && data.changedFields.jobid.old !== data.changedFields.jobid.new) { - action = "moved to this job"; - } else { - action = "updated"; - } - - // Construct the body string - const body = `An ${mediaType} has been ${action}.`; - - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: "notifications.job.newMediaAdded", - body, - variables: { - mediaType, - action, - movedToJob: data?.data?._movedToJob - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for new notes added to a job. - */ -const newNoteAddedBuilder = (data) => { - const body = [ - "A", - data?.data?.critical && "critical", - data?.data?.private && "private", - data?.data?.type, - "note has been added by", - `${data.data.created_by}` - ] - .filter(Boolean) - .join(" "); - - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: "notifications.job.newNoteAdded", - body, - variables: { - createdBy: data?.data?.created_by, - critical: data?.data?.critical, - type: data?.data?.type, - private: data?.data?.private - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for new time tickets posted. - */ -const newTimeTicketPostedBuilder = (data) => { - const type = data?.data?.cost_center; - const body = `A ${startCase(type.toLowerCase())} time ticket for ${data?.data?.date} has been posted.`.trim(); - - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: "notifications.job.newTimeTicketPosted", - body, - variables: { - type, - date: data?.data?.date - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for parts marked as back-ordered. - */ -const partMarkedBackOrderedBuilder = (data) => { - const body = `A part ${data?.data?.line_desc} has been marked as back-ordered.`; - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: "notifications.job.partBackOrdered", - body, - variables: { - line_desc: data?.data?.line_desc - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for payment collection events. - */ -const paymentCollectedCompletedBuilder = (data) => { - const momentFormat = "MM/DD/YYYY"; - - // Format amount using Dinero.js - const amountDinero = Dinero({ - amount: Math.round((data.data.amount || 0) * 100) // Convert to cents, default to 0 if missing - }); - - const amountFormatted = amountDinero.toFormat(); - - const payer = data.data.payer; - const paymentType = data.data.type; - const paymentDate = moment(data.data.date).format(momentFormat); - - const body = `Payment of ${amountFormatted} has been collected from ${payer} via ${paymentType} on ${paymentDate}`; - - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: "notifications.job.paymentCollected", - body, - variables: { - amount: data.data.amount, - payer: data.data.payer, - type: data.data.type, - date: data.data.date - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for changes to scheduled dates. - */ -const scheduledDatesChangedBuilder = (data) => { - const changedFields = data.changedFields; - - // Define field configurations - const fieldConfigs = { - scheduled_in: "Scheduled In", - scheduled_completion: "Scheduled Completion", - scheduled_delivery: "Scheduled Delivery" - }; - - // Helper function to format date and time with "at" - const formatDateTime = (date) => { - if (!date) return "unset"; - const formatted = moment(date).tz(data.bodyShopTimezone); - const datePart = formatted.format("MM/DD/YYYY"); - const timePart = formatted.format("hh:mm a"); - return `${datePart} at ${timePart}`; - }; - - // Build field messages dynamically - const fieldMessages = Object.entries(fieldConfigs) - .filter(([field]) => changedFields[field]) // Only include changed fields - .map(([field, label]) => { - const { old, new: newValue } = changedFields[field]; - - // Case 1: Scheduled date cancelled (from value to null) - if (old && !newValue) { - return `${label} was cancelled (previously ${formatDateTime(old)}).`; - } - // Case 2: Scheduled date set (from null to value) - else if (!old && newValue) { - return `${label} was set to ${formatDateTime(newValue)}.`; - } - // Case 3: Scheduled date changed (from value to value) - else if (old && newValue) { - return `${label} changed from ${formatDateTime(old)} to ${formatDateTime(newValue)}.`; - } - return ""; // Fallback, though this shouldn't happen with the filter - }) - .filter(Boolean); // Remove any empty strings - - const body = fieldMessages.length > 0 ? fieldMessages.join(" ") : "Scheduled dates have been updated."; - - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: "notifications.job.scheduledDatesChanged", - body, - variables: { - scheduledIn: changedFields.scheduled_in?.new, - oldScheduledIn: changedFields.scheduled_in?.old, - scheduledCompletion: changedFields.scheduled_completion?.new, - oldScheduledCompletion: changedFields.scheduled_completion?.old, - scheduledDelivery: changedFields.scheduled_delivery?.new, - oldScheduledDelivery: changedFields.scheduled_delivery?.old - }, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; -}; - -/** - * Builds notification data for tasks updated or created. - */ -const tasksUpdatedCreatedBuilder = (data) => { - const momentFormat = "MM/DD/YYYY hh:mm a"; - const timezone = data.bodyShopTimezone; - const taskTitle = data?.data?.title ? `"${data.data.title}"` : "Unnamed Task"; - - let body; - let variables; - - if (data.isNew) { - // Created case - const priority = formatTaskPriority(data?.data?.priority); - const createdBy = data?.data?.created_by || "Unknown"; // Fallback for undefined created_by - const dueDate = data.data.due_date ? ` due on ${moment(data.data.due_date).tz(timezone).format(momentFormat)}` : ""; - const completedOnCreation = data.data.completed === true; - body = `A ${priority} task ${taskTitle} has been created${completedOnCreation ? " and marked completed" : ""} by ${createdBy}${dueDate}.`; - variables = { - isNew: data.isNew, - roNumber: data.jobRoNumber, - title: data?.data?.title, - priority: data?.data?.priority, - createdBy: data?.data?.created_by, - dueDate: data?.data?.due_date, - completed: completedOnCreation ? data?.data?.completed : undefined // Only include if true - }; - } else { - // Updated case - const changedFields = data.changedFields; - const fieldNames = Object.keys(changedFields); - const oldTitle = changedFields.title ? `"${changedFields.title.old || "Unnamed Task"}"` : taskTitle; - - // Special case: Only 'completed' changed - if (fieldNames.length === 1 && changedFields.completed) { - body = `Task ${oldTitle} was marked ${changedFields.completed.new ? "complete" : "incomplete"}`; - variables = { - isNew: data.isNew, - roNumber: data.jobRoNumber, - title: data?.data?.title, - changedCompleted: data?.changedFields?.completed?.new - }; - } else { - // General update case - const fieldMessages = []; - - if (changedFields.title) { - fieldMessages.push(`Task ${oldTitle} changed title to "${changedFields.title.new || "unnamed task"}".`); - } - if (changedFields.description) { - fieldMessages.push("Description updated."); - } - if (changedFields.priority) { - fieldMessages.push(`Priority changed to ${formatTaskPriority(changedFields.priority.new)}.`); - } - if (changedFields.due_date) { - fieldMessages.push(`Due date set to ${moment(changedFields.due_date.new).tz(timezone).format(momentFormat)}.`); - } - if (changedFields.completed) { - fieldMessages.push(`Status changed to ${changedFields.completed.new ? "complete" : "incomplete"}.`); - } - - body = - fieldMessages.length > 0 - ? fieldMessages.length === 1 && changedFields.title - ? fieldMessages[0] // If only title changed, use it standalone - : `Task ${oldTitle} updated: ${fieldMessages.join(", ")}` - : `Task ${oldTitle} has been updated.`; - variables = { - isNew: data.isNew, - roNumber: data.jobRoNumber, - title: data?.data?.title, - changedTitleOld: data?.changedFields?.title?.old, - changedTitleNew: data?.changedFields?.title?.new, - changedPriority: data?.changedFields?.priority?.new, - changedDueDate: data?.changedFields?.due_date?.new, - changedCompleted: data?.changedFields?.completed?.new - }; - } - } - - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: data.isNew ? "notifications.job.taskCreated" : "notifications.job.taskUpdated", + key, body, variables, recipients: [] @@ -638,38 +34,364 @@ const tasksUpdatedCreatedBuilder = (data) => { fcm: { recipients: [] } }; - populateWatchers(data, result); + // Populate recipients from scenarioWatchers + data.scenarioWatchers.forEach((recipients) => { + const { user, app, fcm, email, firstName, lastName, employeeId, associationId } = recipients; + if (app === true) + result.app.recipients.push({ + user, + bodyShopId: data.bodyShopId, + employeeId, + associationId + }); + if (fcm === true) result.fcm.recipients.push(user); + if (email === true) result.email.recipients.push({ user, firstName, lastName }); + }); + return result; }; /** - * Builds notification data for supplement imported events. - * TODO: This is an advanced case and will be done later + * Creates a notification for when the alternate transport is changed. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const alternateTransportChangedBuilder = (data) => { + const oldTransport = data?.changedFields?.alt_transport?.old; + const newTransport = data?.changedFields?.alt_transport?.new; + let body; + + if (oldTransport && newTransport) + body = `The alternate transportation has been changed from ${oldTransport} to ${newTransport}.`; + else if (!oldTransport && newTransport) body = `The alternate transportation has been set to ${newTransport}.`; + else if (oldTransport && !newTransport) + body = `The alternate transportation has been canceled (previously ${oldTransport}).`; + else body = `The alternate transportation has been updated.`; + + return buildNotification(data, "notifications.job.alternateTransportChanged", body, { + alternateTransport: newTransport, + oldAlternateTransport: oldTransport + }); +}; + +/** + * Creates a notification for when a bill is posted. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const billPostedHandler = (data) => { + const facing = data?.data?.isinhouse ? "in-house" : "vendor"; + const body = `An ${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim(); + + return buildNotification(data, "notifications.job.billPosted", body, { + isInHouse: data?.data?.isinhouse, + isCreditMemo: data?.data?.is_credit_memo + }); +}; + +/** + * Creates a notification for when the status of critical parts changes. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const criticalPartsStatusChangedBuilder = (data) => { + const lineDesc = data?.data?.line_desc; + const status = data?.data?.status; + const body = status + ? `The status on a critical part line (${lineDesc}) has been set to ${status}.` + : `The status on a critical part line (${lineDesc}) has been cleared.`; + + return buildNotification(data, "notifications.job.criticalPartsStatusChanged", body, { + joblineId: data?.data?.id, + status: data?.data?.status, + line_desc: lineDesc + }); +}; + +/** + * Creates a notification for when the intake or delivery checklist is completed. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const intakeDeliveryChecklistCompletedBuilder = (data) => { + const checklistType = data?.changedFields?.intakechecklist ? "intake" : "delivery"; + const body = `The ${checklistType.charAt(0).toUpperCase() + checklistType.slice(1)} checklist has been completed.`; + + return buildNotification(data, "notifications.job.checklistCompleted", body, { + checklistType, + completed: true + }); +}; + +/** + * Creates a notification for when a job is assigned to the user. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const jobAssignedToMeBuilder = (data) => { + const body = `You have been assigned to ${getJobAssignmentType(data.scenarioFields?.[0])}.`; + + return buildNotification(data, "notifications.job.assigned", body, { + type: data.scenarioFields?.[0] + }); +}; + +/** + * Creates a notification for when jobs are added to production. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const jobsAddedToProductionBuilder = (data) => { + const body = `Job is now in production.`; + return buildNotification(data, "notifications.job.addedToProduction", body); +}; + +/** + * Creates a notification for when the job status changes. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const jobStatusChangeBuilder = (data) => { + const oldStatus = data?.changedFields?.status?.old; + const newStatus = data?.changedFields?.status?.new; + let body; + + if (oldStatus && newStatus) body = `The status has been changed from ${oldStatus} to ${newStatus}.`; + else if (!oldStatus && newStatus) body = `The status has been set to ${newStatus}.`; + else if (oldStatus && !newStatus) body = `The status has been cleared (previously ${oldStatus}).`; + else body = `The status has been updated.`; + + return buildNotification(data, "notifications.job.statusChanged", body, { + status: newStatus, + oldStatus: oldStatus + }); +}; + +/** + * Creates a notification for when new media is added or reassigned. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const newMediaAddedReassignedBuilder = (data) => { + const mediaType = data?.data?.type?.startsWith("image") ? "Image" : "Document"; + const action = data?.data?._documentMoved + ? "moved to another job" + : data.isNew + ? "added" + : data.changedFields?.jobid && data.changedFields.jobid.old !== data.changedFields.jobid.new + ? "moved to this job" + : "updated"; + const body = `An ${mediaType} has been ${action}.`; + + return buildNotification(data, "notifications.job.newMediaAdded", body, { + mediaType, + action, + movedToJob: data?.data?._movedToJob + }); +}; + +/** + * Creates a notification for when a new note is added. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const newNoteAddedBuilder = (data) => { + const body = [ + "A", + data?.data?.critical && "critical", + data?.data?.private && "private", + data?.data?.type, + "note has been added by", + `${data.data.created_by}` + ] + .filter(Boolean) + .join(" "); + + return buildNotification(data, "notifications.job.newNoteAdded", body, { + createdBy: data?.data?.created_by, + critical: data?.data?.critical, + type: data?.data?.type, + private: data?.data?.private + }); +}; + +/** + * Creates a notification for when a new time ticket is posted. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const newTimeTicketPostedBuilder = (data) => { + const type = data?.data?.cost_center; + const body = `A ${startCase(type.toLowerCase())} time ticket for ${data?.data?.date} has been posted.`.trim(); + + return buildNotification(data, "notifications.job.newTimeTicketPosted", body, { + type, + date: data?.data?.date + }); +}; + +/** + * Creates a notification for when a part is marked as back-ordered. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const partMarkedBackOrderedBuilder = (data) => { + const body = `A part ${data?.data?.line_desc} has been marked as back-ordered.`; + + return buildNotification(data, "notifications.job.partBackOrdered", body, { + line_desc: data?.data?.line_desc + }); +}; + +/** + * Creates a notification for when payment is collected or completed. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const paymentCollectedCompletedBuilder = (data) => { + const momentFormat = "MM/DD/YYYY"; + const amountDinero = Dinero({ amount: Math.round((data.data.amount || 0) * 100) }); + const amountFormatted = amountDinero.toFormat(); + const payer = data.data.payer; + const paymentType = data.data.type; + const paymentDate = moment(data.data.date).format(momentFormat); + const body = `Payment of ${amountFormatted} has been collected from ${payer} via ${paymentType} on ${paymentDate}`; + + return buildNotification(data, "notifications.job.paymentCollected", body, { + amount: data.data.amount, + payer: data.data.payer, + type: data.data.type, + date: data.data.date + }); +}; + +/** + * Creates a notification for when scheduled dates are changed. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const scheduledDatesChangedBuilder = (data) => { + const changedFields = data.changedFields; + const fieldConfigs = { + scheduled_in: "Scheduled In", + scheduled_completion: "Scheduled Completion", + scheduled_delivery: "Scheduled Delivery" + }; + const formatDateTime = (date) => { + if (!date) return "(no date set)"; + const formatted = moment(date).tz(data.bodyShopTimezone); + return `${formatted.format("MM/DD/YYYY")} at ${formatted.format("hh:mm a")}`; + }; + + const fieldMessages = Object.entries(fieldConfigs) + .filter(([field]) => changedFields[field]) + .map(([field, label]) => { + const { old, new: newValue } = changedFields[field]; + if (old && !newValue) return `${label} was cancelled (previously ${formatDateTime(old)}).`; + else if (!old && newValue) return `${label} was set to ${formatDateTime(newValue)}.`; + else if (old && newValue) return `${label} changed from ${formatDateTime(old)} to ${formatDateTime(newValue)}.`; + return ""; + }) + .filter(Boolean); + + const body = fieldMessages.length > 0 ? fieldMessages.join(" ") : "Scheduled dates have been updated."; + + return buildNotification(data, "notifications.job.scheduledDatesChanged", body, { + scheduledIn: changedFields.scheduled_in?.new, + oldScheduledIn: changedFields.scheduled_in?.old, + scheduledCompletion: changedFields.scheduled_completion?.new, + oldScheduledCompletion: changedFields.scheduled_completion?.old, + scheduledDelivery: changedFields.scheduled_delivery?.new, + oldScheduledDelivery: changedFields.scheduled_delivery?.old + }); +}; + +/** + * Creates a notification for when tasks are updated or created. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} + */ +const tasksUpdatedCreatedBuilder = (data) => { + const momentFormat = "MM/DD/YYYY hh:mm a"; + const timezone = data.bodyShopTimezone; + const taskTitle = data?.data?.title ? `"${data.data.title}"` : "Unnamed Task"; + + let body, variables; + if (data.isNew) { + const priority = formatTaskPriority(data?.data?.priority); + const createdBy = data?.data?.created_by || "Unknown"; + const dueDate = data.data.due_date ? ` due on ${moment(data.data.due_date).tz(timezone).format(momentFormat)}` : ""; + const completedOnCreation = data.data.completed === true; + body = `A ${priority} task ${taskTitle} has been created${completedOnCreation ? " and marked completed" : ""} by ${createdBy}${dueDate}.`; + variables = { + isNew: data.isNew, + roNumber: data.jobRoNumber, + title: data?.data?.title, + priority: data?.data?.priority, + createdBy: data?.data?.created_by, + dueDate: data?.data?.due_date, + completed: completedOnCreation ? data?.data?.completed : undefined + }; + } else { + const changedFields = data.changedFields; + const fieldNames = Object.keys(changedFields); + const oldTitle = changedFields.title ? `"${changedFields.title.old || "Unnamed Task"}"` : taskTitle; + + if (fieldNames.length === 1 && changedFields.completed) { + body = `Task ${oldTitle} was marked ${changedFields.completed.new ? "complete" : "incomplete"}`; + variables = { + isNew: data.isNew, + roNumber: data.jobRoNumber, + title: data?.data?.title, + changedCompleted: changedFields.completed.new + }; + } else { + const fieldMessages = []; + if (changedFields.title) + fieldMessages.push(`Task ${oldTitle} changed title to "${changedFields.title.new || "unnamed task"}".`); + if (changedFields.description) fieldMessages.push("Description updated."); + if (changedFields.priority) + fieldMessages.push(`Priority changed to ${formatTaskPriority(changedFields.priority.new)}.`); + if (changedFields.due_date) + fieldMessages.push(`Due date set to ${moment(changedFields.due_date.new).tz(timezone).format(momentFormat)}.`); + if (changedFields.completed) + fieldMessages.push(`Status changed to ${changedFields.completed.new ? "complete" : "incomplete"}.`); + + body = + fieldMessages.length > 0 + ? fieldMessages.length === 1 && changedFields.title + ? fieldMessages[0] + : `Task ${oldTitle} updated: ${fieldMessages.join(", ")}` + : `Task ${oldTitle} has been updated.`; + variables = { + isNew: data.isNew, + roNumber: data.jobRoNumber, + title: data?.data?.title, + changedTitleOld: changedFields.title?.old, + changedTitleNew: changedFields.title?.new, + changedPriority: changedFields.priority?.new, + changedDueDate: changedFields.due_date?.new, + changedCompleted: changedFields.completed?.new + }; + } + } + + return buildNotification( + data, + data.isNew ? "notifications.job.taskCreated" : "notifications.job.taskUpdated", + body, + variables + ); +}; + +/** + * Creates a notification for when a supplement is imported. + * @param data + * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} */ const supplementImportedBuilder = (data) => { const body = `A supplement has been imported.`; - const result = { - app: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopId: data.bodyShopId, - key: "notifications.job.supplementImported", - body, - variables: {}, - recipients: [] - }, - email: { - jobId: data.jobId, - jobRoNumber: data.jobRoNumber, - bodyShopName: data.bodyShopName, - body, - recipients: [] - }, - fcm: { recipients: [] } - }; - - populateWatchers(data, result); - return result; + return buildNotification(data, "notifications.job.supplementImported", body); }; module.exports = { diff --git a/server/notifications/scenarioParser.js b/server/notifications/scenarioParser.js index d5c9069a0..ddf1ff103 100644 --- a/server/notifications/scenarioParser.js +++ b/server/notifications/scenarioParser.js @@ -35,7 +35,6 @@ const scenarioParser = async (req, jobIdField) => { } = req; // Step 1: Validate we know what user committed the action that fired the parser - // console.log("Step 1"); const hasuraUserRole = event?.session_variables?.["x-hasura-role"]; const hasuraUserId = event?.session_variables?.["x-hasura-user-id"]; @@ -52,7 +51,6 @@ const scenarioParser = async (req, jobIdField) => { } // Step 2: Extract just the jobId using the provided jobIdField - // console.log("Step 2"); let jobId = null; if (jobIdField) { @@ -70,7 +68,6 @@ const scenarioParser = async (req, jobIdField) => { } // Step 3: Query job watchers associated with the job ID using GraphQL - // console.log("Step 3"); const watcherData = await gqlClient.request(queries.GET_JOB_WATCHERS, { jobid: jobId @@ -96,7 +93,6 @@ const scenarioParser = async (req, jobIdField) => { } // Step 5: Perform the full event diff now that we know there are watchers - // console.log("Step 5"); const eventData = await eventParser({ newData: event.data.new, @@ -107,7 +103,6 @@ const scenarioParser = async (req, jobIdField) => { }); // Step 6: Extract body shop information from the job data - // console.log("Step 6"); const bodyShopId = watcherData?.job?.bodyshop?.id; const bodyShopName = watcherData?.job?.bodyshop?.shopname; @@ -122,7 +117,6 @@ const scenarioParser = async (req, jobIdField) => { } // Step 7: Identify scenarios that match the event data and job context - // console.log("Step 7"); const matchingScenarios = await getMatchingScenarios( { @@ -155,7 +149,6 @@ const scenarioParser = async (req, jobIdField) => { }; // Step 8: Query notification settings for the job watchers - // console.log("Step 8"); const associationsData = await gqlClient.request(queries.GET_NOTIFICATION_ASSOCIATIONS, { emails: jobWatchers.map((x) => x.email), @@ -173,7 +166,6 @@ const scenarioParser = async (req, jobIdField) => { } // Step 9: Filter scenario watchers based on their enabled notification methods - // console.log("Step 9"); finalScenarioData.matchingScenarios = finalScenarioData.matchingScenarios.map((scenario) => ({ ...scenario, @@ -213,7 +205,6 @@ const scenarioParser = async (req, jobIdField) => { } // Step 10: Build and collect scenarios to dispatch notifications for - // console.log("Step 10"); const scenariosToDispatch = []; @@ -240,7 +231,6 @@ const scenarioParser = async (req, jobIdField) => { } // Step 11: Filter scenario fields to include only those that changed - // console.log("Step 11"); const filteredScenarioFields = scenario.fields?.filter((field) => eventData.changedFieldNames.includes(field)) || []; @@ -274,7 +264,6 @@ const scenarioParser = async (req, jobIdField) => { } // Step 12: Dispatch email notifications to the email queue - // console.log("Step 12"); const emailsToDispatch = scenariosToDispatch.map((scenario) => scenario?.email); if (!isEmpty(emailsToDispatch)) { @@ -287,7 +276,6 @@ const scenarioParser = async (req, jobIdField) => { } // Step 13: Dispatch app notifications to the app queue - // console.log("Step 13"); const appsToDispatch = scenariosToDispatch.map((scenario) => scenario?.app); if (!isEmpty(appsToDispatch)) { From 9ef8440e64c78315693b097893fbc9ca066f35c9 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 12 Mar 2025 11:46:09 -0400 Subject: [PATCH 2/3] IO-3166-Global-Notifications-Part-2: Add Enabled key to scenario map (backend), filter out scenarios not enabled. --- server/notifications/scenarioMapper.js | 27 ++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/server/notifications/scenarioMapper.js b/server/notifications/scenarioMapper.js index c125f3952..27d290a66 100644 --- a/server/notifications/scenarioMapper.js +++ b/server/notifications/scenarioMapper.js @@ -30,10 +30,12 @@ const { isFunction } = require("lodash"); * - builder {Function}: A function to handle the scenario. * - onlyTruthyValues {boolean|Array}: Specifies fields that must have truthy values for the scenario to match. * - filterCallback {Function}: Optional callback (sync or async) to further filter the scenario based on event data (returns boolean). + * - enabled {boolean}: If true, the scenario is active; if false or omitted, the scenario is skipped. */ const notificationScenarios = [ { key: "job-assigned-to-me", + enabled: true, table: "jobs", fields: ["employee_prep", "employee_body", "employee_csr", "employee_refinish"], matchToUserFields: ["employee_prep", "employee_body", "employee_csr", "employee_refinish"], @@ -41,24 +43,28 @@ const notificationScenarios = [ }, { key: "bill-posted", + enabled: true, table: "bills", builder: billPostedHandler, onNew: true }, { key: "new-note-added", + enabled: true, table: "notes", builder: newNoteAddedBuilder, onNew: true }, { key: "schedule-dates-changed", + enabled: true, table: "jobs", fields: ["scheduled_in", "scheduled_completion", "scheduled_delivery"], builder: scheduledDatesChangedBuilder }, { key: "tasks-updated-created", + enabled: true, table: "tasks", fields: ["updated_at"], // onNew: true, @@ -66,12 +72,14 @@ const notificationScenarios = [ }, { key: "job-status-change", + enabled: true, table: "jobs", fields: ["status"], builder: jobStatusChangeBuilder }, { key: "job-added-to-production", + enabled: true, table: "jobs", fields: ["inproduction"], onlyTruthyValues: ["inproduction"], @@ -79,36 +87,42 @@ const notificationScenarios = [ }, { key: "alternate-transport-changed", + enabled: true, table: "jobs", fields: ["alt_transport"], builder: alternateTransportChangedBuilder }, { key: "new-time-ticket-posted", + enabled: true, table: "timetickets", builder: newTimeTicketPostedBuilder }, { key: "intake-delivery-checklist-completed", + enabled: true, table: "jobs", fields: ["intakechecklist", "deliverchecklist"], builder: intakeDeliveryChecklistCompletedBuilder }, { key: "payment-collected-completed", + enabled: true, table: "payments", onNew: true, builder: paymentCollectedCompletedBuilder }, { - // MAKE SURE YOU ARE NOT ON A LMS ENVIRONMENT + // Only works on a non LMS ENV key: "new-media-added-reassigned", + enabled: true, table: "documents", fields: ["jobid"], builder: newMediaAddedReassignedBuilder }, { key: "critical-parts-status-changed", + enabled: true, table: "joblines", fields: ["status"], onlyTruthyValues: ["status"], @@ -117,6 +131,7 @@ const notificationScenarios = [ }, { key: "part-marked-back-ordered", + enabled: true, table: "joblines", fields: ["status"], builder: partMarkedBackOrderedBuilder, @@ -133,12 +148,11 @@ const notificationScenarios = [ } } }, - // -------------- Difficult --------------- - // Holding off on this one for now + // Holding off on this one for now, spans multiple tables { key: "supplement-imported", + enabled: false, builder: supplementImportedBuilder - // spans multiple tables, } ]; @@ -159,6 +173,11 @@ const notificationScenarios = [ const getMatchingScenarios = async (eventData, getBodyshopFromRedis) => { const matches = []; for (const scenario of notificationScenarios) { + // Check if the scenario is enabled; skip if not explicitly true + if (scenario.enabled !== true) { + continue; + } + // If eventData has a table, then only scenarios with a table property that matches should be considered. if (eventData.table) { if (!scenario.table || eventData.table.name !== scenario.table) { From 87db292e5d3e3ab29a303079a44a486c423901ab Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Wed, 12 Mar 2025 12:05:21 -0400 Subject: [PATCH 3/3] IO-3166-Global-Notifications-Part-2: Fix typo in builder function name --- server/notifications/scenarioBuilders.js | 4 ++-- server/notifications/scenarioMapper.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/notifications/scenarioBuilders.js b/server/notifications/scenarioBuilders.js index 817f467bb..19b1d9f72 100644 --- a/server/notifications/scenarioBuilders.js +++ b/server/notifications/scenarioBuilders.js @@ -79,7 +79,7 @@ const alternateTransportChangedBuilder = (data) => { * @param data * @returns {{app: {jobId, jobRoNumber: *, bodyShopId: *, key: string, body: string, variables: Object, recipients: *[]}, email: {jobId, jobRoNumber: *, bodyShopName: *, body: string, recipients: *[]}, fcm: {recipients: *[]}}} */ -const billPostedHandler = (data) => { +const billPostedBuilder = (data) => { const facing = data?.data?.isinhouse ? "in-house" : "vendor"; const body = `An ${facing} ${data?.data?.is_credit_memo ? "credit memo" : "bill"} has been posted.`.trim(); @@ -396,7 +396,7 @@ const supplementImportedBuilder = (data) => { module.exports = { alternateTransportChangedBuilder, - billPostedHandler, + billPostedBuilder, criticalPartsStatusChangedBuilder, intakeDeliveryChecklistCompletedBuilder, jobAssignedToMeBuilder, diff --git a/server/notifications/scenarioMapper.js b/server/notifications/scenarioMapper.js index 27d290a66..73a97cd5d 100644 --- a/server/notifications/scenarioMapper.js +++ b/server/notifications/scenarioMapper.js @@ -1,6 +1,6 @@ const { jobAssignedToMeBuilder, - billPostedHandler, + billPostedBuilder, newNoteAddedBuilder, scheduledDatesChangedBuilder, tasksUpdatedCreatedBuilder, @@ -45,7 +45,7 @@ const notificationScenarios = [ key: "bill-posted", enabled: true, table: "bills", - builder: billPostedHandler, + builder: billPostedBuilder, onNew: true }, {