/** * @module scenarioParser * @description * This module exports a function that parses an event and triggers notification scenarios based on the event data. * It integrates with event parsing utilities, GraphQL queries, and notification queues to manage the dispatching * of notifications via email and app channels. The function processes event data, identifies relevant scenarios, * queries user notification preferences, and dispatches notifications accordingly. */ const eventParser = require("./eventParser"); const { client: gqlClient } = require("../graphql-client/graphql-client"); const queries = require("../graphql-client/queries"); const { isEmpty, isFunction } = require("lodash"); const { getMatchingScenarios } = require("./scenarioMapper"); const { dispatchEmailsToQueue } = require("./queues/emailQueue"); const { dispatchAppsToQueue } = require("./queues/appQueue"); // If true, the user who commits the action will NOT receive notifications; if false, they will. const FILTER_SELF_FROM_WATCHERS = process.env?.FILTER_SELF_FROM_WATCHERS !== "false"; /** * Parses an event and determines matching scenarios for notifications. * Queries job watchers and notification settings before triggering scenario builders. * * @param {Object} req - The request object containing event data, trigger, table, and logger. * @param {string} jobIdField - The field path (e.g., "req.body.event.new.id") to extract the job ID. * @returns {Promise} Resolves when the parsing and notification dispatching process is complete. * @throws {Error} If required request fields (event data, trigger, or table) or body shop data are missing. */ const scenarioParser = async (req, jobIdField) => { const { event, trigger, table } = req.body; const { logger, sessionUtils: { getBodyshopFromRedis } } = req; // Step 1: Validate we know what user committed the action that fired the parser const hasuraUserRole = event?.session_variables?.["x-hasura-role"]; const hasuraUserId = event?.session_variables?.["x-hasura-user-id"]; // Bail if we don't know who started the scenario if (hasuraUserRole === "user" && !hasuraUserId) { logger.log("No Hasura user ID found, skipping notification parsing", "info", "notifications"); return; } // Validate that required fields are present in the request body if (!event?.data || !trigger || !table) { throw new Error("Missing required request fields: event data, trigger, or table."); } // Step 2: Extract just the jobId using the provided jobIdField let jobId = null; if (jobIdField) { let keyName = jobIdField; const prefix = "req.body.event.new."; if (keyName.startsWith(prefix)) { keyName = keyName.slice(prefix.length); } jobId = event.data.new[keyName] || (event.data.old && event.data.old[keyName]) || null; } if (!jobId) { if (process?.env?.NODE_ENV === "development") { logger.log(`No jobId found using path "${jobIdField}", skipping notification parsing`, "info", "notifications"); } return; } // Step 3: Query job watchers associated with the job ID using GraphQL const watcherData = await gqlClient.request(queries.GET_JOB_WATCHERS, { jobid: jobId }); // Transform watcher data into a simplified format with email and employee details let jobWatchers = watcherData?.job_watchers?.map((watcher) => ({ email: watcher.user_email, firstName: watcher?.user?.employee?.first_name, lastName: watcher?.user?.employee?.last_name, employeeId: watcher?.user?.employee?.id, authId: watcher?.user?.authid })); if (FILTER_SELF_FROM_WATCHERS && hasuraUserRole === "user") { jobWatchers = jobWatchers.filter((watcher) => watcher.authId !== hasuraUserId); } // Exit early if no job watchers are found for this job if (isEmpty(jobWatchers)) { if (process?.env?.NODE_ENV === "development") { logger.log(`No watchers found for jobId "${jobId}", skipping notification parsing`, "info", "notifications"); } return; } // Step 5: Perform the full event diff now that we know there are watchers const eventData = await eventParser({ newData: event.data.new, oldData: event.data.old, trigger, table, jobId }); // Step 6: Extract body shop information from the job data const bodyShopId = watcherData?.job?.bodyshop?.id; const bodyShopName = watcherData?.job?.bodyshop?.shopname; const bodyShopTimezone = watcherData?.job?.bodyshop?.timezone; const jobRoNumber = watcherData?.job?.ro_number; const jobClaimNumber = watcherData?.job?.clm_no; // Validate that body shop data exists, as it’s required for notifications if (!bodyShopId || !bodyShopName) { throw new Error("No bodyshop data found for this job."); } // Step 7: Identify scenarios that match the event data and job context const matchingScenarios = await getMatchingScenarios( { ...eventData, jobWatchers, bodyShopId, bodyShopName }, getBodyshopFromRedis ); // Exit early if no matching scenarios are identified if (isEmpty(matchingScenarios)) { if (process?.env?.NODE_ENV === "development") { logger.log( `No matching scenarios found for jobId "${jobId}", skipping notification dispatch`, "info", "notifications" ); } return; } // Combine event data with additional context for scenario processing const finalScenarioData = { ...eventData, jobWatchers, bodyShopId, bodyShopName, bodyShopTimezone, matchingScenarios }; // Step 8: Query notification settings for the job watchers const associationsData = await gqlClient.request(queries.GET_NOTIFICATION_ASSOCIATIONS, { emails: jobWatchers.map((x) => x.email), shopid: bodyShopId }); // Exit early if no notification associations are found if (isEmpty(associationsData?.associations)) { if (process?.env?.NODE_ENV === "development") { logger.log( `No notification associations found for jobId "${jobId}", skipping notification dispatch`, "info", "notifications" ); } return; } // Step 9: Filter scenario watchers based on their enabled notification methods finalScenarioData.matchingScenarios = finalScenarioData.matchingScenarios.map((scenario) => ({ ...scenario, scenarioWatchers: associationsData.associations .filter((assoc) => { const settings = assoc.notification_settings && assoc.notification_settings[scenario.key]; // Include only watchers with at least one enabled notification method (app, email, or FCM) return settings && (settings.app || settings.email || settings.fcm); }) .map((assoc) => { const settings = assoc.notification_settings[scenario.key]; const watcherEmail = assoc.useremail; const matchingWatcher = jobWatchers.find((watcher) => watcher.email === watcherEmail); // Build watcher object with notification preferences and personal details return { user: watcherEmail, email: settings.email, app: settings.app, fcm: settings.fcm, firstName: matchingWatcher?.firstName, lastName: matchingWatcher?.lastName, employeeId: matchingWatcher?.employeeId, associationId: assoc.id }; }) })); // Exit early if no scenarios have eligible watchers after filtering if (isEmpty(finalScenarioData?.matchingScenarios)) { if (process?.env?.NODE_ENV === "development") { logger.log( `No eligible watchers after filtering for jobId "${jobId}", skipping notification dispatch`, "info", "notifications" ); } return; } // Step 10: Build and collect scenarios to dispatch notifications for const scenariosToDispatch = []; for (const scenario of finalScenarioData.matchingScenarios) { // Skip if no watchers or no builder function is defined for the scenario if (isEmpty(scenario.scenarioWatchers) || !isFunction(scenario.builder)) { continue; } let eligibleWatchers = scenario.scenarioWatchers; // Filter watchers to only those assigned to changed fields, if specified if (scenario.matchToUserFields && scenario.matchToUserFields.length > 0) { eligibleWatchers = scenario.scenarioWatchers.filter((watcher) => scenario.matchToUserFields.some( (field) => eventData.changedFieldNames.includes(field) && eventData.data[field]?.includes(watcher.employeeId) ) ); } // Skip if no watchers remain after filtering if (isEmpty(eligibleWatchers)) { continue; } // Step 11: Filter scenario fields to include only those that changed const filteredScenarioFields = scenario.fields?.filter((field) => eventData.changedFieldNames.includes(field)) || []; // Use the scenario’s builder to construct the notification data scenariosToDispatch.push( scenario.builder({ trigger: finalScenarioData.trigger.name, bodyShopId: finalScenarioData.bodyShopId, bodyShopName: finalScenarioData.bodyShopName, bodyShopTimezone: finalScenarioData.bodyShopTimezone, scenarioKey: scenario.key, scenarioTable: scenario.table, scenarioFields: filteredScenarioFields, scenarioBuilder: scenario.builder, scenarioWatchers: eligibleWatchers, jobId: finalScenarioData.jobId, jobRoNumber: jobRoNumber, jobClaimNumber: jobClaimNumber, isNew: finalScenarioData.isNew, changedFieldNames: finalScenarioData.changedFieldNames, changedFields: finalScenarioData.changedFields, data: finalScenarioData.data }) ); } if (isEmpty(scenariosToDispatch)) { if (process?.env?.NODE_ENV === "development") { logger.log(`No scenarios to dispatch for jobId "${jobId}" after building`, "info", "notifications"); } return; } // Step 12: Dispatch email notifications to the email queue const emailsToDispatch = scenariosToDispatch.map((scenario) => scenario?.email); if (!isEmpty(emailsToDispatch)) { dispatchEmailsToQueue({ emailsToDispatch, logger }).catch((e) => logger.log("Something went wrong dispatching emails to the Email Notification Queue", "error", "queue", null, { message: e?.message, stack: e?.stack }) ); } // Step 13: Dispatch app notifications to the app queue const appsToDispatch = scenariosToDispatch.map((scenario) => scenario?.app); if (!isEmpty(appsToDispatch)) { dispatchAppsToQueue({ appsToDispatch, logger }).catch((e) => logger.log("Something went wrong dispatching apps to the App Notification Queue", "error", "queue", null, { message: e?.message, stack: e?.stack }) ); } }; module.exports = scenarioParser;